From 73b554c8ce9dccabe1eccf074dffdccb670fce18 Mon Sep 17 00:00:00 2001
From: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com>
Date: Mon, 24 Aug 2026 13:35:32 -0400
Subject: [PATCH 1/4] fix(alarms): strip only paired markdown from the rule
sentence
cleanRuleSummary removed every asterisk, underscore and backtick in the
string. PoracleNG's rowtext emits only **bold** today, so nothing on screen
changes -- but it interpolates template names, areas and saved-place labels
into that sentence unescaped, and work_gym is a name, not italics.
Stripping is now pair-aware: code spans first (a backtick pair wins over
emphasis inside it), then **bold**, *italic*, __underline__ and _italic_.
The two underscore forms additionally require a non-word character outside
the delimiter, which is the boundary Discord itself applies, so an
underscore between two word characters is left alone.
Verified against PoracleNG 5.2.1 on the dev instance: every live description
returned by /api/tracking/allProfiles?includeDescriptions=true uses ** and
nothing else, and all of them come through unchanged.
---
.../src/app/shared/utils/rule-summary.spec.ts | 16 +++++++++
.../src/app/shared/utils/rule-summary.ts | 33 +++++++++++++------
CHANGELOG.md | 1 +
3 files changed, 40 insertions(+), 10 deletions(-)
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.spec.ts
index 3a182389..6e969527 100644
--- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.spec.ts
+++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.spec.ts
@@ -33,6 +33,22 @@ describe('cleanRuleSummary', () => {
);
});
+ it('leaves an underscore that is part of a name alone', () => {
+ // Poracle interpolates user-chosen strings -- template names, and the areas and saved-place labels
+ // a scope override renders -- straight into this sentence without escaping them. A place called
+ // work_gym is not italics, and losing the underscore renames it on the card.
+ expect(cleanRuleSummary('**Bulbasaur** | distance: 5000m | template: my_template ')).toBe(
+ 'Bulbasaur | distance: 5000m | template: my_template',
+ );
+ expect(cleanRuleSummary('**Pikachu** | areas: north_side, east_side ')).toBe('Pikachu | areas: north_side, east_side');
+ });
+
+ it('strips the other Discord emphasis, but only where it is paired', () => {
+ expect(cleanRuleSummary('_Bulbasaur_ | distance: 5000m')).toBe('Bulbasaur | distance: 5000m');
+ expect(cleanRuleSummary('__Bulbasaur__ | distance: 5000m')).toBe('Bulbasaur | distance: 5000m');
+ expect(cleanRuleSummary('*Bulbasaur* | `iv: 90%-100%`')).toBe('Bulbasaur | iv: 90%-100%');
+ });
+
it('is empty for the absent, null and blank cases, so the card renders nothing', () => {
expect(cleanRuleSummary(undefined)).toBe('');
expect(cleanRuleSummary(null)).toBe('');
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.ts
index 65c1b21d..9692e27f 100644
--- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.ts
+++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/rule-summary.ts
@@ -4,21 +4,34 @@
* The upstream string is written for Discord, so it carries markdown emphasis and the loose spacing
* that survives a chat client: `**Bulbasaur** | distance: 5000m | iv: 90%-100% `. The bold always
* wraps the species or the level, which is already the card's heading, so keeping it would double the
- * emphasis and fight the `
` rather than help it. Everything here is plain-text transformation —
+ * emphasis and fight the `` rather than help it. Everything here is plain-text transformation --
* the result is interpolated, never handed to innerHTML.
+ *
+ * The stripping is deliberately pair-aware rather than a sweep of `[*_`]`. PoracleNG interpolates
+ * user-chosen strings into this sentence without escaping them -- `rowtext` builds `**%s**` around a
+ * template name, an area or a saved-place label -- and `work_gym` is a name, not italics. Discord
+ * agrees: an underscore flanked by word characters emphasises nothing. So a delimiter is only removed
+ * where it has a partner, and the underscore forms additionally need a non-word character on the
+ * outside, which is exactly the boundary Discord applies.
*/
export function cleanRuleSummary(raw: null | string | undefined): string {
if (!raw) return '';
- return raw
- .replace(/\*\*/g, '')
- .replace(/__/g, '')
- .replace(/[*_`]/g, '')
- .replace(/\s+/g, ' ')
- .replace(/\s*\|\s*/g, ' | ')
- .replace(/^[\s|]+/, '')
- .replace(/[\s|]+$/, '')
- .trim();
+ return (
+ raw
+ // Code spans first: inside a backtick pair Discord renders the rest literally, so unwrapping the
+ // span before looking for emphasis stops a `*` in code being read as a delimiter.
+ .replace(/`([^`\n]+)`/g, '$1')
+ .replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '$1')
+ .replace(/\*(?=\S)([^*\n]*?\S)\*/g, '$1')
+ .replace(/(?
Date: Mon, 24 Aug 2026 13:40:39 -0400
Subject: [PATCH 2/4] test(profiles): pin the schedule pills to a live language
switch
Reported as a defect from #816: the pill labels call translate.instant
inside a computed, instant is not a signal, so a language switch should
leave already-rendered pills in the previous language.
It does not, and the reason is worth writing down rather than papering
over. @ngx-translate v18 backs its store with signals, and instant() reads
_currentLang and _translations on the way to a value -- so calling it
inside a computed registers them as dependencies and the labels invalidate
on a switch like any other signal read. The premise held on the versions
before v18; this repo has been on v18 since #377, well before #816.
So: no behaviour change, because there is no defect to fix. What was
missing is the guard. The new spec switches language through I18nService,
the path the language menu takes, and asserts the rendered pill follows.
Confirmed it goes red when the instant() call is wrapped in untracked(),
which is exactly the shape a future ngx-translate dropping those signal
reads would produce.
---
.../active-hours-chip.component.spec.ts | 22 +++++++++++++++++++
.../active-hours-chip.component.ts | 9 ++++++++
2 files changed, 31 insertions(+)
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.spec.ts
index f861feaa..fa479f56 100644
--- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.spec.ts
+++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.spec.ts
@@ -3,6 +3,7 @@ import { provideTranslateService, TranslateService } from '@ngx-translate/core';
import { ActiveHoursChipComponent } from './active-hours-chip.component';
import { ActiveHourEntry } from '../../../core/models/active-hours.models';
+import { I18nService } from '../../../core/services/i18n.service';
describe('ActiveHoursChipComponent', () => {
let component: ActiveHoursChipComponent;
@@ -106,6 +107,27 @@ describe('ActiveHoursChipComponent', () => {
expect(component.pills()[0].label).toBe('Weekends 9:00 AM–5:30 PM, every 2h');
});
+ it('follows a live display-language switch', () => {
+ // The labels come from translate.instant inside a computed, which only stays current because
+ // instant reads the translation store's signals -- an implementation detail of @ngx-translate
+ // v18 that older versions did not have. Switching through I18nService is the path the language
+ // menu takes, so this fails if either half stops holding.
+ const i18n = TestBed.inject(I18nService);
+ const translate = TestBed.inject(TranslateService);
+ withEnglishRangeStrings();
+ translate.setTranslation('it', { PROFILES: { ACTIVE_HOURS_RANGE_HOURLY: '{{days}} {{start}}–{{end}}, ogni ora' } }, true);
+
+ fixture.componentRef.setInput('activeHours', [{ day: 1, endHours: 17, endMins: 0, hours: 9, mins: 0, step: 1 }] as ActiveHourEntry[]);
+ fixture.detectChanges();
+ expect(fixture.nativeElement.querySelector('.chip-active').textContent).toContain('hourly');
+
+ i18n.use('it');
+ fixture.detectChanges();
+
+ expect(fixture.nativeElement.querySelector('.chip-active').textContent).toContain('ogni ora');
+ expect(component.pills()[0].label).toBe('Mon 9:00 AM–5:00 PM, ogni ora');
+ });
+
it('should leave a single fire label untouched', () => {
withEnglishRangeStrings();
fixture.componentRef.setInput('activeHours', [{ day: 1, hours: 9, mins: 0 }] as ActiveHourEntry[]);
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.ts
index b3170167..dde59c02 100644
--- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.ts
+++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-chip/active-hours-chip.component.ts
@@ -21,6 +21,15 @@ export class ActiveHoursChipComponent {
readonly isEmpty = computed(() => this.activeHours().length === 0);
+ /**
+ * Reactive to a display-language switch, and by a route worth stating out loud: `translate.instant`
+ * reads the store's `_currentLang` and `_translations` signals, so calling it inside a `computed`
+ * registers them as dependencies and switching language invalidates these labels the same way a new
+ * schedule does. That is true of @ngx-translate v18 and was not true of the versions before it, where
+ * this shape went stale until something else knocked `activeHours`. The spec switches the language and
+ * asserts the rendered pill follows; if a future version stops reading those signals it goes red here
+ * rather than in front of a user.
+ */
readonly pills = computed(() =>
this.groups().map(g => ({
label: formatRuleLabel(g, (key, params) => this.translate.instant(key, params)),
From 5864e6388d07e41264e67506c8635e79ee1e09b6 Mon Sep 17 00:00:00 2001
From: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com>
Date: Mon, 24 Aug 2026 13:41:38 -0400
Subject: [PATCH 3/4] refactor(quests): drop isStardust from the quest edit
dialog
usesRewardSlot took over in the template when #821 gave pokecoins the same
control, and nothing else read isStardust -- only its own two assertions,
which is a property kept alive by the test that tests it.
The assertions were worth keeping, so they now name usesRewardSlot: on a
stardust rule it is the flag that puts the floor field on screen, and on
the pokemon-encounter twin it is the flag that keeps reward from being
overwritten on save. STARDUST stays, REWARD_SLOT_TYPES is built from it.
---
.../app/modules/quests/quest-edit-dialog.component.spec.ts | 4 ++--
.../src/app/modules/quests/quest-edit-dialog.component.ts | 2 --
2 files changed, 2 insertions(+), 4 deletions(-)
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.spec.ts
index e716224c..12d6a074 100644
--- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.spec.ts
+++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.spec.ts
@@ -168,7 +168,7 @@ describe('QuestEditDialogComponent', () => {
it('offers the stardust floor, which PoracleNG keeps in reward', () => {
setup({ ...baseQuest, pokemonId: 0, reward: 1000, rewardType: 3 });
- expect(component.isStardust).toBe(true);
+ expect(component.usesRewardSlot).toBe(true);
expect(component.form.controls.stardust.value).toBe(1000);
component.form.controls.stardust.setValue(1500);
@@ -183,7 +183,7 @@ describe('QuestEditDialogComponent', () => {
setup({ ...baseQuest, reward: 25, rewardType: 7 });
expect(component.hasAmount).toBe(false);
- expect(component.isStardust).toBe(false);
+ expect(component.usesRewardSlot).toBe(false);
component.save();
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts
index 8b2ca893..ab37b21f 100644
--- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts
+++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-edit-dialog.component.ts
@@ -94,8 +94,6 @@ export class QuestEditDialogComponent {
readonly isPokecoins = this.data.rewardType === POKECOINS;
- readonly isStardust = this.data.rewardType === STARDUST;
-
readonly isWebhook = inject(AuthService).isImpersonating();
saving = signal(false);
From e7df0684babf5da9041103bc25e882dee665cc11 Mon Sep 17 00:00:00 2001
From: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com>
Date: Mon, 24 Aug 2026 13:45:31 -0400
Subject: [PATCH 4/4] docs(changelog): one heading per section under Unreleased
Squash-merging nine branches in a row left the section with two Added blocks and
two Changed blocks. Each merge resolved its CHANGELOG conflict by keeping both
sides, which is right for the bullets and wrong for the heading above them.
The release workflow promotes this section verbatim, so the published notes would
have carried the duplicates.
Bullets are unchanged and all fifteen are still here; only the headings are merged,
into Keep a Changelog's order.
---
CHANGELOG.md | 14 +++++---------
1 file changed, 5 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 66443123..5f2e17a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,15 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Quest alarms can track PokéCoin rewards.** Poracle started accepting them in 5.2.0, so quest alarms gained a sixth reward tab that works the way Stardust does: a minimum amount and no item picker, because Poracle matches this reward on the amount alone. The tab appears only on a Poracle new enough to store it -- an older one refuses the reward type outright -- and a PokéCoin rule you already have, set with the bot or left behind by a downgrade, stays visible and deletable either way.
- **A request an older Poracle cannot serve now says which feature it was and what the server would need.** Nothing throws it yet, so there is no visible change today: it is the answer waiting for the first control that depends on a newer PoracleNG than the one an instance is pointed at. Until now such a request came back either in Poracle's own wording, which names a database column, or as a plain server error. It now answers with the feature named as this site names it and the version or database migration that would serve it, in words, beside the control that asked -- rather than borrowing the disabled-by-your-administrator wording, which would be untrue and would send the reader looking for a switch nobody turned off.
-
-### Documentation
-
-- **The PoracleNG version compatibility page has been rewritten, and its premise replaced.** It described PoracleNG as having two long-lived branches and this site supporting both; develop shipped as 5.2.1 and merged, so the real question is 5.1.0 versus 5.2.1 and newer. It now covers how support is decided, why a database migration number is a better thing to gate on than a version string, which features need a newer server, how to add another, and two traps worth knowing: PoracleNG's v1 API is unchanged on 5.2.1 -- the new error format and status codes in its release notes apply to v2 only -- and its OpenAPI document numbers the days of the week differently from the scheduler that reads them.
-### Changed
-
-- **A refused alarm explains itself the same way whichever Poracle surface answered.** The v2 write path already read PoracleNG's newer RFC 9457 error bodies and named the individual field it refused; the older v1 path, still the one most installs use, was reading only the older shape and answering a validation refusal as though the server had broken. Both paths now share one reader, and where many fields are refused at once the message names the first few and counts the rest rather than rendering a dozen clauses into a snackbar ([#803](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/803)).
-### Added
-
- **Every alarm card says in a sentence what its rule actually does.** A card gave you the species and a row of filter chips, and working out that a rule meant "Bulbasaur within 5 km, 90% IV or better, level 20 to 35" was a matter of decoding the chips. Poracle already writes that sentence -- it is the same wording the bot answers a `!pokemon` command with -- and was returning it on every read of this page, where it was thrown away. It now sits at the foot of each card, under a hairline, below the chips that still read first when you are scanning forty rules. Long ones are clamped to two lines with a control to open them. On the nine card types Poracle words well; fort-change cards keep their chips, because the sentence Poracle renders for them still has a raw JSON array in the middle of it. The line appears only when the language Poracle writes your alerts in is the language you are reading the site in, so a card never carries two languages at once -- and only on a Poracle new enough to send it, which older instances are not; in both cases the card is exactly what it was before ([#810](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/810)).
- **Quiet one gym, one area or one species for a while, instead of deleting the rule.** A notifications-paused button now sits in the actions row of every alarm card that names something specific -- Pokemon and nest cards name a species, gym, raid and egg cards name a gym, max battle cards name a station -- and on the Areas page, on both the checklist rows and the selected-area chips. Pick a duration from fifteen minutes to a day and that subject goes quiet; the button becomes a live countdown, and pressing it again extends or lifts it. The dashboard grows a Quiet card while anything is silenced, which lists everything including quiet periods set from the Discord bot and offers Resume on each. This is not *Pause Alerts*, which stays what it was: account-wide, indefinite, and saved. A quiet period is one subject for a while, and Poracle holds it in memory -- a restart of the processor clears every one, which the sheet says out loud rather than showing a deadline it cannot keep. Requires PoracleNG 5.2.0 or newer; on anything older the control does not appear at all ([#809](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/809)).
- **Showcases, Kecleon and Gold Stops are trackable in their own right.** They were only ever reachable through the invasion add dialog, which files them as ordinary invasions, so the alert that arrived described a Team Rocket encounter that was not there. There is now a *Pokéstop Events* page: pick the events you want, set a radius or areas, and Poracle formats them with its showcase template. The rules live in the same table invasions do, so the invasion list stops listing them and the dashboard counts them separately — the total across all alarm types is unchanged, but the Invasions figure will drop by however many event alarms you had, and a *Pokéstop Events* figure appears beside it ([#806](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/806)).
@@ -29,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
+- **A refused alarm explains itself the same way whichever Poracle surface answered.** The v2 write path already read PoracleNG's newer RFC 9457 error bodies and named the individual field it refused; the older v1 path, still the one most installs use, was reading only the older shape and answering a validation refusal as though the server had broken. Both paths now share one reader, and where many fields are refused at once the message names the first few and counts the rest rather than rendering a dozen clauses into a snackbar ([#803](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/803)).
- **Pokemon alarm edits are written through PoracleNG 5.2.1’s strict `/api/v2` surface, where the server can tell an edit apart from a takeover.** Nothing changes on screen. What changes is underneath: an edit now addresses the rule by its id, so Poracle refuses outright if the uid is not yours or if the result would duplicate an alarm you already have, instead of PoracleWeb.NET having to work that out from a success response and undo it afterwards. Poracle also explains a rejected filter field by name now, so the message on the dialog says which one. Anything older than 5.2.0 keeps the surface it has always used, unchanged, and so do the other nine alarm types; an edit carrying anything the new surface cannot express takes the old path rather than failing. Set `PORACLE_TRACKING_API_VERSION` to `v1` or `v2` to pin it ([#805](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/805)).
### Fixed
@@ -38,6 +30,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **A delegate configured in Poracle's own config can see their webhooks again.** Poracle reports a delegated webhook by the name the operator wrote in `webhook_admins`, and this site matched those strings against the webhook's URL, so a name matched nothing: the *My Webhooks* item appeared in the sidebar, the page it led to was empty, and the button on it would have been refused. Grants are now resolved to the webhook they name, whether the config names it by name or by URL, and a grant that names no webhook at all no longer puts an item in the sidebar ([#797](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/797)).
- **Impersonating a delegate shows what the delegate sees.** *My Webhooks* was hidden inside an impersonation session -- the one place an admin looks to find out why someone is complaining -- while the page and its actions would both have answered. Impersonating a second account from inside that session is refused, since only one token can be held for the way back out ([#797](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/797)).
+### Documentation
+
+- **The PoracleNG version compatibility page has been rewritten, and its premise replaced.** It described PoracleNG as having two long-lived branches and this site supporting both; develop shipped as 5.2.1 and merged, so the real question is 5.1.0 versus 5.2.1 and newer. It now covers how support is decided, why a database migration number is a better thing to gate on than a version string, which features need a newer server, how to add another, and two traps worth knowing: PoracleNG's v1 API is unchanged on 5.2.1 -- the new error format and status codes in its release notes apply to v2 only -- and its OpenAPI document numbers the days of the week differently from the scheduler that reads them.
+
## [2.17.1] - 2026-08-21
### Changed