From 34a7c141b8d53374b0c82f244186fb0e31c38569 Mon Sep 17 00:00:00 2001 From: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:47:19 -0400 Subject: [PATCH 1/5] 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. --- .../ClientApp/src/app/core/models/index.ts | 51 ++++++++ .../src/app/core/services/places.service.ts | 45 +++++++ .../where-chip/where-chip.component.html | 4 + .../where-chip/where-chip.component.scss | 34 ++++++ .../where-chip/where-chip.component.ts | 59 +++++++++ .../where-sheet/where-sheet.component.html | 74 ++++++++++++ .../where-sheet/where-sheet.component.scss | 45 +++++++ .../where-sheet/where-sheet.component.ts | 112 ++++++++++++++++++ .../src/app/shared/utils/alarm-scope.spec.ts | 91 ++++++++++++++ .../src/app/shared/utils/alarm-scope.ts | 109 +++++++++++++++++ .../ClientApp/src/assets/i18n/da.json | 16 +++ .../ClientApp/src/assets/i18n/de.json | 16 +++ .../ClientApp/src/assets/i18n/en.json | 16 +++ .../ClientApp/src/assets/i18n/es.json | 16 +++ .../ClientApp/src/assets/i18n/fr.json | 16 +++ .../ClientApp/src/assets/i18n/it.json | 16 +++ .../ClientApp/src/assets/i18n/nl.json | 16 +++ .../ClientApp/src/assets/i18n/pl.json | 16 +++ .../ClientApp/src/assets/i18n/pt-BR.json | 16 +++ .../ClientApp/src/assets/i18n/pt.json | 16 +++ .../ClientApp/src/assets/i18n/sv.json | 16 +++ CHANGELOG.md | 4 + 22 files changed, 804 insertions(+) create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/places.service.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.html create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.scss create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.html create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.scss create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.ts diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/index.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/index.ts index 955551bb..1d538c87 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/index.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/index.ts @@ -24,6 +24,8 @@ export interface Monster { minIv: number; minLevel: number; minWeight: number; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; ping?: string | null; pokemonId: number; profileNo: number; @@ -54,6 +56,8 @@ export interface Raid { id: string; level: number; move: number; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; ping?: string | null; pokemonId: number; profileNo: number; @@ -78,6 +82,8 @@ export interface MaxBattle { id: string; level: number; move: number; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; ping?: string; pokemonId: number; profileNo: number; @@ -99,6 +105,8 @@ export interface Egg { gymId: string | null; id: string; level: number; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; ping?: string | null; profileNo: number; rsvpChanges: number; @@ -117,6 +125,8 @@ export interface Quest { clean: number; distance: number; id: string; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; ping?: string | null; pokemonId: number; profileNo: number; @@ -139,6 +149,8 @@ export interface Invasion { gender: number; gruntType: string | null; id: string; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; ping?: string | null; profileNo: number; template: string | null; @@ -156,6 +168,8 @@ export interface Lure { distance: number; id: string; lureId: number; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; ping?: string | null; profileNo: number; template: string | null; @@ -173,6 +187,8 @@ export interface Nest { distance: number; id: string; minSpawnAvg: number; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; ping?: string | null; pokemonId: number; profileNo: number; @@ -192,6 +208,8 @@ export interface FortChange { fortType: string | null; id: string; includeEmpty: number; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; ping?: string | null; profileNo: number; template: string | null; @@ -210,6 +228,8 @@ export interface Gym { distance: number; gymId: string | null; id: string; + overrideAreas?: null | string[]; + overrideLocationLabel?: null | string; ping?: string | null; profileNo: number; slotChanges: number; @@ -655,3 +675,34 @@ export interface ProfileOverviewProfile { name: string; profile_no: number; } + +/** A named coordinate an alarm can be anchored to, instead of the profile pin. */ +export interface SavedPlace { + label: string; + latitude: number; + longitude: number; +} + +/** Everywhere a user's alarms can be anchored: the profile pin, plus whatever they have named. */ +export interface SavedPlaces { + /** The profile pin every alarm falls back to. Absent when the user has never set a location. */ + default?: null | SavedPlace; + named: SavedPlace[]; +} + +/** + * Where an alarm reaches the user. Three answers, and they are mutually exclusive by construction — + * PoracleNG refuses a place with areas, areas with a radius, or a place with no radius, so the UI + * models the choice as one of three rather than as three independent fields. + */ +export type AlarmScopeMode = 'areas' | 'place' | 'profile'; + +export interface AlarmScope { + /** Only for 'areas'. */ + areas?: string[]; + /** Only for 'place', in kilometres, as the dialogs already work in km. */ + distanceKm?: number; + mode: AlarmScopeMode; + /** Only for 'place'. */ + placeLabel?: string; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/places.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/places.service.ts new file mode 100644 index 00000000..aac3608c --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/places.service.ts @@ -0,0 +1,45 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, computed, inject, signal } from '@angular/core'; +import { Observable, tap } from 'rxjs'; + +import { ConfigService } from './config.service'; +import { SavedPlace, SavedPlaces } from '../models'; + +/** + * The places a user can point an alarm at. + * + * Held as a signal because the Where sheet, the Places screen and every alarm card read the same + * list, and a place added in one has to show up in the others without a reload. + */ +@Injectable({ providedIn: 'root' }) +export class PlacesService { + private readonly config = inject(ConfigService); + private readonly http = inject(HttpClient); + private readonly places = signal(null); + + /** Named places only. Empty until {@link load} has run. */ + readonly named = computed(() => this.places()?.named ?? []); + + /** The profile pin, which every alarm falls back to. */ + readonly pin = computed(() => this.places()?.default ?? null); + + add(place: SavedPlace): Observable { + return this.http.post(`${this.config.apiHost}/api/location/places`, place).pipe(tap(updated => this.places.set(updated))); + } + + load(): Observable { + return this.http.get(`${this.config.apiHost}/api/location/places`).pipe(tap(places => this.places.set(places))); + } + + /** + * Deletes a place. Answers 409 with `referencingRules` when alarms still point at it — the caller + * should name them rather than reporting a bare failure. + */ + remove(label: string): Observable { + return this.http + .delete(`${this.config.apiHost}/api/location/places/${encodeURIComponent(label)}`) + .pipe( + tap(() => this.places.update(current => (current ? { ...current, named: current.named.filter(p => p.label !== label) } : current))), + ); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.html new file mode 100644 index 00000000..92308b28 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.html @@ -0,0 +1,4 @@ + + {{ icon() }} + {{ label() }} + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.scss new file mode 100644 index 00000000..8bc91857 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.scss @@ -0,0 +1,34 @@ +.where-chip { + align-items: center; + background: var(--chip-bg, rgb(0 0 0 / 6%)); + border-radius: 999px; + color: var(--chip-fg, inherit); + display: inline-flex; + font-size: 0.75rem; + gap: 0.25rem; + line-height: 1.4; + max-width: 100%; + padding: 0.15rem 0.55rem; +} + +// The inherited scope is on almost every card, so it recedes; an override is the exception and reads +// as one. +.where-chip-inherited { + opacity: 0.72; +} + +.where-chip-editable { + cursor: pointer; +} + +.where-chip-icon { + font-size: 1rem; + height: 1rem; + width: 1rem; +} + +.where-chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.ts new file mode 100644 index 00000000..067f3a4e --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.ts @@ -0,0 +1,59 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslateService } from '@ngx-translate/core'; + +import { AlarmScope, describeScope, scopeOf } from '../../utils/alarm-scope'; + +/** + * Where an alarm reaches you, as a sentence fragment: "Anywhere in my areas", "Within 2 km of Home", + * "Only in Terrigal, Erina". + * + * Every alarm has always had an answer to this; before per-alarm scope it was an invisible inherited + * one. The chip states it on the card and is the way into the Where sheet, so the same control reads + * and edits the same idea wherever it appears. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatIconModule, MatTooltipModule], + selector: 'app-where-chip', + standalone: true, + styleUrl: './where-chip.component.scss', + templateUrl: './where-chip.component.html', +}) +export class WhereChipComponent { + private readonly translate = inject(TranslateService); + + /** The alarm's radius in metres, as PoracleNG stores it. */ + readonly distance = input(0); + + /** False on a read-only surface, where the chip states the scope without offering to change it. */ + readonly editable = input(true); + + /** Areas the alarm is confined to, when it has any. */ + readonly overrideAreas = input(null); + + /** Saved place the alarm measures its radius from, when it has one. */ + readonly overrideLocationLabel = input(null); + + readonly scope = computed(() => scopeOf(this.overrideLocationLabel(), this.overrideAreas(), this.distance())); + + readonly icon = computed(() => { + switch (this.scope().mode) { + case 'areas': + return 'map'; + case 'place': + return 'place'; + default: + return 'public'; + } + }); + + /** The inherited case is the quiet one: it is the default, and most cards will show it. */ + readonly isInherited = computed(() => this.scope().mode === 'profile'); + + /** Areas the profile subscribes to, used only to describe the inherited case. */ + readonly profileAreas = input([]); + + readonly label = computed(() => describeScope(this.scope(), this.profileAreas(), this.translate)); +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.html new file mode 100644 index 00000000..cf8dcea9 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.html @@ -0,0 +1,74 @@ +

{{ 'WHERE.SHEET_TITLE' | translate }}

+ + + + + + + + + + + + + + + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.scss new file mode 100644 index 00000000..cfcd8209 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.scss @@ -0,0 +1,45 @@ +.where-sheet { + display: block; + min-width: min(28rem, 80vw); +} + +.where-options { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.where-option { + display: block; +} + +.where-option-detail { + color: var(--mat-sys-on-surface-variant, rgb(0 0 0 / 60%)); + font-size: 0.8rem; + margin: 0 0 0 2.25rem; +} + +.where-option-body { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0.5rem 0 0 2.25rem; +} + +.where-distance { + max-width: 8rem; +} + +.where-empty { + color: var(--mat-sys-on-surface-variant, rgb(0 0 0 / 60%)); + font-size: 0.8rem; + margin: 0; +} + +.where-own-icon { + font-size: 1rem; + height: 1rem; + opacity: 0.6; + vertical-align: middle; + width: 1rem; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.ts new file mode 100644 index 00000000..3b2a3113 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.ts @@ -0,0 +1,112 @@ +import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { TranslatePipe } from '@ngx-translate/core'; + +import { AreaService } from '../../../core/services/area.service'; +import { PlacesService } from '../../../core/services/places.service'; +import { UserGeofenceService } from '../../../core/services/user-geofence.service'; +import { AlarmScope, AlarmScopeMode, titleCaseArea } from '../../utils/alarm-scope'; + +export interface WhereSheetData { + /** Areas the profile subscribes to, so the inherited option can say what it means. */ + profileAreas: string[]; + scope: AlarmScope; +} + +/** + * The one place an alarm's delivery scope is chosen, shared by every alarm dialog and every card. + * + * The three options are a radio group rather than three independent fields because PoracleNG treats + * them as mutually exclusive: a place with areas, areas with a radius, or a place without one are all + * refused. Modelled as a choice, those states cannot be expressed, so there is nothing to validate and + * no error copy to write. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormsModule, + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatRadioModule, + MatSelectModule, + TranslatePipe, + ], + selector: 'app-where-sheet', + standalone: true, + styleUrl: './where-sheet.component.scss', + templateUrl: './where-sheet.component.html', +}) +export class WhereSheetComponent implements OnInit { + private readonly areaService = inject(AreaService); + private readonly geofenceService = inject(UserGeofenceService); + /** + * Admin areas plus the user's own geofences. Their own are listed because PoracleWeb writes them + * past PoracleNG's user-selectable filter; without that they would be offered and then refused. + */ + readonly availableAreas = signal<{ name: string; own: boolean }[]>([]); + readonly data = inject(MAT_DIALOG_DATA); + readonly distanceKm = signal(this.data.scope.distanceKm ?? 1); + + readonly mode = signal(this.data.scope.mode); + readonly placeLabel = signal(this.data.scope.placeLabel ?? ''); + readonly selectedAreas = signal(this.data.scope.areas ?? []); + readonly canSave = computed(() => { + switch (this.mode()) { + case 'areas': + return this.selectedAreas().length > 0; + case 'place': + return this.placeLabel().length > 0 && this.distanceKm() > 0; + default: + return true; + } + }); + + readonly dialogRef = inject>(MatDialogRef); + + readonly places = inject(PlacesService); + + readonly profileAreaSummary = computed(() => this.data.profileAreas.map(titleCaseArea).join(', ')); + + ngOnInit(): void { + this.places.load().subscribe({ error: () => undefined }); + + this.areaService.getAvailable().subscribe({ + error: () => undefined, + next: areas => this.availableAreas.update(current => [...areas.map(a => ({ name: a.name, own: false })), ...current]), + }); + + this.geofenceService.getCustomGeofences().subscribe({ + error: () => undefined, + next: own => this.availableAreas.update(current => [...current, ...own.map(g => ({ name: g.kojiName, own: true }))]), + }); + } + + save(): void { + this.dialogRef.close(this.currentScope()); + } + + protected titleCase(area: string): string { + return titleCaseArea(area); + } + + private currentScope(): AlarmScope { + switch (this.mode()) { + case 'areas': + return { areas: this.selectedAreas(), mode: 'areas' }; + case 'place': + return { distanceKm: this.distanceKm(), mode: 'place', placeLabel: this.placeLabel() }; + default: + return { mode: 'profile' }; + } + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.spec.ts new file mode 100644 index 00000000..c5c4ae0e --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.spec.ts @@ -0,0 +1,91 @@ +import { TranslateService } from '@ngx-translate/core'; + +import { describeScope, formatAreaList, kmToMetres, metresToKm, scopeOf, scopeToFields, titleCaseArea } from './alarm-scope'; + +/** Echoes the key and interpolation so assertions read against the shape, not a translation. */ +const translate = { + instant: (key: string, params?: Record) => (params ? `${key}:${JSON.stringify(params)}` : key), +} as unknown as TranslateService; + +describe('alarm-scope', () => { + describe('scopeOf', () => { + it('reads areas as the areas mode', () => { + expect(scopeOf(null, ['terrigal'], 0)).toEqual({ areas: ['terrigal'], mode: 'areas' }); + }); + + it('reads a label and a radius as the place mode, in km', () => { + expect(scopeOf('home', null, 2500)).toEqual({ distanceKm: 2.5, mode: 'place', placeLabel: 'home' }); + }); + + it('reads neither as inherited', () => { + expect(scopeOf(null, null, 500)).toEqual({ mode: 'profile' }); + }); + + it('treats an empty area list as inherited rather than as an empty restriction', () => { + // An alarm restricted to no areas would match nothing. PoracleNG stores the cleared state as an + // empty column, so this has to read back as "no override". + expect(scopeOf(null, [], 0)).toEqual({ mode: 'profile' }); + }); + + it('prefers areas when a row somehow carries both', () => { + // PoracleNG refuses to store both, but a row written by an older client might. Areas win because + // they are the more restrictive of the two, so the alarm cannot silently widen. + expect(scopeOf('home', ['terrigal'], 500).mode).toBe('areas'); + }); + }); + + describe('scopeToFields', () => { + it('clears with empty values rather than null, so an override can be taken off', () => { + // null means "not stated, keep what is stored" on the write path. Sending null here would make + // the override impossible to remove. + expect(scopeToFields({ mode: 'profile' })).toEqual({ overrideAreas: [], overrideLocationLabel: '', distance: 0 }); + }); + + it('zeroes the radius when areas are chosen', () => { + // Areas and a radius are mutually exclusive upstream; leaving a stale radius on the form would + // be refused with a message about a field the user did not touch. + expect(scopeToFields({ areas: ['terrigal'], mode: 'areas' }).distance).toBe(0); + }); + + it('sends the place radius in metres', () => { + expect(scopeToFields({ distanceKm: 2.5, mode: 'place', placeLabel: 'home' })).toEqual({ + overrideAreas: [], + overrideLocationLabel: 'home', + distance: 2500, + }); + }); + }); + + describe('describeScope', () => { + it('names the place and the radius', () => { + expect(describeScope({ distanceKm: 2, mode: 'place', placeLabel: 'Home' }, [], translate)).toBe( + 'WHERE.NEAR_PLACE:{"distance":"2","place":"Home"}', + ); + }); + + it('distinguishes an inherited scope with areas from one without', () => { + expect(describeScope({ mode: 'profile' }, ['terrigal'], translate)).toBe('WHERE.PROFILE_AREAS'); + expect(describeScope({ mode: 'profile' }, [], translate)).toBe('WHERE.PROFILE_ANYWHERE'); + }); + }); + + describe('formatAreaList', () => { + it('title-cases the stored lowercase names', () => { + // Geofence names are lowercase because Poracle matches case-sensitively. People are not. + expect(formatAreaList(['avoca beach'], translate)).toBe('Avoca Beach'); + }); + + it('counts the tail past three', () => { + expect(formatAreaList(['a', 'b', 'c', 'd', 'e'], translate)).toBe('WHERE.AREA_LIST_MORE:{"areas":"A, B, C","count":2}'); + }); + }); + + it('rounds metres to a tenth of a km and back', () => { + expect(metresToKm(2450)).toBe(2.5); + expect(kmToMetres(2.5)).toBe(2500); + }); + + it('leaves an already-capitalised name alone', () => { + expect(titleCaseArea('Avoca Beach')).toBe('Avoca Beach'); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.ts new file mode 100644 index 00000000..7d009141 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.ts @@ -0,0 +1,109 @@ +import { TranslateService } from '@ngx-translate/core'; + +import { AlarmScope, AlarmScopeMode } from '../../core/models'; + +export type { AlarmScope, AlarmScopeMode }; + +/** + * The three answers an alarm can give to "where should this reach me", read off the two override + * fields and the radius. + * + * PoracleNG stores them as independent columns and enforces their mutual exclusion with three + * validation rules. Reading them back into one discriminated value is what lets the UI offer a radio + * group instead of three fields plus error messages, so the invalid combinations cannot be expressed. + */ +export function scopeOf( + overrideLocationLabel: null | string | undefined, + overrideAreas: null | string[] | undefined, + distanceMetres: number, +): AlarmScope { + if (overrideAreas && overrideAreas.length > 0) { + return { areas: overrideAreas, mode: 'areas' }; + } + + if (overrideLocationLabel) { + return { + distanceKm: metresToKm(distanceMetres), + mode: 'place', + placeLabel: overrideLocationLabel, + }; + } + + return { mode: 'profile' }; +} + +/** + * The scope as the fields PoracleNG stores. The unused half is sent as an explicit empty rather than + * null, because null means "not stated, keep what is stored" on the write path — an override could + * otherwise be set but never taken off. + */ +export function scopeToFields(scope: AlarmScope): { + distance: number; + overrideAreas: string[]; + overrideLocationLabel: string; +} { + switch (scope.mode) { + case 'areas': + // Areas and a radius are mutually exclusive upstream, so the radius goes to zero here rather + // than being left at whatever the form last held. + return { overrideAreas: scope.areas ?? [], overrideLocationLabel: '', distance: 0 }; + case 'place': + return { + overrideAreas: [], + overrideLocationLabel: scope.placeLabel ?? '', + distance: kmToMetres(scope.distanceKm ?? 0), + }; + default: + return { overrideAreas: [], overrideLocationLabel: '', distance: 0 }; + } +} + +/** One line describing the scope, in the second person, for a chip or a summary row. */ +export function describeScope(scope: AlarmScope, profileAreas: string[], translate: TranslateService): string { + switch (scope.mode) { + case 'areas': { + const areas = scope.areas ?? []; + return translate.instant('WHERE.ONLY_IN', { areas: formatAreaList(areas, translate) }); + } + case 'place': + return translate.instant('WHERE.NEAR_PLACE', { + distance: formatDistance(scope.distanceKm ?? 0), + place: scope.placeLabel ?? '', + }); + default: + return profileAreas.length > 0 ? translate.instant('WHERE.PROFILE_AREAS') : translate.instant('WHERE.PROFILE_ANYWHERE'); + } +} + +/** + * Area names as a reader would say them. Past three the list stops being informative and starts being + * a wall, so the tail is counted instead. + */ +export function formatAreaList(areas: string[], translate: TranslateService): string { + const shown = areas.slice(0, 3).map(titleCaseArea); + + return areas.length > 3 + ? translate.instant('WHERE.AREA_LIST_MORE', { areas: shown.join(', '), count: areas.length - 3 }) + : shown.join(', '); +} + +/** Geofence names are stored lowercase because Poracle matches case-sensitively; people are not. */ +export function titleCaseArea(area: string): string { + return area + .split(' ') + .map(word => (word.length > 0 ? word[0].toUpperCase() + word.slice(1) : word)) + .join(' '); +} + +/** Trailing zeroes read as false precision on a radius someone typed as "2". */ +export function formatDistance(km: number): string { + return Number.isInteger(km) ? `${km}` : `${km.toFixed(1)}`; +} + +export function metresToKm(metres: number): number { + return Math.round((metres / 1000) * 10) / 10; +} + +export function kmToMetres(km: number): number { + return Math.round(km * 1000); +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json index 5336c4b9..86cc01ba 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -1713,5 +1713,21 @@ "PREVIOUS_PAGE": "Forrige side", "FIRST_PAGE": "Første side", "LAST_PAGE": "Sidste side" + }, + "WHERE": { + "AREAS_LABEL": "Areas", + "AREA_LIST_MORE": "{{areas}} and {{count}} more", + "NEAR_PLACE": "Within {{distance}} km of {{place}}", + "NO_PLACES": "You have not saved any places yet. Add one from the location menu.", + "ONLY_IN": "Only in {{areas}}", + "OPTION_AREAS": "Only in specific areas", + "OPTION_PLACE": "Near a place", + "OPTION_PROFILE": "Anywhere in my areas", + "PLACE_LABEL": "Place", + "PROFILE_ANYWHERE": "Anywhere I get alerts", + "PROFILE_AREAS": "Anywhere in my areas", + "RADIUS_KM": "Radius (km)", + "SAVE": "Set where", + "SHEET_TITLE": "Where should this alert reach you?" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json index 1e4b32d3..e1db44b7 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -1713,5 +1713,21 @@ "PREVIOUS_PAGE": "Vorherige Seite", "FIRST_PAGE": "Erste Seite", "LAST_PAGE": "Letzte Seite" + }, + "WHERE": { + "AREAS_LABEL": "Areas", + "AREA_LIST_MORE": "{{areas}} and {{count}} more", + "NEAR_PLACE": "Within {{distance}} km of {{place}}", + "NO_PLACES": "You have not saved any places yet. Add one from the location menu.", + "ONLY_IN": "Only in {{areas}}", + "OPTION_AREAS": "Only in specific areas", + "OPTION_PLACE": "Near a place", + "OPTION_PROFILE": "Anywhere in my areas", + "PLACE_LABEL": "Place", + "PROFILE_ANYWHERE": "Anywhere I get alerts", + "PROFILE_AREAS": "Anywhere in my areas", + "RADIUS_KM": "Radius (km)", + "SAVE": "Set where", + "SHEET_TITLE": "Where should this alert reach you?" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json index 5a978262..668e1b93 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -1713,5 +1713,21 @@ "PREVIOUS_PAGE": "Previous page", "FIRST_PAGE": "First page", "LAST_PAGE": "Last page" + }, + "WHERE": { + "AREAS_LABEL": "Areas", + "AREA_LIST_MORE": "{{areas}} and {{count}} more", + "NEAR_PLACE": "Within {{distance}} km of {{place}}", + "NO_PLACES": "You have not saved any places yet. Add one from the location menu.", + "ONLY_IN": "Only in {{areas}}", + "OPTION_AREAS": "Only in specific areas", + "OPTION_PLACE": "Near a place", + "OPTION_PROFILE": "Anywhere in my areas", + "PLACE_LABEL": "Place", + "PROFILE_ANYWHERE": "Anywhere I get alerts", + "PROFILE_AREAS": "Anywhere in my areas", + "RADIUS_KM": "Radius (km)", + "SAVE": "Set where", + "SHEET_TITLE": "Where should this alert reach you?" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json index 95b81869..738d0280 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -1713,5 +1713,21 @@ "PREVIOUS_PAGE": "Página anterior", "FIRST_PAGE": "Primera página", "LAST_PAGE": "Última página" + }, + "WHERE": { + "AREAS_LABEL": "Areas", + "AREA_LIST_MORE": "{{areas}} and {{count}} more", + "NEAR_PLACE": "Within {{distance}} km of {{place}}", + "NO_PLACES": "You have not saved any places yet. Add one from the location menu.", + "ONLY_IN": "Only in {{areas}}", + "OPTION_AREAS": "Only in specific areas", + "OPTION_PLACE": "Near a place", + "OPTION_PROFILE": "Anywhere in my areas", + "PLACE_LABEL": "Place", + "PROFILE_ANYWHERE": "Anywhere I get alerts", + "PROFILE_AREAS": "Anywhere in my areas", + "RADIUS_KM": "Radius (km)", + "SAVE": "Set where", + "SHEET_TITLE": "Where should this alert reach you?" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json index 9ee16606..6ae1e6a7 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -1713,5 +1713,21 @@ "PREVIOUS_PAGE": "Page précédente", "FIRST_PAGE": "Première page", "LAST_PAGE": "Dernière page" + }, + "WHERE": { + "AREAS_LABEL": "Areas", + "AREA_LIST_MORE": "{{areas}} and {{count}} more", + "NEAR_PLACE": "Within {{distance}} km of {{place}}", + "NO_PLACES": "You have not saved any places yet. Add one from the location menu.", + "ONLY_IN": "Only in {{areas}}", + "OPTION_AREAS": "Only in specific areas", + "OPTION_PLACE": "Near a place", + "OPTION_PROFILE": "Anywhere in my areas", + "PLACE_LABEL": "Place", + "PROFILE_ANYWHERE": "Anywhere I get alerts", + "PROFILE_AREAS": "Anywhere in my areas", + "RADIUS_KM": "Radius (km)", + "SAVE": "Set where", + "SHEET_TITLE": "Where should this alert reach you?" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json index 095bcbdb..d71f8011 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -1713,5 +1713,21 @@ "PREVIOUS_PAGE": "Pagina precedente", "FIRST_PAGE": "Prima pagina", "LAST_PAGE": "Ultima pagina" + }, + "WHERE": { + "AREAS_LABEL": "Areas", + "AREA_LIST_MORE": "{{areas}} and {{count}} more", + "NEAR_PLACE": "Within {{distance}} km of {{place}}", + "NO_PLACES": "You have not saved any places yet. Add one from the location menu.", + "ONLY_IN": "Only in {{areas}}", + "OPTION_AREAS": "Only in specific areas", + "OPTION_PLACE": "Near a place", + "OPTION_PROFILE": "Anywhere in my areas", + "PLACE_LABEL": "Place", + "PROFILE_ANYWHERE": "Anywhere I get alerts", + "PROFILE_AREAS": "Anywhere in my areas", + "RADIUS_KM": "Radius (km)", + "SAVE": "Set where", + "SHEET_TITLE": "Where should this alert reach you?" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json index aaf0b291..d0905471 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -1713,5 +1713,21 @@ "PREVIOUS_PAGE": "Vorige pagina", "FIRST_PAGE": "Eerste pagina", "LAST_PAGE": "Laatste pagina" + }, + "WHERE": { + "AREAS_LABEL": "Areas", + "AREA_LIST_MORE": "{{areas}} and {{count}} more", + "NEAR_PLACE": "Within {{distance}} km of {{place}}", + "NO_PLACES": "You have not saved any places yet. Add one from the location menu.", + "ONLY_IN": "Only in {{areas}}", + "OPTION_AREAS": "Only in specific areas", + "OPTION_PLACE": "Near a place", + "OPTION_PROFILE": "Anywhere in my areas", + "PLACE_LABEL": "Place", + "PROFILE_ANYWHERE": "Anywhere I get alerts", + "PROFILE_AREAS": "Anywhere in my areas", + "RADIUS_KM": "Radius (km)", + "SAVE": "Set where", + "SHEET_TITLE": "Where should this alert reach you?" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json index 7df8267a..560169d2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -1713,5 +1713,21 @@ "PREVIOUS_PAGE": "Poprzednia strona", "FIRST_PAGE": "Pierwsza strona", "LAST_PAGE": "Ostatnia strona" + }, + "WHERE": { + "AREAS_LABEL": "Areas", + "AREA_LIST_MORE": "{{areas}} and {{count}} more", + "NEAR_PLACE": "Within {{distance}} km of {{place}}", + "NO_PLACES": "You have not saved any places yet. Add one from the location menu.", + "ONLY_IN": "Only in {{areas}}", + "OPTION_AREAS": "Only in specific areas", + "OPTION_PLACE": "Near a place", + "OPTION_PROFILE": "Anywhere in my areas", + "PLACE_LABEL": "Place", + "PROFILE_ANYWHERE": "Anywhere I get alerts", + "PROFILE_AREAS": "Anywhere in my areas", + "RADIUS_KM": "Radius (km)", + "SAVE": "Set where", + "SHEET_TITLE": "Where should this alert reach you?" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json index ce2fb60e..a43d3c0e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json @@ -1713,5 +1713,21 @@ "PREVIOUS_PAGE": "Página anterior", "FIRST_PAGE": "Primeira página", "LAST_PAGE": "Última página" + }, + "WHERE": { + "AREAS_LABEL": "Areas", + "AREA_LIST_MORE": "{{areas}} and {{count}} more", + "NEAR_PLACE": "Within {{distance}} km of {{place}}", + "NO_PLACES": "You have not saved any places yet. Add one from the location menu.", + "ONLY_IN": "Only in {{areas}}", + "OPTION_AREAS": "Only in specific areas", + "OPTION_PLACE": "Near a place", + "OPTION_PROFILE": "Anywhere in my areas", + "PLACE_LABEL": "Place", + "PROFILE_ANYWHERE": "Anywhere I get alerts", + "PROFILE_AREAS": "Anywhere in my areas", + "RADIUS_KM": "Radius (km)", + "SAVE": "Set where", + "SHEET_TITLE": "Where should this alert reach you?" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json index 330a2c62..0ea8b7a3 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -1713,5 +1713,21 @@ "PREVIOUS_PAGE": "Página anterior", "FIRST_PAGE": "Primeira página", "LAST_PAGE": "Última página" + }, + "WHERE": { + "AREAS_LABEL": "Areas", + "AREA_LIST_MORE": "{{areas}} and {{count}} more", + "NEAR_PLACE": "Within {{distance}} km of {{place}}", + "NO_PLACES": "You have not saved any places yet. Add one from the location menu.", + "ONLY_IN": "Only in {{areas}}", + "OPTION_AREAS": "Only in specific areas", + "OPTION_PLACE": "Near a place", + "OPTION_PROFILE": "Anywhere in my areas", + "PLACE_LABEL": "Place", + "PROFILE_ANYWHERE": "Anywhere I get alerts", + "PROFILE_AREAS": "Anywhere in my areas", + "RADIUS_KM": "Radius (km)", + "SAVE": "Set where", + "SHEET_TITLE": "Where should this alert reach you?" } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json index 4486675c..344dde33 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -1713,5 +1713,21 @@ "PREVIOUS_PAGE": "Föregående sida", "FIRST_PAGE": "Första sidan", "LAST_PAGE": "Sista sidan" + }, + "WHERE": { + "AREAS_LABEL": "Areas", + "AREA_LIST_MORE": "{{areas}} and {{count}} more", + "NEAR_PLACE": "Within {{distance}} km of {{place}}", + "NO_PLACES": "You have not saved any places yet. Add one from the location menu.", + "ONLY_IN": "Only in {{areas}}", + "OPTION_AREAS": "Only in specific areas", + "OPTION_PLACE": "Near a place", + "OPTION_PROFILE": "Anywhere in my areas", + "PLACE_LABEL": "Place", + "PROFILE_ANYWHERE": "Anywhere I get alerts", + "PROFILE_AREAS": "Anywhere in my areas", + "RADIUS_KM": "Radius (km)", + "SAVE": "Set where", + "SHEET_TITLE": "Where should this alert reach you?" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d771c27..c17e8341 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Every alarm can say where it should reach you.** The scope an alarm has always had, inherited and invisible, is now stated on the card and editable per alarm: anywhere in your areas, within a radius of a saved place, or only in specific areas. One shared control reads and writes it wherever it appears, rather than two more fields in each of the nine alarm dialogs. The three options are a radio group because PoracleNG treats them as mutually exclusive, so the combinations it refuses cannot be expressed in the first place ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). + ### Fixed - **Discord login no longer shows the "Authorize" button on every sign-in.** Adding `prompt=none` to the OAuth2 redirect tells Discord to skip the consent screen for users who have already granted the app permission. The screen still appears on first-time authorization ([#719](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/719)). From aecd7156b8d8466f9deac0d5058a2030bfab9b52 Mon Sep 17 00:00:00 2001 From: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:58:29 -0400 Subject: [PATCH 2/5] 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. --- .../ClientApp/src/app/app.html | 4 + .../ClientApp/src/app/app.ts | 5 + .../location-dialog.component.html | 3 +- .../location-dialog.component.ts | 21 ++- .../places-dialog.component.html | 48 ++++++ .../places-dialog.component.scss | 43 ++++++ .../places-dialog.component.spec.ts | 141 +++++++++++++++++ .../places-dialog/places-dialog.component.ts | 143 ++++++++++++++++++ .../ClientApp/src/assets/i18n/da.json | 19 ++- .../ClientApp/src/assets/i18n/de.json | 19 ++- .../ClientApp/src/assets/i18n/en.json | 19 ++- .../ClientApp/src/assets/i18n/es.json | 19 ++- .../ClientApp/src/assets/i18n/fr.json | 19 ++- .../ClientApp/src/assets/i18n/it.json | 19 ++- .../ClientApp/src/assets/i18n/nl.json | 19 ++- .../ClientApp/src/assets/i18n/pl.json | 19 ++- .../ClientApp/src/assets/i18n/pt-BR.json | 19 ++- .../ClientApp/src/assets/i18n/pt.json | 19 ++- .../ClientApp/src/assets/i18n/sv.json | 19 ++- CHANGELOG.md | 1 + 20 files changed, 604 insertions(+), 14 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.html create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.scss create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.ts diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html index 70e3398f..61e0a77f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.html @@ -121,6 +121,10 @@ tune {{ 'MENU.ALERT_DEFAULTS' | translate }} + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.ts index 21ac3361..a9545e9c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/location-dialog/location-dialog.component.ts @@ -19,6 +19,12 @@ import { I18nService } from '../../../core/services/i18n.service'; import { LocationService } from '../../../core/services/location.service'; import { SettingsService } from '../../../core/services/settings.service'; +/** Extra options for {@link LocationDialogComponent}. */ +export interface LocationDialogData { + /** Close with the chosen coordinates instead of saving them as the profile pin. */ + pickOnly?: boolean; +} + @Component({ imports: [ FormsModule, @@ -61,7 +67,12 @@ export class LocationDialogComponent implements OnInit, OnDestroy { private skipNextReverse = false; private readonly snackBar = inject(MatSnackBar); - readonly data = inject(MAT_DIALOG_DATA); + /** + * `pickOnly` borrows this dialog as a coordinate picker without touching the profile pin. Saving + * unconditionally is what the dialog was for, so a caller that only wants a point (naming a place, + * say) would otherwise silently move the user's pin on the way past. + */ + readonly data = inject<(LocationDialogData & Location) | null>(MAT_DIALOG_DATA); readonly dialogRef = inject(MatDialogRef); /** * When the operator has switched off geocoding, hide the address search rather than let it 403. @@ -191,8 +202,14 @@ export class LocationDialogComponent implements OnInit, OnDestroy { save(): void { if (!this.isValid()) return; - this.saving.set(true); const loc: Location = { latitude: this.latitude, longitude: this.longitude }; + + if (this.data?.pickOnly) { + this.dialogRef.close(loc); + return; + } + + this.saving.set(true); this.locationService.setLocation(loc).subscribe({ error: () => { this.saving.set(false); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.html new file mode 100644 index 00000000..2f9bcc73 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.html @@ -0,0 +1,48 @@ +

{{ 'WHERE.PLACES_TITLE' | translate }}

+ + + @if (loading()) { +
+ } @else { + @if (places.pin(); as pin) { +
+ my_location +
+ {{ 'WHERE.PIN_TITLE' | translate }} + {{ pin.latitude | number: '1.4-4' }}, {{ pin.longitude | number: '1.4-4' }} +
+
+

{{ 'WHERE.PIN_NOTE' | translate }}

+ } + + @if (places.named().length === 0) { +

{{ 'WHERE.PLACES_EMPTY' | translate }}

+ } @else { + + @for (place of places.named(); track place.label) { + + place + {{ place.label }} + {{ place.latitude | number: '1.4-4' }}, {{ place.longitude | number: '1.4-4' }} + + + } + + } + } +
+ + + + + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.scss new file mode 100644 index 00000000..a07dfe39 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.scss @@ -0,0 +1,43 @@ +.places { + display: block; + min-width: min(26rem, 80vw); +} + +.places-loading { + display: flex; + justify-content: center; + padding: 2rem 0; +} + +.places-pin { + align-items: center; + display: flex; + gap: 0.75rem; + padding: 0.5rem 0; +} + +.places-pin-icon { + opacity: 0.7; +} + +.places-pin-text { + display: flex; + flex-direction: column; +} + +.places-pin-detail, +.places-pin-note, +.places-empty { + color: var(--mat-sys-on-surface-variant, rgb(0 0 0 / 60%)); + font-size: 0.8rem; +} + +.places-pin-note { + border-bottom: 1px solid var(--mat-sys-outline-variant, rgb(0 0 0 / 12%)); + margin: 0 0 0.5rem; + padding-bottom: 0.75rem; +} + +.places-empty { + margin: 1rem 0; +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.spec.ts new file mode 100644 index 00000000..5182f1ec --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.spec.ts @@ -0,0 +1,141 @@ +import { HttpErrorResponse, provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { provideTranslateService } from '@ngx-translate/core'; +import { of, throwError } from 'rxjs'; + +import { PlacesDialogComponent } from './places-dialog.component'; +import { ConfigService } from '../../../core/services/config.service'; +import { PlacesService } from '../../../core/services/places.service'; + +describe('PlacesDialogComponent', () => { + let dialog: { open: jest.Mock }; + let places: { + add: jest.Mock; + load: jest.Mock; + named: jest.Mock; + pin: jest.Mock; + remove: jest.Mock; + }; + let snackBar: { open: jest.Mock }; + + /** Queues what each successive dialog.open() should resolve to. */ + function queueDialogResults(...results: unknown[]): void { + results.forEach(result => dialog.open.mockReturnValueOnce({ afterClosed: () => of(result) })); + } + + function create(): PlacesDialogComponent { + dialog = { open: jest.fn() }; + snackBar = { open: jest.fn() }; + places = { + named: jest.fn().mockReturnValue([{ label: 'work', latitude: 1, longitude: 2 }]), + add: jest.fn().mockReturnValue(of({ named: [], default: null })), + load: jest.fn().mockReturnValue(of({ named: [], default: null })), + pin: jest.fn().mockReturnValue({ label: '', latitude: 3, longitude: 4 }), + remove: jest.fn().mockReturnValue(of(void 0)), + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideTranslateService(), + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { provide: MatDialog, useValue: dialog }, + { provide: MatSnackBar, useValue: snackBar }, + { provide: PlacesService, useValue: places }, + { provide: ConfigService, useValue: { apiHost: 'http://test' } }, + provideHttpClient(), + provideHttpClientTesting(), + ], + imports: [PlacesDialogComponent], + }); + + // MatDialogModule is in the component's own imports, so its MatDialog wins over the TestBed + // provider. Overriding at the component injector is the only level that beats it. + TestBed.overrideComponent(PlacesDialogComponent, { + set: { + providers: [ + { provide: MatDialog, useValue: dialog }, + { provide: MatSnackBar, useValue: snackBar }, + ], + }, + }); + + const component = TestBed.createComponent(PlacesDialogComponent).componentInstance; + component.ngOnInit(); + return component; + } + + it('borrows the location dialog as a picker rather than moving the profile pin', () => { + // Without pickOnly the location dialog saves whatever point is chosen as the user's pin, so + // naming a place would quietly relocate every alarm that has no override. + const component = create(); + queueDialogResults(undefined); + + component.addPlace(); + + expect(dialog.open.mock.calls[0][1].data).toMatchObject({ pickOnly: true }); + }); + + it('saves the place once a point is picked and a name given', () => { + const component = create(); + queueDialogResults({ latitude: 10, longitude: 20 }, 'gym'); + + component.addPlace(); + + expect(places.add).toHaveBeenCalledWith({ label: 'gym', latitude: 10, longitude: 20 }); + }); + + it('saves nothing when the naming step is cancelled', () => { + // ConfirmDialog's prompt closes with false on cancel, which is falsy in the same way an empty + // name is: both mean no place. + const component = create(); + queueDialogResults({ latitude: 10, longitude: 20 }, false); + + component.addPlace(); + + expect(places.add).not.toHaveBeenCalled(); + }); + + it('offers the existing names so the prompt can refuse a duplicate', () => { + const component = create(); + queueDialogResults({ latitude: 10, longitude: 20 }, false); + + component.addPlace(); + + expect(dialog.open.mock.calls[1][1].data.promptField.existingNames).toEqual(['work']); + }); + + it('says how many alerts are in the way when a place cannot be deleted', () => { + // The 409 carries the alarms still pointing at it. "Could not delete" would leave the user with + // nothing to act on. + const component = create(); + places.remove.mockReturnValue( + throwError(() => new HttpErrorResponse({ error: { referencingRules: ['pokemon 7', 'raid 9'] }, status: 409 })), + ); + + component.removePlace({ label: 'work', latitude: 1, longitude: 2 }); + + expect(snackBar.open).toHaveBeenCalledWith('WHERE.PLACE_IN_USE', expect.anything(), expect.anything()); + }); + + it('reports a plain failure when the delete fails for any other reason', () => { + const component = create(); + places.remove.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))); + + component.removePlace({ label: 'work', latitude: 1, longitude: 2 }); + + expect(snackBar.open).toHaveBeenCalledWith('WHERE.PLACE_DELETE_ERROR', expect.anything(), expect.anything()); + }); + + it('deletes only after the confirmation is accepted', () => { + const component = create(); + queueDialogResults(false); + + component.confirmRemove({ label: 'work', latitude: 1, longitude: 2 }); + + expect(places.remove).not.toHaveBeenCalled(); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.ts new file mode 100644 index 00000000..943668b9 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.ts @@ -0,0 +1,143 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatListModule } from '@angular/material/list'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslatePipe, TranslateService } from '@ngx-translate/core'; + +import { Location, SavedPlace } from '../../../core/models'; +import { PlacesService } from '../../../core/services/places.service'; +import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'; +import { LocationDialogComponent } from '../location-dialog/location-dialog.component'; + +/** + * The places a user's alarms can be aimed at: the profile pin, plus whatever they have named. + * + * Adding a place borrows the location dialog as a coordinate picker rather than growing a second map, + * then asks for the name separately, because picking a point and naming it are two decisions and + * putting them on one screen makes both feel like a form. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [MatButtonModule, MatDialogModule, MatIconModule, MatListModule, MatProgressSpinnerModule, TranslatePipe], + selector: 'app-places-dialog', + standalone: true, + styleUrl: './places-dialog.component.scss', + templateUrl: './places-dialog.component.html', +}) +export class PlacesDialogComponent implements OnInit { + private readonly dialog = inject(MatDialog); + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + readonly busy = signal(false); + readonly loading = signal(true); + readonly places = inject(PlacesService); + + addPlace(): void { + const picker = this.dialog.open(LocationDialogComponent, { + width: '600px', + data: { latitude: this.places.pin()?.latitude ?? 0, longitude: this.places.pin()?.longitude ?? 0, pickOnly: true }, + }); + + picker.afterClosed().subscribe((point?: Location) => { + if (!point) return; + this.nameAndSave(point); + }); + } + + confirmRemove(place: SavedPlace): void { + this.dialog + .open(ConfirmDialogComponent, { + data: { + confirmText: this.translate.instant('COMMON.DELETE'), + message: this.translate.instant('WHERE.PLACE_DELETE_CONFIRM', { place: place.label }), + title: this.translate.instant('WHERE.PLACE_DELETE_TITLE'), + }, + }) + .afterClosed() + .subscribe(confirmed => { + if (confirmed) this.removePlace(place); + }); + } + + ngOnInit(): void { + this.reload(); + } + + removePlace(place: SavedPlace): void { + this.busy.set(true); + this.places.remove(place.label).subscribe({ + error: (err: HttpErrorResponse) => { + this.busy.set(false); + + // 409 carries the alarms still pointing at the place. Naming them is the difference between + // "could not delete" and knowing what to repoint first. + const rules: string[] = err.status === 409 ? (err.error?.referencingRules ?? []) : []; + this.snackBar.open( + rules.length > 0 + ? this.translate.instant('WHERE.PLACE_IN_USE', { count: rules.length, place: place.label }) + : this.translate.instant('WHERE.PLACE_DELETE_ERROR'), + this.translate.instant('COMMON.OK'), + { duration: 6000 }, + ); + }, + next: () => { + this.busy.set(false); + this.snackBar.open(this.translate.instant('WHERE.PLACE_DELETED', { place: place.label }), this.translate.instant('COMMON.OK'), { + duration: 3000, + }); + }, + }); + } + + private nameAndSave(point: Location): void { + // ConfirmDialog's promptField already does the name-with-duplicate-check, so this reuses it rather + // than adding a third dialog that asks for a single string. + const naming = this.dialog.open(ConfirmDialogComponent, { + width: '420px', + data: { + confirmText: this.translate.instant('COMMON.SAVE'), + message: this.translate.instant('WHERE.NAME_PLACE_MESSAGE'), + promptField: { + existingNames: this.places.named().map(p => p.label), + label: this.translate.instant('WHERE.PLACE_NAME'), + value: '', + }, + title: this.translate.instant('WHERE.NAME_PLACE_TITLE'), + }, + }); + + naming.afterClosed().subscribe((label?: false | string) => { + if (!label) return; + + this.busy.set(true); + this.places.add({ label, latitude: point.latitude, longitude: point.longitude }).subscribe({ + error: (err: HttpErrorResponse) => { + this.busy.set(false); + // PoracleNG reports a rejected label inside its own response, so the API turns it into a 400 + // with the reason. Showing that beats a generic failure: it is usually "you already have one". + this.snackBar.open(err.error?.error ?? this.translate.instant('WHERE.PLACE_SAVE_ERROR'), this.translate.instant('COMMON.OK'), { + duration: 6000, + }); + }, + next: () => { + this.busy.set(false); + this.snackBar.open(this.translate.instant('WHERE.PLACE_SAVED', { place: label }), this.translate.instant('COMMON.OK'), { + duration: 3000, + }); + }, + }); + }); + } + + private reload(): void { + this.loading.set(true); + this.places.load().subscribe({ + error: () => this.loading.set(false), + next: () => this.loading.set(false), + }); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json index 86cc01ba..02b245a8 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -63,7 +63,8 @@ "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", "ACCENT_INSTINCT": "Instinct", - "ALERT_DEFAULTS": "Standardindstillinger for advarsler" + "ALERT_DEFAULTS": "Standardindstillinger for advarsler", + "PLACES": "Places" }, "SHORTCUTS": { "TITLE": "Tastaturgenveje", @@ -1715,6 +1716,22 @@ "LAST_PAGE": "Sidste side" }, "WHERE": { + "ADD_PLACE": "Add a place", + "NAME_PLACE_MESSAGE": "What should this place be called?", + "NAME_PLACE_TITLE": "Name this place", + "PIN_NOTE": "The fallback for every alert that is not aimed somewhere else.", + "PIN_TITLE": "My pin", + "PLACES_EMPTY": "No places yet. Add one to send alerts somewhere other than your pin: work, the gym, your parents' house.", + "PLACES_TITLE": "Places", + "PLACE_DELETED": "Deleted {{place}}.", + "PLACE_DELETE_CONFIRM": "Alerts aimed at {{place}} will fall back to your pin.", + "PLACE_DELETE_ERROR": "Could not delete that place.", + "PLACE_DELETE_TITLE": "Delete this place?", + "PLACE_IN_USE": "{{place}} is used by {{count}} alert(s). Repoint them first.", + "PLACE_NAME": "Name", + "PLACE_SAVED": "Saved {{place}}.", + "PLACE_SAVE_ERROR": "Could not save that place.", + "USE_THIS_POINT": "Use this point", "AREAS_LABEL": "Areas", "AREA_LIST_MORE": "{{areas}} and {{count}} more", "NEAR_PLACE": "Within {{distance}} km of {{place}}", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json index e1db44b7..5a860c04 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -63,7 +63,8 @@ "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", "ACCENT_INSTINCT": "Instinct", - "ALERT_DEFAULTS": "Benachrichtigungs-Standards" + "ALERT_DEFAULTS": "Benachrichtigungs-Standards", + "PLACES": "Places" }, "SHORTCUTS": { "TITLE": "Tastenkürzel", @@ -1715,6 +1716,22 @@ "LAST_PAGE": "Letzte Seite" }, "WHERE": { + "ADD_PLACE": "Add a place", + "NAME_PLACE_MESSAGE": "What should this place be called?", + "NAME_PLACE_TITLE": "Name this place", + "PIN_NOTE": "The fallback for every alert that is not aimed somewhere else.", + "PIN_TITLE": "My pin", + "PLACES_EMPTY": "No places yet. Add one to send alerts somewhere other than your pin: work, the gym, your parents' house.", + "PLACES_TITLE": "Places", + "PLACE_DELETED": "Deleted {{place}}.", + "PLACE_DELETE_CONFIRM": "Alerts aimed at {{place}} will fall back to your pin.", + "PLACE_DELETE_ERROR": "Could not delete that place.", + "PLACE_DELETE_TITLE": "Delete this place?", + "PLACE_IN_USE": "{{place}} is used by {{count}} alert(s). Repoint them first.", + "PLACE_NAME": "Name", + "PLACE_SAVED": "Saved {{place}}.", + "PLACE_SAVE_ERROR": "Could not save that place.", + "USE_THIS_POINT": "Use this point", "AREAS_LABEL": "Areas", "AREA_LIST_MORE": "{{areas}} and {{count}} more", "NEAR_PLACE": "Within {{distance}} km of {{place}}", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json index 668e1b93..3222031e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -63,7 +63,8 @@ "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", "ACCENT_INSTINCT": "Instinct", - "ALERT_DEFAULTS": "Alert Defaults" + "ALERT_DEFAULTS": "Alert Defaults", + "PLACES": "Places" }, "SHORTCUTS": { "TITLE": "Keyboard Shortcuts", @@ -1715,6 +1716,22 @@ "LAST_PAGE": "Last page" }, "WHERE": { + "ADD_PLACE": "Add a place", + "NAME_PLACE_MESSAGE": "What should this place be called?", + "NAME_PLACE_TITLE": "Name this place", + "PIN_NOTE": "The fallback for every alert that is not aimed somewhere else.", + "PIN_TITLE": "My pin", + "PLACES_EMPTY": "No places yet. Add one to send alerts somewhere other than your pin: work, the gym, your parents' house.", + "PLACES_TITLE": "Places", + "PLACE_DELETED": "Deleted {{place}}.", + "PLACE_DELETE_CONFIRM": "Alerts aimed at {{place}} will fall back to your pin.", + "PLACE_DELETE_ERROR": "Could not delete that place.", + "PLACE_DELETE_TITLE": "Delete this place?", + "PLACE_IN_USE": "{{place}} is used by {{count}} alert(s). Repoint them first.", + "PLACE_NAME": "Name", + "PLACE_SAVED": "Saved {{place}}.", + "PLACE_SAVE_ERROR": "Could not save that place.", + "USE_THIS_POINT": "Use this point", "AREAS_LABEL": "Areas", "AREA_LIST_MORE": "{{areas}} and {{count}} more", "NEAR_PLACE": "Within {{distance}} km of {{place}}", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json index 738d0280..89e8c601 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -63,7 +63,8 @@ "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", "ACCENT_INSTINCT": "Instinct", - "ALERT_DEFAULTS": "Valores predeterminados de alertas" + "ALERT_DEFAULTS": "Valores predeterminados de alertas", + "PLACES": "Places" }, "SHORTCUTS": { "TITLE": "Atajos de teclado", @@ -1715,6 +1716,22 @@ "LAST_PAGE": "Última página" }, "WHERE": { + "ADD_PLACE": "Add a place", + "NAME_PLACE_MESSAGE": "What should this place be called?", + "NAME_PLACE_TITLE": "Name this place", + "PIN_NOTE": "The fallback for every alert that is not aimed somewhere else.", + "PIN_TITLE": "My pin", + "PLACES_EMPTY": "No places yet. Add one to send alerts somewhere other than your pin: work, the gym, your parents' house.", + "PLACES_TITLE": "Places", + "PLACE_DELETED": "Deleted {{place}}.", + "PLACE_DELETE_CONFIRM": "Alerts aimed at {{place}} will fall back to your pin.", + "PLACE_DELETE_ERROR": "Could not delete that place.", + "PLACE_DELETE_TITLE": "Delete this place?", + "PLACE_IN_USE": "{{place}} is used by {{count}} alert(s). Repoint them first.", + "PLACE_NAME": "Name", + "PLACE_SAVED": "Saved {{place}}.", + "PLACE_SAVE_ERROR": "Could not save that place.", + "USE_THIS_POINT": "Use this point", "AREAS_LABEL": "Areas", "AREA_LIST_MORE": "{{areas}} and {{count}} more", "NEAR_PLACE": "Within {{distance}} km of {{place}}", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json index 6ae1e6a7..945405d3 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -63,7 +63,8 @@ "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", "ACCENT_INSTINCT": "Instinct", - "ALERT_DEFAULTS": "Réglages par défaut des alertes" + "ALERT_DEFAULTS": "Réglages par défaut des alertes", + "PLACES": "Places" }, "SHORTCUTS": { "TITLE": "Raccourcis clavier", @@ -1715,6 +1716,22 @@ "LAST_PAGE": "Dernière page" }, "WHERE": { + "ADD_PLACE": "Add a place", + "NAME_PLACE_MESSAGE": "What should this place be called?", + "NAME_PLACE_TITLE": "Name this place", + "PIN_NOTE": "The fallback for every alert that is not aimed somewhere else.", + "PIN_TITLE": "My pin", + "PLACES_EMPTY": "No places yet. Add one to send alerts somewhere other than your pin: work, the gym, your parents' house.", + "PLACES_TITLE": "Places", + "PLACE_DELETED": "Deleted {{place}}.", + "PLACE_DELETE_CONFIRM": "Alerts aimed at {{place}} will fall back to your pin.", + "PLACE_DELETE_ERROR": "Could not delete that place.", + "PLACE_DELETE_TITLE": "Delete this place?", + "PLACE_IN_USE": "{{place}} is used by {{count}} alert(s). Repoint them first.", + "PLACE_NAME": "Name", + "PLACE_SAVED": "Saved {{place}}.", + "PLACE_SAVE_ERROR": "Could not save that place.", + "USE_THIS_POINT": "Use this point", "AREAS_LABEL": "Areas", "AREA_LIST_MORE": "{{areas}} and {{count}} more", "NEAR_PLACE": "Within {{distance}} km of {{place}}", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json index d71f8011..95df4520 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -63,7 +63,8 @@ "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", "ACCENT_INSTINCT": "Instinct", - "ALERT_DEFAULTS": "Impostazioni predefinite avvisi" + "ALERT_DEFAULTS": "Impostazioni predefinite avvisi", + "PLACES": "Places" }, "SHORTCUTS": { "TITLE": "Scorciatoie da Tastiera", @@ -1715,6 +1716,22 @@ "LAST_PAGE": "Ultima pagina" }, "WHERE": { + "ADD_PLACE": "Add a place", + "NAME_PLACE_MESSAGE": "What should this place be called?", + "NAME_PLACE_TITLE": "Name this place", + "PIN_NOTE": "The fallback for every alert that is not aimed somewhere else.", + "PIN_TITLE": "My pin", + "PLACES_EMPTY": "No places yet. Add one to send alerts somewhere other than your pin: work, the gym, your parents' house.", + "PLACES_TITLE": "Places", + "PLACE_DELETED": "Deleted {{place}}.", + "PLACE_DELETE_CONFIRM": "Alerts aimed at {{place}} will fall back to your pin.", + "PLACE_DELETE_ERROR": "Could not delete that place.", + "PLACE_DELETE_TITLE": "Delete this place?", + "PLACE_IN_USE": "{{place}} is used by {{count}} alert(s). Repoint them first.", + "PLACE_NAME": "Name", + "PLACE_SAVED": "Saved {{place}}.", + "PLACE_SAVE_ERROR": "Could not save that place.", + "USE_THIS_POINT": "Use this point", "AREAS_LABEL": "Areas", "AREA_LIST_MORE": "{{areas}} and {{count}} more", "NEAR_PLACE": "Within {{distance}} km of {{place}}", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json index d0905471..0a432ec2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -63,7 +63,8 @@ "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", "ACCENT_INSTINCT": "Instinct", - "ALERT_DEFAULTS": "Standaardinstellingen meldingen" + "ALERT_DEFAULTS": "Standaardinstellingen meldingen", + "PLACES": "Places" }, "SHORTCUTS": { "TITLE": "Sneltoetsen", @@ -1715,6 +1716,22 @@ "LAST_PAGE": "Laatste pagina" }, "WHERE": { + "ADD_PLACE": "Add a place", + "NAME_PLACE_MESSAGE": "What should this place be called?", + "NAME_PLACE_TITLE": "Name this place", + "PIN_NOTE": "The fallback for every alert that is not aimed somewhere else.", + "PIN_TITLE": "My pin", + "PLACES_EMPTY": "No places yet. Add one to send alerts somewhere other than your pin: work, the gym, your parents' house.", + "PLACES_TITLE": "Places", + "PLACE_DELETED": "Deleted {{place}}.", + "PLACE_DELETE_CONFIRM": "Alerts aimed at {{place}} will fall back to your pin.", + "PLACE_DELETE_ERROR": "Could not delete that place.", + "PLACE_DELETE_TITLE": "Delete this place?", + "PLACE_IN_USE": "{{place}} is used by {{count}} alert(s). Repoint them first.", + "PLACE_NAME": "Name", + "PLACE_SAVED": "Saved {{place}}.", + "PLACE_SAVE_ERROR": "Could not save that place.", + "USE_THIS_POINT": "Use this point", "AREAS_LABEL": "Areas", "AREA_LIST_MORE": "{{areas}} and {{count}} more", "NEAR_PLACE": "Within {{distance}} km of {{place}}", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json index 560169d2..b8cb8c54 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -63,7 +63,8 @@ "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", "ACCENT_INSTINCT": "Instinct", - "ALERT_DEFAULTS": "Domyślne ustawienia alertów" + "ALERT_DEFAULTS": "Domyślne ustawienia alertów", + "PLACES": "Places" }, "SHORTCUTS": { "TITLE": "Skróty klawiszowe", @@ -1715,6 +1716,22 @@ "LAST_PAGE": "Ostatnia strona" }, "WHERE": { + "ADD_PLACE": "Add a place", + "NAME_PLACE_MESSAGE": "What should this place be called?", + "NAME_PLACE_TITLE": "Name this place", + "PIN_NOTE": "The fallback for every alert that is not aimed somewhere else.", + "PIN_TITLE": "My pin", + "PLACES_EMPTY": "No places yet. Add one to send alerts somewhere other than your pin: work, the gym, your parents' house.", + "PLACES_TITLE": "Places", + "PLACE_DELETED": "Deleted {{place}}.", + "PLACE_DELETE_CONFIRM": "Alerts aimed at {{place}} will fall back to your pin.", + "PLACE_DELETE_ERROR": "Could not delete that place.", + "PLACE_DELETE_TITLE": "Delete this place?", + "PLACE_IN_USE": "{{place}} is used by {{count}} alert(s). Repoint them first.", + "PLACE_NAME": "Name", + "PLACE_SAVED": "Saved {{place}}.", + "PLACE_SAVE_ERROR": "Could not save that place.", + "USE_THIS_POINT": "Use this point", "AREAS_LABEL": "Areas", "AREA_LIST_MORE": "{{areas}} and {{count}} more", "NEAR_PLACE": "Within {{distance}} km of {{place}}", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json index a43d3c0e..dc9802ea 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt-BR.json @@ -63,7 +63,8 @@ "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", "ACCENT_INSTINCT": "Instinct", - "ALERT_DEFAULTS": "Padrões de alertas" + "ALERT_DEFAULTS": "Padrões de alertas", + "PLACES": "Places" }, "SHORTCUTS": { "TITLE": "Atalhos do Teclado", @@ -1715,6 +1716,22 @@ "LAST_PAGE": "Última página" }, "WHERE": { + "ADD_PLACE": "Add a place", + "NAME_PLACE_MESSAGE": "What should this place be called?", + "NAME_PLACE_TITLE": "Name this place", + "PIN_NOTE": "The fallback for every alert that is not aimed somewhere else.", + "PIN_TITLE": "My pin", + "PLACES_EMPTY": "No places yet. Add one to send alerts somewhere other than your pin: work, the gym, your parents' house.", + "PLACES_TITLE": "Places", + "PLACE_DELETED": "Deleted {{place}}.", + "PLACE_DELETE_CONFIRM": "Alerts aimed at {{place}} will fall back to your pin.", + "PLACE_DELETE_ERROR": "Could not delete that place.", + "PLACE_DELETE_TITLE": "Delete this place?", + "PLACE_IN_USE": "{{place}} is used by {{count}} alert(s). Repoint them first.", + "PLACE_NAME": "Name", + "PLACE_SAVED": "Saved {{place}}.", + "PLACE_SAVE_ERROR": "Could not save that place.", + "USE_THIS_POINT": "Use this point", "AREAS_LABEL": "Areas", "AREA_LIST_MORE": "{{areas}} and {{count}} more", "NEAR_PLACE": "Within {{distance}} km of {{place}}", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json index 0ea8b7a3..141c08b1 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -63,7 +63,8 @@ "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", "ACCENT_INSTINCT": "Instinct", - "ALERT_DEFAULTS": "Padrões de alertas" + "ALERT_DEFAULTS": "Padrões de alertas", + "PLACES": "Places" }, "SHORTCUTS": { "TITLE": "Atalhos de Teclado", @@ -1715,6 +1716,22 @@ "LAST_PAGE": "Última página" }, "WHERE": { + "ADD_PLACE": "Add a place", + "NAME_PLACE_MESSAGE": "What should this place be called?", + "NAME_PLACE_TITLE": "Name this place", + "PIN_NOTE": "The fallback for every alert that is not aimed somewhere else.", + "PIN_TITLE": "My pin", + "PLACES_EMPTY": "No places yet. Add one to send alerts somewhere other than your pin: work, the gym, your parents' house.", + "PLACES_TITLE": "Places", + "PLACE_DELETED": "Deleted {{place}}.", + "PLACE_DELETE_CONFIRM": "Alerts aimed at {{place}} will fall back to your pin.", + "PLACE_DELETE_ERROR": "Could not delete that place.", + "PLACE_DELETE_TITLE": "Delete this place?", + "PLACE_IN_USE": "{{place}} is used by {{count}} alert(s). Repoint them first.", + "PLACE_NAME": "Name", + "PLACE_SAVED": "Saved {{place}}.", + "PLACE_SAVE_ERROR": "Could not save that place.", + "USE_THIS_POINT": "Use this point", "AREAS_LABEL": "Areas", "AREA_LIST_MORE": "{{areas}} and {{count}} more", "NEAR_PLACE": "Within {{distance}} km of {{place}}", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json index 344dde33..cd6e7fe9 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -63,7 +63,8 @@ "ACCENT_MYSTIC": "Mystic", "ACCENT_VALOR": "Valor", "ACCENT_INSTINCT": "Instinct", - "ALERT_DEFAULTS": "Standardinställningar för aviseringar" + "ALERT_DEFAULTS": "Standardinställningar för aviseringar", + "PLACES": "Places" }, "SHORTCUTS": { "TITLE": "Kortkommandon", @@ -1715,6 +1716,22 @@ "LAST_PAGE": "Sista sidan" }, "WHERE": { + "ADD_PLACE": "Add a place", + "NAME_PLACE_MESSAGE": "What should this place be called?", + "NAME_PLACE_TITLE": "Name this place", + "PIN_NOTE": "The fallback for every alert that is not aimed somewhere else.", + "PIN_TITLE": "My pin", + "PLACES_EMPTY": "No places yet. Add one to send alerts somewhere other than your pin: work, the gym, your parents' house.", + "PLACES_TITLE": "Places", + "PLACE_DELETED": "Deleted {{place}}.", + "PLACE_DELETE_CONFIRM": "Alerts aimed at {{place}} will fall back to your pin.", + "PLACE_DELETE_ERROR": "Could not delete that place.", + "PLACE_DELETE_TITLE": "Delete this place?", + "PLACE_IN_USE": "{{place}} is used by {{count}} alert(s). Repoint them first.", + "PLACE_NAME": "Name", + "PLACE_SAVED": "Saved {{place}}.", + "PLACE_SAVE_ERROR": "Could not save that place.", + "USE_THIS_POINT": "Use this point", "AREAS_LABEL": "Areas", "AREA_LIST_MORE": "{{areas}} and {{count}} more", "NEAR_PLACE": "Within {{distance}} km of {{place}}", diff --git a/CHANGELOG.md b/CHANGELOG.md index c17e8341..4012a21c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - **Every alarm can say where it should reach you.** The scope an alarm has always had, inherited and invisible, is now stated on the card and editable per alarm: anywhere in your areas, within a radius of a saved place, or only in specific areas. One shared control reads and writes it wherever it appears, rather than two more fields in each of the nine alarm dialogs. The three options are a radio group because PoracleNG treats them as mutually exclusive, so the combinations it refuses cannot be expressed in the first place ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **Places.** The user menu gains a Places screen: your pin, plus whatever you name. Adding one borrows the existing location map as a picker rather than growing a second one, and naming is a separate step because picking a point and naming it are two decisions. Deleting a place that alerts still point at says how many and refuses, instead of quietly widening them back to your pin ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). ### Fixed From 54ba5800957c018d32d9d64c011986b811dc6560 Mon Sep 17 00:00:00 2001 From: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:08:16 -0400 Subject: [PATCH 3/5] 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. --- .../pokemon/pokemon-list.component.html | 11 ++-- .../modules/pokemon/pokemon-list.component.ts | 47 +++++++++++++- .../where-chip/where-chip.component.spec.ts | 61 +++++++++++++++++++ .../where-sheet/where-sheet.component.html | 7 ++- .../where-sheet/where-sheet.component.ts | 35 ++++++++--- .../src/app/shared/utils/alarm-scope.spec.ts | 16 ++++- .../src/app/shared/utils/alarm-scope.ts | 12 +++- .../ClientApp/src/assets/i18n/da.json | 5 ++ .../ClientApp/src/assets/i18n/de.json | 5 ++ .../ClientApp/src/assets/i18n/en.json | 5 ++ .../ClientApp/src/assets/i18n/es.json | 5 ++ .../ClientApp/src/assets/i18n/fr.json | 5 ++ .../ClientApp/src/assets/i18n/it.json | 5 ++ .../ClientApp/src/assets/i18n/nl.json | 5 ++ .../ClientApp/src/assets/i18n/pl.json | 5 ++ .../ClientApp/src/assets/i18n/pt-BR.json | 5 ++ .../ClientApp/src/assets/i18n/pt.json | 5 ++ .../ClientApp/src/assets/i18n/sv.json | 5 ++ CHANGELOG.md | 1 + 19 files changed, 222 insertions(+), 23 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.spec.ts diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.html index 64c279be..3b1343fa 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.html @@ -201,11 +201,12 @@

{{ getPokemonName(monster.pokemonId) }}

}
- @if (monster.distance === 0) { - map {{ 'POKEMON.USING_AREAS' | translate }} - } @else { - straighten {{ formatDistance(monster.distance) }} - } +
@if (monster.pvpRankingLeague > 0) {
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.ts index 820ba952..fc390b0f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/pokemon/pokemon-list.component.ts @@ -20,6 +20,7 @@ import { firstValueFrom } from 'rxjs'; import { PokemonAddDialogComponent } from './pokemon-add-dialog.component'; import { PokemonEditDialogComponent } from './pokemon-edit-dialog.component'; import { Monster } from '../../core/models'; +import { AreaService } from '../../core/services/area.service'; import { I18nService } from '../../core/services/i18n.service'; import { IconService } from '../../core/services/icon.service'; import { MasterDataService } from '../../core/services/masterdata.service'; @@ -27,6 +28,9 @@ import { MonsterService } from '../../core/services/monster.service'; import { TestAlertService } from '../../core/services/test-alert.service'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component'; import { DistanceDialogComponent } from '../../shared/components/distance-dialog/distance-dialog.component'; +import { WhereChipComponent } from '../../shared/components/where-chip/where-chip.component'; +import { WhereSheetComponent, WhereSheetData } from '../../shared/components/where-sheet/where-sheet.component'; +import { AlarmScope, scopeOf, scopeToFields } from '../../shared/utils/alarm-scope'; import { isAutoDelete as cleanIsAutoDelete } from '../../shared/utils/clean-flags'; @Component({ @@ -47,6 +51,7 @@ import { isAutoDelete as cleanIsAutoDelete } from '../../shared/utils/clean-flag MatInputModule, MatSelectModule, TranslatePipe, + WhereChipComponent, ], selector: 'app-pokemon-list', standalone: true, @@ -54,6 +59,7 @@ import { isAutoDelete as cleanIsAutoDelete } from '../../shared/utils/clean-flag templateUrl: './pokemon-list.component.html', }) export class PokemonListComponent implements OnInit { + private readonly areaService = inject(AreaService); private readonly destroyRef = inject(DestroyRef); private readonly dialog = inject(MatDialog); private readonly i18n = inject(I18nService); @@ -63,12 +69,12 @@ export class PokemonListComponent implements OnInit { // Search & quick filters readonly searchControl = new FormControl(''); private readonly searchValue = toSignal(this.searchControl.valueChanges, { initialValue: '' }); - private readonly snackBar = inject(MatSnackBar); + readonly activeFilter = signal(null); readonly activeGen = signal<{ label: string; min: number; max: number } | null>(null); - readonly monsters = signal([]); + readonly sortBy = signal<'name' | 'id' | 'evolution' | 'generation'>('name'); readonly filteredMonsters = computed(() => { const gen = this.activeGen(); @@ -145,6 +151,8 @@ export class PokemonListComponent implements OnInit { readonly loading = signal(true); + readonly profileAreas = signal([]); + readonly selectedIds = signal(new Set()); // Bulk operations @@ -279,6 +287,22 @@ export class PokemonListComponent implements OnInit { }); } + /** Change one alarm's delivery scope from its card, without opening the whole edit dialog. */ + editScope(monster: Monster): void { + const data: WhereSheetData = { + profileAreas: this.profileAreas(), + scope: scopeOf(monster.overrideLocationLabel, monster.overrideAreas, monster.distance), + }; + + this.dialog + .open(WhereSheetComponent, { width: '520px', autoFocus: false, data }) + .afterClosed() + .subscribe((scope?: AlarmScope) => { + if (!scope) return; + this.applyScope(monster, scope); + }); + } + formatDistance(meters: number): string { if (meters >= 1000) { return `${(meters / 1000).toFixed(1)} km`; @@ -389,6 +413,13 @@ export class PokemonListComponent implements OnInit { // Ensure masterdata is loaded this.masterData.loadData().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); this.loadMonsters(); + + // Only used to word the inherited scope honestly: "anywhere in my areas" is a lie for a user who + // has none selected. A failure leaves it empty, which produces the more cautious wording. + this.areaService + .getSelected() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ error: () => undefined, next: areas => this.profileAreas.set(areas) }); } onImageError(event: Event, pokemonId: number): void { @@ -459,6 +490,18 @@ export class PokemonListComponent implements OnInit { }); } + private applyScope(monster: Monster, scope: AlarmScope): void { + const fields = scopeToFields(scope); + + this.monsterService.update(monster.uid, fields).subscribe({ + error: () => this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVE_ERROR'), this.i18n.instant('COMMON.OK'), { duration: 4000 }), + next: () => { + this.snackBar.open(this.i18n.instant('WHERE.SCOPE_SAVED'), this.i18n.instant('COMMON.OK'), { duration: 2500 }); + this.loadMonsters(); + }, + }); + } + private getBaseEvolution(id: number): number { return this.masterData.getBaseEvolution(id); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.spec.ts new file mode 100644 index 00000000..762e7d0c --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-chip/where-chip.component.spec.ts @@ -0,0 +1,61 @@ +import { ComponentRef } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideTranslateService } from '@ngx-translate/core'; + +import { WhereChipComponent } from './where-chip.component'; + +describe('WhereChipComponent', () => { + let fixture: ComponentFixture; + let ref: ComponentRef; + + function create(inputs: Record): WhereChipComponent { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideTranslateService()], + imports: [WhereChipComponent], + }); + fixture = TestBed.createComponent(WhereChipComponent); + ref = fixture.componentRef; + Object.entries(inputs).forEach(([key, value]) => ref.setInput(key, value)); + fixture.detectChanges(); + return fixture.componentInstance; + } + + it('names the place and radius for a place-scoped alarm', () => { + const chip = create({ overrideLocationLabel: 'work', distance: 2000 }); + + expect(chip.label()).toBe('WHERE.NEAR_PLACE'); + expect(chip.icon()).toBe('place'); + expect(chip.isInherited()).toBe(false); + }); + + it('says the pin, not the areas, for a plain radius', () => { + // The pre-existing "within N km of me" alarm. Reading it as inherited areas would put the opposite + // words on the card, which is the whole reason the profile mode carries a radius. + const chip = create({ distance: 500 }); + + expect(chip.label()).toBe('WHERE.NEAR_PIN'); + expect(chip.isInherited()).toBe(true); + }); + + it('recedes for the inherited scope, since nearly every card has it', () => { + const chip = create({ distance: 0, profileAreas: ['terrigal'] }); + + expect(chip.label()).toBe('WHERE.PROFILE_AREAS'); + expect(chip.icon()).toBe('public'); + expect(chip.isInherited()).toBe(true); + }); + + it('does not claim areas the user has not got', () => { + const chip = create({ distance: 0, profileAreas: [] }); + + expect(chip.label()).toBe('WHERE.PROFILE_ANYWHERE'); + }); + + it('shows the map icon when the alarm is confined to areas', () => { + const chip = create({ overrideAreas: ['terrigal'], distance: 0 }); + + expect(chip.label()).toBe('WHERE.ONLY_IN'); + expect(chip.icon()).toBe('map'); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.html index cf8dcea9..f4b89fcd 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.html @@ -3,7 +3,7 @@

{{ 'WHERE.SHEET_TITLE' | translate }}