Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@
<mat-icon>tune</mat-icon>
<span>{{ 'MENU.ALERT_DEFAULTS' | translate }}</span>
</button>
<button mat-menu-item (click)="openPlaces()">
<mat-icon>place</mat-icon>
<span>{{ 'MENU.PLACES' | translate }}</span>
</button>
<button mat-menu-item [matMenuTriggerFor]="accentMenu">
<mat-icon>palette</mat-icon>
<span>{{ 'MENU.ACCENT_THEME' | translate }}</span>
Expand Down
5 changes: 5 additions & 0 deletions Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { DashboardService } from './core/services/dashboard.service';
import { I18nService } from './core/services/i18n.service';
import { SettingsService } from './core/services/settings.service';
import { AlertDefaultsDialogComponent } from './shared/components/alert-defaults-dialog/alert-defaults-dialog.component';
import { PlacesDialogComponent } from './shared/components/places-dialog/places-dialog.component';

interface NavItem {
adminOnly?: boolean;
Expand Down Expand Up @@ -381,6 +382,10 @@ export class App implements OnInit {
this.dialog.open(AlertDefaultsDialogComponent, { width: '480px', autoFocus: false });
}

openPlaces(): void {
this.dialog.open(PlacesDialogComponent, { width: '480px', autoFocus: false });
}

setAccentTheme(theme: string): void {
this.accentTheme.set(theme);
localStorage.setItem('poracle-accent', theme);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -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 | SavedPlaces>(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<SavedPlaces> {
return this.http.post<SavedPlaces>(`${this.config.apiHost}/api/location/places`, place).pipe(tap(updated => this.places.set(updated)));
}

load(): Observable<SavedPlaces> {
return this.http.get<SavedPlaces>(`${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<void> {
return this.http
.delete<void>(`${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))),
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -289,11 +289,23 @@ <h4>{{ 'ALARM.LOCATION_MODE' | translate }}</h4>
</mat-radio-group>

@if (notifForm.controls.distanceMode.value === 'distance') {
<mat-form-field appearance="outline" class="full-width">
<mat-label>{{ 'ALARM.DISTANCE_LABEL' | translate }}</mat-label>
<input matInput type="number" [formControl]="notifForm.controls.distanceKm" min="0" step="0.1" />
<span matSuffix>{{ 'ALARM.DISTANCE_SUFFIX' | translate }}</span>
</mat-form-field>
<div class="scope-near-row">
<mat-form-field appearance="outline">
<mat-label>{{ 'WHERE.MEASURED_FROM' | translate }}</mat-label>
<mat-select [formControl]="notifForm.controls.placeLabel">
<mat-option value="">{{ 'WHERE.MY_PIN' | translate }}</mat-option>
@for (place of places.named(); track place.label) {
<mat-option [value]="place.label">{{ place.label }}</mat-option>
}
</mat-select>
</mat-form-field>

<mat-form-field appearance="outline">
<mat-label>{{ 'ALARM.DISTANCE_LABEL' | translate }}</mat-label>
<input matInput type="number" [formControl]="notifForm.controls.distanceKm" min="0" step="0.1" />
<span matSuffix>{{ 'ALARM.DISTANCE_SUFFIX' | translate }}</span>
</mat-form-field>
</div>
}

<app-delivery-preview
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,9 @@ mat-expansion-panel {
min-width: 0;
}
}

.scope-near-row {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,46 @@ describe('PokemonAddDialogComponent', () => {
);
});

it('sends the scope fields for an alarm aimed at a saved place', () => {
component.selectedPokemonIds.set([MEOWTH]);
component.notifForm.controls.distanceMode.setValue('distance');
component.notifForm.controls.distanceKm.setValue(2);
component.notifForm.controls.placeLabel.setValue('work');

component.save();

const created = monsterService.create.mock.calls[0][0] as MonsterCreate;
expect(created.overrideLocationLabel).toBe('work');
expect(created.distance).toBe(2000);
expect(created.overrideAreas).toEqual([]);
});

it('leaves the radius on the pin when no place is chosen', () => {
// The legitimate-case half: "within 2 km of me" is the alarm most people make, and it must not
// acquire a location override just because the field exists.
component.selectedPokemonIds.set([MEOWTH]);
component.notifForm.controls.distanceMode.setValue('distance');
component.notifForm.controls.distanceKm.setValue(2);

component.save();

const created = monsterService.create.mock.calls[0][0] as MonsterCreate;
expect(created.overrideLocationLabel).toBe('');
expect(created.distance).toBe(2000);
});

it('clears the radius and any place when the alarm uses areas', () => {
component.selectedPokemonIds.set([MEOWTH]);
component.notifForm.controls.distanceMode.setValue('areas');
component.notifForm.controls.placeLabel.setValue('work');

component.save();

const created = monsterService.create.mock.calls[0][0] as MonsterCreate;
expect(created.distance).toBe(0);
expect(created.overrideLocationLabel).toBe('');
});

it('does nothing when no pokemon are selected', () => {
component.filtersForm.controls.forms.setValue([ALOLAN]);
component.save();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ import { AuthService } from '../../core/services/auth.service';
import { I18nService } from '../../core/services/i18n.service';
import { MasterDataService } from '../../core/services/masterdata.service';
import { MonsterService } from '../../core/services/monster.service';
import { PlacesService } from '../../core/services/places.service';
import { PoracleConfigService } from '../../core/services/poracle-config.service';
import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component';
import { PokemonSelectorComponent } from '../../shared/components/pokemon-selector/pokemon-selector.component';
import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component';
import { scopeToFields } from '../../shared/utils/alarm-scope';

@Component({
imports: [
Expand Down Expand Up @@ -96,14 +98,18 @@ export class PokemonAddDialogComponent implements OnInit {
});

readonly isWebhook = inject(AuthService).isImpersonating();

notifForm = this.fb.group({
clean: [false],
distanceKm: [this.alertDefaults.defaultDistanceKm()],
distanceMode: [this.alertDefaults.defaultMode()],
// Empty means the profile pin, which is what "set a distance" has always meant. A label points the
// radius at a saved place instead.
placeLabel: [''],
template: [''],
});

readonly places = inject(PlacesService);

/** Caps offered by Poracle (e.g. [50] or [50, 51]). Empty = hide the cap picker entirely. */
readonly pvpCaps = computed(() => this.poracleConfig.serverConfig().pvpCaps);

Expand All @@ -125,6 +131,10 @@ export class PokemonAddDialogComponent implements OnInit {
}

ngOnInit(): void {
// Saved places for the "measured from" selector. A failure leaves only the pin, which is what the
// dialog offered before per-alarm scope existed.
this.places.load().subscribe({ error: () => undefined });

// Pre-fill the cap from Poracle's admin-configured default. Users can still override.
this.poracleConfig.load().subscribe(cfg => {
this.pvpForm.controls.pvpRankingCap.setValue(cfg.defaultPvpCap);
Expand All @@ -136,11 +146,22 @@ export class PokemonAddDialogComponent implements OnInit {
}

onDistanceModeChange(): void {
if (this.notifForm.controls.distanceMode.value === 'areas') {
const mode = this.notifForm.controls.distanceMode.value;

if (mode === 'areas') {
this.notifForm.controls.distanceKm.setValue(0);
} else if (!this.notifForm.controls.distanceKm.value) {
this.notifForm.controls.placeLabel.setValue('');
return;
}

if (!this.notifForm.controls.distanceKm.value) {
this.notifForm.controls.distanceKm.setValue(1);
}

if (mode === 'distance') {
// Back to the pin. Leaving a stale label would send a place the user can no longer see selected.
this.notifForm.controls.placeLabel.setValue('');
}
}

onPokemonSelected(ids: number[]): void {
Expand All @@ -154,7 +175,13 @@ export class PokemonAddDialogComponent implements OnInit {
const filters = this.filtersForm.getRawValue();
const pvp = this.pvpForm.getRawValue();
const notif = this.notifForm.getRawValue();
const distanceMeters = notif.distanceMode === 'areas' ? 0 : Math.round((notif.distanceKm ?? 1) * 1000);
// One conversion for all three answers, shared with the card chip and the scope sheet, so the wire
// format has a single implementation.
const scope = scopeToFields(
notif.distanceMode === 'areas'
? { mode: 'profile' }
: { distanceKm: notif.distanceKm ?? 1, mode: notif.placeLabel ? 'place' : 'profile', placeLabel: notif.placeLabel ?? '' },
);

// PoracleNG models `form` as a single int per tracking entry, so a multi-form
// selection fans out into one alarm per form. When specific forms are available we
Expand All @@ -166,10 +193,12 @@ export class PokemonAddDialogComponent implements OnInit {
const creates = this.selectedPokemonIds().flatMap(pokemonId =>
formIds.map(form => {
const monster: MonsterCreate = {
overrideAreas: scope.overrideAreas,
overrideLocationLabel: scope.overrideLocationLabel,
atk: filters.atk ?? 0,
clean: notif.clean ? 1 : 0,
def: filters.def ?? 0,
distance: distanceMeters,
distance: scope.distance,
form,
gender: filters.gender ?? 0,
maxAtk: filters.maxAtk ?? 15,
Expand Down
Loading
Loading