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..135009ea --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/places-dialog/places-dialog.component.ts @@ -0,0 +1,144 @@ +import { DecimalPipe } from '@angular/common'; +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: [DecimalPipe, 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/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.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-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..f4b89fcd --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.html @@ -0,0 +1,75 @@ +

{{ '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..48feb64d --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/where-sheet/where-sheet.component.ts @@ -0,0 +1,131 @@ +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, titleCaseArea } from '../../utils/alarm-scope'; + +/** + * What the sheet offers, which is not quite what PoracleNG stores. "Near a point" covers both a radius + * from the profile pin and a radius from a saved place, because to a person those are one choice with + * a target, not two unrelated modes. The mapping back to the stored fields happens on save. + */ +type SheetMode = 'areas' | 'inherit' | 'near'; + +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(initialMode(this.data.scope)); + readonly selectedAreas = signal(this.data.scope.areas ?? []); + readonly canSave = computed(() => { + switch (this.mode()) { + case 'areas': + return this.selectedAreas().length > 0; + case 'near': + // The pin needs no label, only a radius. + return this.distanceKm() > 0; + default: + return true; + } + }); + + readonly dialogRef = inject>(MatDialogRef); + + /** Empty means the profile pin; anything else is a saved place's label. */ + readonly placeLabel = signal(this.data.scope.placeLabel ?? ''); + + 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 'near': + return this.placeLabel() + ? { distanceKm: this.distanceKm(), mode: 'place', placeLabel: this.placeLabel() } + : { distanceKm: this.distanceKm(), mode: 'profile' }; + default: + return { mode: 'profile' }; + } + } +} + +/** A stored scope back into the sheet's three options. A pin radius lands on "near", not "inherit". */ +function initialMode(scope: AlarmScope): SheetMode { + if (scope.mode === 'areas') return 'areas'; + if (scope.mode === 'place') return 'near'; + return (scope.distanceKm ?? 0) > 0 ? 'near' : 'inherit'; +} 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..6bc7c8a5 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.spec.ts @@ -0,0 +1,101 @@ +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 a radius with no place as measured from the pin, not as inherited areas', () => { + // This is the pre-existing "within N km of me" alarm. Collapsing it into the areas reading would + // put the opposite words on the card. + expect(scopeOf(null, null, 500)).toEqual({ distanceKm: 0.5, mode: 'profile' }); + }); + + it('reads no overrides and no radius as inherited', () => { + expect(scopeOf(null, null, 0)).toEqual({ distanceKm: 0, 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({ distanceKm: 0, 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'); + }); + + it('says the pin, not the areas, when the inherited scope carries a radius', () => { + expect(describeScope({ distanceKm: 2, mode: 'profile' }, ['terrigal'], translate)).toBe('WHERE.NEAR_PIN:{"distance":"2"}'); + }); + }); + + 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..8b92fbbb --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/utils/alarm-scope.ts @@ -0,0 +1,117 @@ +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, + }; + } + + // A radius with no place is the behaviour that predates per-alarm scope: measured from the profile + // pin. It has to survive as its own reading, or "within 500 m of me" renders as "anywhere in my + // areas" — the same words for the opposite of what the alarm does. + return { distanceKm: metresToKm(distanceMetres), 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: + // Inherited scope, with or without a radius from the pin. Both overrides are cleared explicitly. + return { overrideAreas: [], overrideLocationLabel: '', distance: kmToMetres(scope.distanceKm ?? 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: + if ((scope.distanceKm ?? 0) > 0) { + return translate.instant('WHERE.NEAR_PIN', { distance: formatDistance(scope.distanceKm ?? 0) }); + } + + 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..1fa36dec 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", @@ -1713,5 +1714,44 @@ "PREVIOUS_PAGE": "Forrige side", "FIRST_PAGE": "Første side", "LAST_PAGE": "Sidste side" + }, + "WHERE": { + "EDIT_FROM_CARD": "Change this from the card.", + "MEASURED_FROM": "Measured from", + "SCOPE_SAVED": "Where updated.", + "SCOPE_SAVE_ERROR": "Could not update where that alert reaches you.", + "MY_PIN": "My pin", + "NEAR_PIN": "Within {{distance}} km of my pin", + "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}}", + "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_NEAR": "Near a point", + "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..c9255f64 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", @@ -1713,5 +1714,44 @@ "PREVIOUS_PAGE": "Vorherige Seite", "FIRST_PAGE": "Erste Seite", "LAST_PAGE": "Letzte Seite" + }, + "WHERE": { + "EDIT_FROM_CARD": "Change this from the card.", + "MEASURED_FROM": "Measured from", + "SCOPE_SAVED": "Where updated.", + "SCOPE_SAVE_ERROR": "Could not update where that alert reaches you.", + "MY_PIN": "My pin", + "NEAR_PIN": "Within {{distance}} km of my pin", + "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}}", + "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_NEAR": "Near a point", + "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..a908a925 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", @@ -1713,5 +1714,44 @@ "PREVIOUS_PAGE": "Previous page", "FIRST_PAGE": "First page", "LAST_PAGE": "Last page" + }, + "WHERE": { + "EDIT_FROM_CARD": "Change this from the card.", + "MEASURED_FROM": "Measured from", + "SCOPE_SAVED": "Where updated.", + "SCOPE_SAVE_ERROR": "Could not update where that alert reaches you.", + "MY_PIN": "My pin", + "NEAR_PIN": "Within {{distance}} km of my pin", + "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}}", + "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_NEAR": "Near a point", + "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..787c0a05 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", @@ -1713,5 +1714,44 @@ "PREVIOUS_PAGE": "Página anterior", "FIRST_PAGE": "Primera página", "LAST_PAGE": "Última página" + }, + "WHERE": { + "EDIT_FROM_CARD": "Change this from the card.", + "MEASURED_FROM": "Measured from", + "SCOPE_SAVED": "Where updated.", + "SCOPE_SAVE_ERROR": "Could not update where that alert reaches you.", + "MY_PIN": "My pin", + "NEAR_PIN": "Within {{distance}} km of my pin", + "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}}", + "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_NEAR": "Near a point", + "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..51248715 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", @@ -1713,5 +1714,44 @@ "PREVIOUS_PAGE": "Page précédente", "FIRST_PAGE": "Première page", "LAST_PAGE": "Dernière page" + }, + "WHERE": { + "EDIT_FROM_CARD": "Change this from the card.", + "MEASURED_FROM": "Measured from", + "SCOPE_SAVED": "Where updated.", + "SCOPE_SAVE_ERROR": "Could not update where that alert reaches you.", + "MY_PIN": "My pin", + "NEAR_PIN": "Within {{distance}} km of my pin", + "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}}", + "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_NEAR": "Near a point", + "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..0cd06cd9 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", @@ -1713,5 +1714,44 @@ "PREVIOUS_PAGE": "Pagina precedente", "FIRST_PAGE": "Prima pagina", "LAST_PAGE": "Ultima pagina" + }, + "WHERE": { + "EDIT_FROM_CARD": "Change this from the card.", + "MEASURED_FROM": "Measured from", + "SCOPE_SAVED": "Where updated.", + "SCOPE_SAVE_ERROR": "Could not update where that alert reaches you.", + "MY_PIN": "My pin", + "NEAR_PIN": "Within {{distance}} km of my pin", + "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}}", + "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_NEAR": "Near a point", + "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..01b2040f 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", @@ -1713,5 +1714,44 @@ "PREVIOUS_PAGE": "Vorige pagina", "FIRST_PAGE": "Eerste pagina", "LAST_PAGE": "Laatste pagina" + }, + "WHERE": { + "EDIT_FROM_CARD": "Change this from the card.", + "MEASURED_FROM": "Measured from", + "SCOPE_SAVED": "Where updated.", + "SCOPE_SAVE_ERROR": "Could not update where that alert reaches you.", + "MY_PIN": "My pin", + "NEAR_PIN": "Within {{distance}} km of my pin", + "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}}", + "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_NEAR": "Near a point", + "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..06184ff8 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", @@ -1713,5 +1714,44 @@ "PREVIOUS_PAGE": "Poprzednia strona", "FIRST_PAGE": "Pierwsza strona", "LAST_PAGE": "Ostatnia strona" + }, + "WHERE": { + "EDIT_FROM_CARD": "Change this from the card.", + "MEASURED_FROM": "Measured from", + "SCOPE_SAVED": "Where updated.", + "SCOPE_SAVE_ERROR": "Could not update where that alert reaches you.", + "MY_PIN": "My pin", + "NEAR_PIN": "Within {{distance}} km of my pin", + "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}}", + "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_NEAR": "Near a point", + "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..040b5fab 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", @@ -1713,5 +1714,44 @@ "PREVIOUS_PAGE": "Página anterior", "FIRST_PAGE": "Primeira página", "LAST_PAGE": "Última página" + }, + "WHERE": { + "EDIT_FROM_CARD": "Change this from the card.", + "MEASURED_FROM": "Measured from", + "SCOPE_SAVED": "Where updated.", + "SCOPE_SAVE_ERROR": "Could not update where that alert reaches you.", + "MY_PIN": "My pin", + "NEAR_PIN": "Within {{distance}} km of my pin", + "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}}", + "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_NEAR": "Near a point", + "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..b19c8c1d 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", @@ -1713,5 +1714,44 @@ "PREVIOUS_PAGE": "Página anterior", "FIRST_PAGE": "Primeira página", "LAST_PAGE": "Última página" + }, + "WHERE": { + "EDIT_FROM_CARD": "Change this from the card.", + "MEASURED_FROM": "Measured from", + "SCOPE_SAVED": "Where updated.", + "SCOPE_SAVE_ERROR": "Could not update where that alert reaches you.", + "MY_PIN": "My pin", + "NEAR_PIN": "Within {{distance}} km of my pin", + "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}}", + "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_NEAR": "Near a point", + "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..757d27f7 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", @@ -1713,5 +1714,44 @@ "PREVIOUS_PAGE": "Föregående sida", "FIRST_PAGE": "Första sidan", "LAST_PAGE": "Sista sidan" + }, + "WHERE": { + "EDIT_FROM_CARD": "Change this from the card.", + "MEASURED_FROM": "Measured from", + "SCOPE_SAVED": "Where updated.", + "SCOPE_SAVE_ERROR": "Could not update where that alert reaches you.", + "MY_PIN": "My pin", + "NEAR_PIN": "Within {{distance}} km of my pin", + "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}}", + "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_NEAR": "Near a point", + "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 844456c0..8531e9d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Alarms can be aimed somewhere other than your pin.** PoracleNG 5.1.0 gives every alarm its own delivery scope, and the API now carries it: an alarm can measure its radius from a saved place ("within 2 km of work") or be confined to a set of areas, instead of inheriting the profile's single pin and area list. Saved places are managed at `GET/POST /api/location/places` and `DELETE /api/location/places/{label}`; deleting a place that alarms still point at answers 409 and names them rather than orphaning the label. The three ways a scope can contradict itself — a place and areas together, areas with a radius, a place without one — are refused before anything is written, with wording that says which one to change ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). - **Your own geofences work as an alarm's areas.** PoracleNG refuses `override_areas` entries whose fence is not user-selectable, and PoracleWeb serves user-drawn geofences that way deliberately, to keep them out of the bot's area picker — so naming one would have failed the whole write with "area not permitted". Matching never consults that flag, so the permitted names are sent to PoracleNG and the full list is written to the alarm afterwards, then state is reloaded. Verified against PoracleNG 5.1.0's matcher rather than inferred. Tagged `HACK: trusted-set-areas` alongside the existing area workarounds, and removable in one piece if PoracleNG grows a trusted override write ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **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)). +- **The Pokemon card says where each alert reaches you, and lets you change it there.** The chip replaces the old areas-or-distance badge and covers the cases it could not say: within a radius of a saved place, or confined to specific areas. Clicking it opens the scope picker without opening the whole edit dialog. The other nine alarm types follow ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). +- **New Pokemon alarms can be aimed at a saved place.** The delivery step's radius gains a "measured from" selector: your pin, as before, or any place you have saved. Editing an alarm shows its scope but sends you to the card to change it, so there is one way to do it rather than two that can disagree ([#730](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/730)). ### Fixed