From d58752eafa1129fd1bb9ee34fc4f814da66020fa Mon Sep 17 00:00:00 2001 From: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> Date: Fri, 22 May 2026 13:19:19 -0400 Subject: [PATCH 1/5] feat(raids): redesign level selector with named tiers + custom palette (#259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The raid/egg add dialog had three level-pickers (raid checkboxes, egg checkboxes, boss-level dropdown) all driven by a hardcoded `levels = [1, 2, 3, 4, 5, 6]` array, even though PoracleNG accepts any positive integer. Users with Elite Raids (level 7) or custom server schemes had to configure those via the bot's `!command` interface; the UI silently locked them out. Replace all three sites with a new `` shared component — a Material 3 chip listbox in three sections: - STANDARD: T1-T5 - SPECIAL: Mega (6), Elite (7), plus "Any" (9000) when showAny=true - CUSTOM: any user-added integer, persisted per-user in localStorage Power users add a custom level via an inline "+ Add level" affordance that transforms into a numeric input. The chip then persists across dialog opens (one-click selection on subsequent alarms) and seeds itself from saved alarm data on open, so editing an existing level-42 alarm renders the chip pre-selected rather than orphaned. Single source of truth for label resolution lives in `core/models/raid-level.models.ts`. `resolveLevel(value)` maps any integer to the right LevelOption — adopted by raid-list cards too, so the dialog and the cards now speak the same vocabulary ("Elite", not "Level 7" vs "7" on different surfaces). Edge cases: - 0 / negatives / non-integers in custom input -> inline validation error - 9000 in custom input -> snaps to the "Any" chip (no duplicate) - duplicate of built-in -> flashes existing chip + selects, no new entry - 20-entry LRU cap on the localStorage palette i18n: new `RAIDS.LEVEL.*` keys added in all 11 locales with English fallbacks for non-en (translation volunteers can localize later, per discussion #211). Bonus correctness: the boss tab used to default level=0 ("any") but PoracleNG's canonical wildcard sentinel is 9000. New alarms now use 9000; old alarms with 0 continue to work and edit fine. 37 new unit tests across the model, store, pipe, and component. Closes #259, reported by @prof-miles0. --- .../app/core/models/raid-level.models.spec.ts | 84 ++++++++ .../src/app/core/models/raid-level.models.ts | 64 ++++++ .../custom-level-store.service.spec.ts | 97 +++++++++ .../services/custom-level-store.service.ts | 86 ++++++++ .../raids/raid-add-dialog.component.html | 27 +-- .../raids/raid-add-dialog.component.ts | 30 ++- .../app/modules/raids/raid-list.component.ts | 12 +- .../level-selector.component.html | 111 +++++++++++ .../level-selector.component.scss | 106 ++++++++++ .../level-selector.component.spec.ts | 171 ++++++++++++++++ .../level-selector.component.ts | 184 ++++++++++++++++++ .../app/shared/pipes/level-label.pipe.spec.ts | 42 ++++ .../src/app/shared/pipes/level-label.pipe.ts | 28 +++ .../ClientApp/src/assets/i18n/da.json | 22 ++- .../ClientApp/src/assets/i18n/de.json | 22 ++- .../ClientApp/src/assets/i18n/en.json | 22 ++- .../ClientApp/src/assets/i18n/es.json | 22 ++- .../ClientApp/src/assets/i18n/fr.json | 22 ++- .../ClientApp/src/assets/i18n/it.json | 22 ++- .../ClientApp/src/assets/i18n/nl.json | 22 ++- .../ClientApp/src/assets/i18n/pl.json | 22 ++- .../ClientApp/src/assets/i18n/pt-BR.json | 22 ++- .../ClientApp/src/assets/i18n/pt.json | 22 ++- .../ClientApp/src/assets/i18n/sv.json | 22 ++- CHANGELOG.md | 1 + 25 files changed, 1227 insertions(+), 58 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.scss create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.ts diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.spec.ts new file mode 100644 index 00000000..44ee21bb --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.spec.ts @@ -0,0 +1,84 @@ +import { + ANY_LEVEL, + ANY_LEVEL_VALUE, + isBuiltInLevel, + makeCustomLevel, + resolveLevel, + SPECIAL_LEVELS, + STANDARD_LEVELS, +} from './raid-level.models'; + +describe('raid-level.models', () => { + describe('resolveLevel', () => { + it('returns the ANY_LEVEL option for the 9000 sentinel', () => { + expect(resolveLevel(9000)).toEqual(ANY_LEVEL); + expect(resolveLevel(ANY_LEVEL_VALUE)).toEqual(ANY_LEVEL); + }); + + it('returns the standard option for tiers 1-5', () => { + for (const level of [1, 2, 3, 4, 5]) { + const opt = resolveLevel(level); + expect(opt.value).toBe(level); + expect(opt.category).toBe('standard'); + expect(opt.labelKey).toBe(`RAIDS.LEVEL.T${level}`); + } + }); + + it('returns Mega for level 6 and Elite for level 7', () => { + expect(resolveLevel(6).labelKey).toBe('RAIDS.LEVEL.MEGA'); + expect(resolveLevel(6).category).toBe('special'); + expect(resolveLevel(6).badge).toBe(6); + expect(resolveLevel(7).labelKey).toBe('RAIDS.LEVEL.ELITE'); + expect(resolveLevel(7).category).toBe('special'); + }); + + it('returns a custom option for unrecognized levels', () => { + const opt = resolveLevel(42); + expect(opt.value).toBe(42); + expect(opt.category).toBe('custom'); + expect(opt.badge).toBe(42); + expect(opt.labelKey).toBe('RAIDS.LEVEL.CUSTOM'); + }); + + it('returns custom for level 0 and negative inputs (PoracleNG rejects them but the resolver is permissive)', () => { + expect(resolveLevel(0).category).toBe('custom'); + expect(resolveLevel(-1).category).toBe('custom'); + }); + }); + + describe('isBuiltInLevel', () => { + it('is true for standard, special, and any', () => { + expect(isBuiltInLevel(1)).toBe(true); + expect(isBuiltInLevel(5)).toBe(true); + expect(isBuiltInLevel(6)).toBe(true); + expect(isBuiltInLevel(7)).toBe(true); + expect(isBuiltInLevel(ANY_LEVEL_VALUE)).toBe(true); + }); + + it('is false for custom levels', () => { + expect(isBuiltInLevel(8)).toBe(false); + expect(isBuiltInLevel(42)).toBe(false); + expect(isBuiltInLevel(0)).toBe(false); + }); + }); + + describe('makeCustomLevel', () => { + it('builds a custom option that round-trips through resolveLevel', () => { + const custom = makeCustomLevel(66); + expect(resolveLevel(66)).toEqual(custom); + }); + }); + + describe('STANDARD_LEVELS and SPECIAL_LEVELS', () => { + it('have non-overlapping values', () => { + const std = new Set(STANDARD_LEVELS.map(l => l.value)); + const sp = new Set(SPECIAL_LEVELS.map(l => l.value)); + for (const v of std) expect(sp.has(v)).toBe(false); + }); + + it('do not include the ANY sentinel', () => { + const all = [...STANDARD_LEVELS, ...SPECIAL_LEVELS].map(l => l.value); + expect(all).not.toContain(ANY_LEVEL_VALUE); + }); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.ts new file mode 100644 index 00000000..7320b52f --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.ts @@ -0,0 +1,64 @@ +// PoracleNG accepts any positive integer as a raid/egg level. The UI presents +// a curated vocabulary (Standard tiers, Special tiers like Mega/Elite, and a +// per-user Custom palette) on top of that integer space, plus the canonical +// "Any" sentinel (9000) that PoracleNG treats as a wildcard. +// +// `resolveLevel(value)` is the single place that maps a stored integer to the +// option it should render as — used by both the level selector dialog and the +// alarm cards so the vocabulary stays consistent across surfaces. + +export type LevelCategory = 'standard' | 'special' | 'any' | 'custom'; + +export interface LevelOption { + /** Optional integer shown as inline metadata (e.g., 6 next to "Mega"). */ + badge?: number; + category: LevelCategory; + /** ngx-translate key for the human label (e.g., `RAIDS.LEVEL.MEGA`). */ + labelKey: string; + /** Backend integer. PoracleNG accepts any positive integer. */ + value: number; +} + +/** PoracleNG's wildcard sentinel — matches any raid level. */ +export const ANY_LEVEL_VALUE = 9000 as const; + +export const STANDARD_LEVELS: readonly LevelOption[] = [ + { category: 'standard', labelKey: 'RAIDS.LEVEL.T1', value: 1 }, + { category: 'standard', labelKey: 'RAIDS.LEVEL.T2', value: 2 }, + { category: 'standard', labelKey: 'RAIDS.LEVEL.T3', value: 3 }, + { category: 'standard', labelKey: 'RAIDS.LEVEL.T4', value: 4 }, + { category: 'standard', labelKey: 'RAIDS.LEVEL.T5', value: 5 }, +]; + +export const SPECIAL_LEVELS: readonly LevelOption[] = [ + { badge: 6, category: 'special', labelKey: 'RAIDS.LEVEL.MEGA', value: 6 }, + { badge: 7, category: 'special', labelKey: 'RAIDS.LEVEL.ELITE', value: 7 }, +]; + +export const ANY_LEVEL: LevelOption = { + category: 'any', + labelKey: 'RAIDS.LEVEL.ANY', + value: ANY_LEVEL_VALUE, +}; + +/** Build a display option for an arbitrary integer level. */ +export function makeCustomLevel(value: number): LevelOption { + return { badge: value, category: 'custom', labelKey: 'RAIDS.LEVEL.CUSTOM', value }; +} + +/** + * Resolve a raw stored integer to its display option. Used by the dialog + * (to highlight the right chip) and by alarm cards (to render the right label). + */ +export function resolveLevel(value: number): LevelOption { + if (value === ANY_LEVEL_VALUE) return ANY_LEVEL; + return STANDARD_LEVELS.find(l => l.value === value) ?? SPECIAL_LEVELS.find(l => l.value === value) ?? makeCustomLevel(value); +} + +/** + * True if `value` is one of the standard or special levels (i.e., something + * the selector renders as a built-in chip rather than a custom one). + */ +export function isBuiltInLevel(value: number): boolean { + return value === ANY_LEVEL_VALUE || STANDARD_LEVELS.some(l => l.value === value) || SPECIAL_LEVELS.some(l => l.value === value); +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.spec.ts new file mode 100644 index 00000000..225ad3b5 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.spec.ts @@ -0,0 +1,97 @@ +import { TestBed } from '@angular/core/testing'; + +import { CustomLevelStore } from './custom-level-store.service'; + +const STORAGE_KEY = 'poracle.custom-raid-levels'; + +describe('CustomLevelStore', () => { + beforeEach(() => { + localStorage.removeItem(STORAGE_KEY); + TestBed.resetTestingModule(); + }); + + function makeStore(): CustomLevelStore { + TestBed.configureTestingModule({}); + return TestBed.inject(CustomLevelStore); + } + + it('starts empty when localStorage has nothing', () => { + const store = makeStore(); + expect(store.values()).toEqual([]); + }); + + it('adds a custom level and persists it', () => { + const store = makeStore(); + expect(store.add(42)).toBe(true); + expect(store.values()).toEqual([42]); + expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([42]); + }); + + it('rejects built-in levels (1-7 and 9000)', () => { + const store = makeStore(); + for (const level of [1, 2, 3, 4, 5, 6, 7, 9000]) { + expect(store.add(level)).toBe(false); + } + expect(store.values()).toEqual([]); + }); + + it('rejects zero, negatives, and non-integers', () => { + const store = makeStore(); + expect(store.add(0)).toBe(false); + expect(store.add(-3)).toBe(false); + expect(store.add(7.5)).toBe(false); + expect(store.add(Number.NaN)).toBe(false); + expect(store.values()).toEqual([]); + }); + + it('deduplicates on add', () => { + const store = makeStore(); + store.add(42); + store.add(42); + expect(store.values()).toEqual([42]); + }); + + it('removes a value', () => { + const store = makeStore(); + store.add(42); + store.add(100); + store.remove(42); + expect(store.values()).toEqual([100]); + expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([100]); + }); + + it('seeds custom values from existing alarms, skipping built-ins', () => { + const store = makeStore(); + store.seedFrom([1, 7, 42, 9000, 99]); // 1, 7, 9000 are built-in + expect(store.values()).toEqual([42, 99]); + }); + + it('loads persisted values on construction', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify([8, 42, 99])); + const store = makeStore(); + expect(store.values()).toEqual([8, 42, 99]); + }); + + it('drops invalid entries from persisted state on load', () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify([8, 'oops', -1, 9000, 42])); + const store = makeStore(); + expect(store.values()).toEqual([8, 42]); + }); + + it('caps persisted entries at the LRU max', () => { + const store = makeStore(); + for (let i = 100; i < 130; i++) { + store.add(i); + } + // 30 unique entries added, cap is 20 — first 10 should be dropped + expect(store.values().length).toBe(20); + expect(store.values()[0]).toBe(110); + expect(store.values()[19]).toBe(129); + }); + + it('survives malformed JSON in localStorage', () => { + localStorage.setItem(STORAGE_KEY, 'not-json-at-all'); + const store = makeStore(); + expect(store.values()).toEqual([]); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.ts new file mode 100644 index 00000000..a7c92898 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.ts @@ -0,0 +1,86 @@ +import { Injectable, signal } from '@angular/core'; + +import { ANY_LEVEL_VALUE, isBuiltInLevel } from '../models/raid-level.models'; + +const STORAGE_KEY = 'poracle.custom-raid-levels'; + +/** Maximum number of custom levels to persist. Excess values are dropped LRU-style. */ +const MAX_ENTRIES = 20; + +/** + * Per-user palette of custom raid/egg levels — anything outside the standard + * tiers (1-5), the special tiers (6/Mega, 7/Elite), and the 9000 "Any" sentinel. + * + * Backed by localStorage so a user's "always alarm on level 8" preference + * survives across dialog opens. Reactive via a signal so the selector component + * can re-render on add/remove without an event subscription. + * + * The store is permissive about ingest (it'll accept anything `isBuiltInLevel` + * rejects) and strict about output (only positive non-built-in integers). + */ +@Injectable({ providedIn: 'root' }) +export class CustomLevelStore { + private readonly _values = signal(this.load()); + readonly values = this._values.asReadonly(); + + /** Add a value to the palette. Returns false if rejected (built-in or invalid). */ + add(value: number): boolean { + if (!Number.isInteger(value) || value < 1) return false; + if (isBuiltInLevel(value)) return false; + this._values.update(current => { + if (current.includes(value)) return current; + const next = [...current, value]; + return next.length > MAX_ENTRIES ? next.slice(next.length - MAX_ENTRIES) : next; + }); + this.persist(); + return true; + } + + /** Remove a value from the palette. */ + remove(value: number): void { + this._values.update(current => current.filter(v => v !== value)); + this.persist(); + } + + /** + * Seed the palette with values found on existing alarms when the dialog opens. + * Silently filters out built-ins and invalid values; deduplicates with the + * existing palette. + */ + seedFrom(values: Iterable): void { + let changed = false; + this._values.update(current => { + const seen = new Set(current); + const next = [...current]; + for (const v of values) { + if (!Number.isInteger(v) || v < 1 || isBuiltInLevel(v) || seen.has(v)) continue; + seen.add(v); + next.push(v); + changed = true; + } + return next.length > MAX_ENTRIES ? next.slice(next.length - MAX_ENTRIES) : next; + }); + if (changed) this.persist(); + } + + private load(): number[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter(v => Number.isInteger(v) && v >= 1 && v !== ANY_LEVEL_VALUE && !isBuiltInLevel(v)).slice(0, MAX_ENTRIES); + } catch { + return []; + } + } + + private persist(): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(this._values())); + } catch { + // Quota exceeded or storage disabled — the in-memory signal still works + // for the current session; we just lose persistence. + } + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html index a8bf2586..7122f809 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html @@ -39,22 +39,10 @@

{{ 'RAIDS.SPECIFIC_GYM' | translate }}

{{ 'RAIDS.RAID_LEVELS' | translate }}

-
- @for (level of levels; track level) { - - {{ 'RAIDS.LEVEL_PREFIX' | translate }} {{ level }} - - } -
+

{{ 'RAIDS.EGG_LEVELS' | translate }}

-
- @for (level of levels; track level) { - - {{ 'RAIDS.LEVEL_PREFIX' | translate }} {{ level }} - - } -
+
@@ -66,15 +54,8 @@

{{ 'RAIDS.EGG_LEVELS' | translate }}

{{ 'RAIDS.POKEMON_SELECTED' | translate: { count: selectedPokemonIds().length } }}

} - - {{ 'RAIDS.RAID_LEVEL_LABEL' | translate }} - - {{ 'ALARM.ANY_LEVEL' | translate }} - @for (level of levels; track level) { - {{ 'RAIDS.LEVEL_PREFIX' | translate }} {{ level }} - } - - +

{{ 'RAIDS.RAID_LEVEL_LABEL' | translate }}

+ diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts index bb67d0c5..ca65fd55 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts @@ -1,7 +1,6 @@ import { Component, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; @@ -16,12 +15,14 @@ import { TranslateModule } from '@ngx-translate/core'; import { forkJoin } from 'rxjs'; import { RaidCreate, EggCreate } from '../../core/models'; +import { ANY_LEVEL_VALUE } from '../../core/models/raid-level.models'; import { AuthService } from '../../core/services/auth.service'; import { EggService } from '../../core/services/egg.service'; import { I18nService } from '../../core/services/i18n.service'; import { RaidService } from '../../core/services/raid.service'; import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { GymPickerComponent } from '../../shared/components/gym-picker/gym-picker.component'; +import { LevelSelectorComponent } from '../../shared/components/level-selector/level-selector.component'; import { PokemonSelectorComponent } from '../../shared/components/pokemon-selector/pokemon-selector.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; @@ -36,7 +37,6 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele MatSlideToggleModule, MatIconModule, MatTabsModule, - MatCheckboxModule, MatRadioModule, MatSnackBarModule, MatProgressSpinnerModule, @@ -45,6 +45,7 @@ import { TemplateSelectorComponent } from '../../shared/components/template-sele TemplateSelectorComponent, DeliveryPreviewComponent, GymPickerComponent, + LevelSelectorComponent, ], selector: 'app-raid-add-dialog', standalone: true, @@ -57,9 +58,9 @@ export class RaidAddDialogComponent { private readonly i18n = inject(I18nService); private readonly raidService = inject(RaidService); private readonly snackBar = inject(MatSnackBar); - bossForm = this.fb.group({ - level: [0], - }); + + /** Single-select Boss-tab level; defaults to PoracleNG's "any" sentinel (9000). */ + bossLevel = signal(ANY_LEVEL_VALUE); commonForm = this.fb.group({ clean: [false], @@ -71,14 +72,12 @@ export class RaidAddDialogComponent { }); readonly dialogRef = inject(MatDialogRef); - readonly isWebhook = inject(AuthService).isImpersonating(); - levels = [1, 2, 3, 4, 5, 6]; saving = signal(false); selectedEggLevels = signal([]); selectedGymId = signal(null); - selectedPokemonIds = signal([]); + selectedPokemonIds = signal([]); selectedRaidLevels = signal([]); tabIndex = 0; @@ -90,6 +89,11 @@ export class RaidAddDialogComponent { return this.selectedPokemonIds().length > 0; } + /** Boss tab is single-select; the selector emits an array of length 0 or 1. */ + onBossLevelChange(values: number[]): void { + this.bossLevel.set(values[0] ?? ANY_LEVEL_VALUE); + } + onDistanceModeChange(): void { if (this.commonForm.controls.distanceMode.value === 'areas') { this.commonForm.controls.distanceKm.setValue(0); @@ -148,7 +152,7 @@ export class RaidAddDialogComponent { } } else { // By Boss - const bossLevel = this.bossForm.controls.level.value ?? 0; + const bossLevel = this.bossLevel(); for (const pokemonId of this.selectedPokemonIds()) { const raid: RaidCreate = { clean: common.clean ? 1 : 0, @@ -182,12 +186,4 @@ export class RaidAddDialogComponent { }, }); } - - toggleEggLevel(level: number): void { - this.selectedEggLevels.update(levels => (levels.includes(level) ? levels.filter(l => l !== level) : [...levels, level])); - } - - toggleRaidLevel(level: number): void { - this.selectedRaidLevels.update(levels => (levels.includes(level) ? levels.filter(l => l !== level) : [...levels, level])); - } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts index 76ee2933..1151ce81 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts @@ -14,6 +14,7 @@ import { firstValueFrom, forkJoin } from 'rxjs'; import { RaidAddDialogComponent } from './raid-add-dialog.component'; import { RaidEditDialogComponent, RaidEditDialogData } from './raid-edit-dialog.component'; import { Raid, Egg } from '../../core/models'; +import { resolveLevel } from '../../core/models/raid-level.models'; import { EggService } from '../../core/services/egg.service'; import { I18nService } from '../../core/services/i18n.service'; import { IconService } from '../../core/services/icon.service'; @@ -258,14 +259,11 @@ export class RaidListComponent implements OnInit { } getRaidLevelName(level: number): string { - switch (level) { - case 6: - return this.i18n.instant('RAIDS.LEVEL_MEGA'); - case 9000: - return this.i18n.instant('ALARM.ANY_LEVEL'); - default: - return this.i18n.instant('RAIDS.LEVEL_PREFIX') + ' ' + level; + const opt = resolveLevel(level); + if (opt.category === 'custom') { + return this.i18n.instant(opt.labelKey) + ' ' + opt.badge; } + return this.i18n.instant(opt.labelKey); } getRaidTitle(raid: Raid): string { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html new file mode 100644 index 00000000..f8170f62 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html @@ -0,0 +1,111 @@ +
+
+ + + @for (opt of standardLevels; track opt.value) { + + {{ opt.labelKey | translate }} + + } + +
+ +
+ + + @for (opt of specialLevels; track opt.value) { + + {{ opt.labelKey | translate }} + @if (opt.badge !== undefined) { + + } + + } + @if (showAny) { + + {{ anyLevel.labelKey | translate }} + + } + +
+ +
+ +
+ + @for (opt of palette(); track opt.value) { + + {{ opt.badge }} + + + } + + + @if (!addInputOpen()) { + + } @else { + + + @if (addInputError()) { + {{ addInputError()! | translate }} + } @else { + {{ 'RAIDS.LEVEL.ADD_HELP' | translate }} + } + + + + } +
+
+
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.scss new file mode 100644 index 00000000..29812884 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.scss @@ -0,0 +1,106 @@ +.level-selector { + display: flex; + flex-direction: column; + gap: 14px; + margin: 8px 0 4px; +} + +.level-section { + display: flex; + flex-direction: column; + gap: 6px; +} + +.section-label { + margin: 0; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--mat-sys-on-surface-variant); + opacity: 0.85; +} + +.chip-row { + display: block; + + ::ng-deep .mdc-evolution-chip-set__chips { + flex-wrap: wrap; + gap: 6px; + } +} + +.chip-label { + font-weight: 500; +} + +.chip-badge { + margin-left: 4px; + font-variant-numeric: tabular-nums; + opacity: 0.7; +} + +.chip-badge-only { + font-variant-numeric: tabular-nums; + font-weight: 500; +} + +.chip-remove { + width: 22px !important; + height: 22px !important; + line-height: 22px !important; + margin-left: 2px; + margin-right: -4px; + + ::ng-deep .mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; + } +} + +.custom-section .custom-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.add-chip { + height: 32px; + padding: 0 12px; + border-radius: 8px; + font-size: 0.875rem; + + ::ng-deep .mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + margin-right: 4px; + } +} + +.add-input { + width: 180px; + + ::ng-deep .mat-mdc-form-field-subscript-wrapper { + margin-top: 2px; + } +} + +mat-chip-option { + transition: box-shadow 200ms ease; + + &.flash { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--mat-sys-primary) 35%, transparent); + } +} + +.level-selector.single mat-chip-option { + // Single-select mode shows the active chip more emphatically since there's + // only one in flight. + &[aria-selected='true'] { + font-weight: 600; + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.spec.ts new file mode 100644 index 00000000..2849967d --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.spec.ts @@ -0,0 +1,171 @@ +import { provideHttpClient } from '@angular/common/http'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { provideTranslateService } from '@ngx-translate/core'; + +import { LevelSelectorComponent } from './level-selector.component'; +import { ANY_LEVEL_VALUE } from '../../../core/models/raid-level.models'; +import { CustomLevelStore } from '../../../core/services/custom-level-store.service'; + +const STORAGE_KEY = 'poracle.custom-raid-levels'; + +describe('LevelSelectorComponent', () => { + let fixture: ComponentFixture; + let component: LevelSelectorComponent; + let store: CustomLevelStore; + + beforeEach(() => { + localStorage.removeItem(STORAGE_KEY); + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [LevelSelectorComponent, NoopAnimationsModule], + providers: [provideHttpClient(), provideTranslateService()], + }); + fixture = TestBed.createComponent(LevelSelectorComponent); + component = fixture.componentInstance; + store = TestBed.inject(CustomLevelStore); + }); + + it('renders without error in multi-select mode', () => { + component.multiple = true; + component.value = [1, 7]; + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('seeds custom levels from incoming value', () => { + component.value = [42, 1]; + expect(store.values()).toEqual([42]); + }); + + it('toggle adds/removes in multi-select', () => { + component.multiple = true; + component.value = []; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + (component as unknown as { toggle: (v: number) => void }).toggle(3); + (component as unknown as { toggle: (v: number) => void }).toggle(5); + expect(emitted).toEqual([[3], [3, 5]]); + + (component as unknown as { toggle: (v: number) => void }).toggle(3); + expect(emitted[emitted.length - 1]).toEqual([5]); + }); + + it('toggle replaces in single-select', () => { + component.multiple = false; + component.value = [3]; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + (component as unknown as { toggle: (v: number) => void }).toggle(5); + expect(emitted[0]).toEqual([5]); + }); + + it('single-select clears when the active chip is toggled again', () => { + component.multiple = false; + component.value = [3]; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + (component as unknown as { toggle: (v: number) => void }).toggle(3); + expect(emitted[0]).toEqual([]); + }); + + it('commitAddInput rejects 0 and negatives via inline error', () => { + const c = component as unknown as { + addInputValue: { set: (v: string) => void }; + addInputError: () => string | null; + commitAddInput: () => void; + }; + c.addInputValue.set('0'); + c.commitAddInput(); + expect(c.addInputError()).toBe('RAIDS.LEVEL.INVALID'); + + c.addInputValue.set('-1'); + c.commitAddInput(); + expect(c.addInputError()).toBe('RAIDS.LEVEL.INVALID'); + }); + + it('commitAddInput rejects non-integer input', () => { + const c = component as unknown as { + addInputValue: { set: (v: string) => void }; + addInputError: () => string | null; + commitAddInput: () => void; + }; + c.addInputValue.set('7.5'); + c.commitAddInput(); + expect(c.addInputError()).toBe('RAIDS.LEVEL.INVALID'); + }); + + it('commitAddInput snaps 9000 to ANY chip when showAny is true', () => { + component.showAny = true; + component.value = []; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + const c = component as unknown as { addInputValue: { set: (v: string) => void }; commitAddInput: () => void }; + c.addInputValue.set('9000'); + c.commitAddInput(); + + expect(emitted[emitted.length - 1]).toEqual([ANY_LEVEL_VALUE]); + // Should NOT have been added to the custom palette + expect(store.values()).not.toContain(ANY_LEVEL_VALUE); + }); + + it('commitAddInput selects an existing built-in instead of adding a duplicate', () => { + component.multiple = true; + component.value = []; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + const c = component as unknown as { addInputValue: { set: (v: string) => void }; commitAddInput: () => void }; + c.addInputValue.set('5'); // already in STANDARD + c.commitAddInput(); + + expect(emitted[emitted.length - 1]).toEqual([5]); + expect(store.values()).not.toContain(5); + }); + + it('commitAddInput adds a new custom and selects it', () => { + component.multiple = true; + component.value = []; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + const c = component as unknown as { addInputValue: { set: (v: string) => void }; commitAddInput: () => void }; + c.addInputValue.set('42'); + c.commitAddInput(); + + expect(store.values()).toContain(42); + expect(emitted[emitted.length - 1]).toEqual([42]); + }); + + it('removeCustom evicts the value from the palette and the selection', () => { + component.multiple = true; + component.value = [42]; + expect(store.values()).toContain(42); + + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + const c = component as unknown as { removeCustom: (v: number, e: MouseEvent) => void }; + c.removeCustom(42, new MouseEvent('click')); + + expect(store.values()).not.toContain(42); + expect(emitted[emitted.length - 1]).toEqual([]); + }); + + it('Escape cancels the add input and clears state', () => { + const c = component as unknown as { + openAddInput: () => void; + addInputValue: { set: (v: string) => void; (): string }; + addInputOpen: () => boolean; + onAddKeydown: (e: KeyboardEvent) => void; + }; + c.openAddInput(); + c.addInputValue.set('99'); + c.onAddKeydown(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(c.addInputOpen()).toBe(false); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.ts new file mode 100644 index 00000000..c2b06f7c --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.ts @@ -0,0 +1,184 @@ +import { Component, computed, EventEmitter, inject, Input, Output, signal, ViewChild } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; + +import { + ANY_LEVEL, + ANY_LEVEL_VALUE, + isBuiltInLevel, + LevelOption, + makeCustomLevel, + SPECIAL_LEVELS, + STANDARD_LEVELS, +} from '../../../core/models/raid-level.models'; +import { CustomLevelStore } from '../../../core/services/custom-level-store.service'; + +/** + * Three-section chip-based selector for raid/egg levels. Backed by + * {@link CustomLevelStore} so user-added custom values persist across sessions. + * + * Use `multiple=true` for the raid/egg multi-select case. Use `multiple=false` + * for the boss-level single-select. Set `showAny=true` to surface the 9000 + * "Any" sentinel as a first-class chip (typical for single-select). + */ +@Component({ + imports: [ + FormsModule, + MatButtonModule, + MatChipsModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatTooltipModule, + TranslateModule, + ], + selector: 'app-level-selector', + standalone: true, + styleUrl: './level-selector.component.scss', + templateUrl: './level-selector.component.html', +}) +export class LevelSelectorComponent { + private readonly customLevels = inject(CustomLevelStore); + private readonly translate = inject(TranslateService); + + /** Inline validation error key, if any. */ + protected readonly addInputError = signal(null); + /** Whether the "+ Add level" input is currently expanded. */ + protected readonly addInputOpen = signal(false); + /** Current text in the add-custom input. */ + protected readonly addInputValue = signal(''); + + protected readonly anyLevel = ANY_LEVEL; + + @ViewChild('customInput') customInput?: { nativeElement: HTMLInputElement }; + /** Value that should flash briefly after a duplicate add attempt. */ + protected readonly flashValue = signal(null); + @Input() multiple = true; + + /** Custom palette (from the store). */ + protected readonly palette = computed(() => this.customLevels.values().map(makeCustomLevel)); + /** Current selection. Internal signal; pushed in via `value` setter, out via `valueChange`. */ + protected readonly selected = signal([]); + + /** When true, surface the 9000 "Any" sentinel as a dedicated chip in SPECIAL. */ + @Input() showAny = false; + protected readonly specialLevels = SPECIAL_LEVELS; + protected readonly standardLevels = STANDARD_LEVELS; + @Output() readonly valueChange = new EventEmitter(); + + @Input() + set value(next: number[] | null | undefined) { + const safe = (next ?? []).filter(v => Number.isInteger(v) && v >= 1); + this.selected.set(safe); + // Surface any custom values from incoming alarms into the palette so they + // appear pre-selected in the CUSTOM row rather than being orphaned. + const customs = safe.filter(v => !isBuiltInLevel(v)); + if (customs.length > 0) this.customLevels.seedFrom(customs); + } + + protected cancelAddInput(): void { + this.addInputOpen.set(false); + this.addInputValue.set(''); + this.addInputError.set(null); + } + + protected commitAddInput(): void { + const raw = this.addInputValue().trim(); + if (raw === '') { + this.cancelAddInput(); + return; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isInteger(parsed) || parsed < 1 || String(parsed) !== raw.replace(/^0+(\d)/, '$1')) { + this.addInputError.set('RAIDS.LEVEL.INVALID'); + return; + } + + // The "Any" sentinel snaps to its dedicated chip — don't create a duplicate + // custom entry. If showAny is off, still treat it as a special case for clarity. + if (parsed === ANY_LEVEL_VALUE) { + if (this.showAny) { + this.cancelAddInput(); + if (!this.isSelected(ANY_LEVEL_VALUE)) this.toggle(ANY_LEVEL_VALUE); + this.flash(ANY_LEVEL_VALUE); + return; + } + // showAny is off but user wants Any — accept it as a custom value rather than blocking. + } + + // Duplicate of an existing built-in chip — flash it instead of erroring. + if (isBuiltInLevel(parsed)) { + this.cancelAddInput(); + if (!this.isSelected(parsed)) this.toggle(parsed); + this.flash(parsed); + return; + } + + // Duplicate of an existing custom chip — flash + select if not already. + if (this.palette().some(o => o.value === parsed)) { + this.addInputError.set(this.translate.instant('RAIDS.LEVEL.DUPLICATE', { value: parsed })); + this.flash(parsed); + if (!this.isSelected(parsed)) this.toggle(parsed); + return; + } + + this.customLevels.add(parsed); + this.cancelAddInput(); + if (!this.isSelected(parsed)) this.toggle(parsed); + } + + protected isSelected(value: number): boolean { + return this.selected().includes(value); + } + + protected onAddKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter') { + event.preventDefault(); + this.commitAddInput(); + } else if (event.key === 'Escape') { + event.preventDefault(); + this.cancelAddInput(); + } + } + + protected openAddInput(): void { + this.addInputOpen.set(true); + this.addInputValue.set(''); + this.addInputError.set(null); + queueMicrotask(() => this.customInput?.nativeElement.focus()); + } + + protected removeCustom(value: number, event: MouseEvent): void { + event.stopPropagation(); + this.customLevels.remove(value); + if (this.selected().includes(value)) { + this.toggle(value); + } + } + + protected toggle(value: number): void { + const current = this.selected(); + let next: number[]; + if (this.multiple) { + next = current.includes(value) ? current.filter(v => v !== value) : [...current, value]; + } else { + // Single-select: clicking the active chip clears; otherwise replace. + next = current.includes(value) && current.length === 1 ? [] : [value]; + } + this.selected.set(next); + this.valueChange.emit(next); + } + + private flash(value: number): void { + this.flashValue.set(value); + setTimeout(() => { + if (this.flashValue() === value) this.flashValue.set(null); + }, 600); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.spec.ts new file mode 100644 index 00000000..6ba6bdb2 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.spec.ts @@ -0,0 +1,42 @@ +import { TestBed } from '@angular/core/testing'; + +import { LevelLabelPipe } from './level-label.pipe'; +import { I18nService } from '../../core/services/i18n.service'; + +class FakeI18n { + instant(key: string): string { + return key; // tests assert on the i18n key, not the translation + } +} + +describe('LevelLabelPipe', () => { + let pipe: LevelLabelPipe; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [{ provide: I18nService, useClass: FakeI18n }, LevelLabelPipe], + }); + pipe = TestBed.inject(LevelLabelPipe); + }); + + it('formats standard tiers as T1-T5 keys', () => { + for (let v = 1; v <= 5; v++) { + expect(pipe.transform(v)).toBe(`RAIDS.LEVEL.T${v}`); + } + }); + + it('formats Mega and Elite', () => { + expect(pipe.transform(6)).toBe('RAIDS.LEVEL.MEGA'); + expect(pipe.transform(7)).toBe('RAIDS.LEVEL.ELITE'); + }); + + it('formats 9000 as ANY', () => { + expect(pipe.transform(9000)).toBe('RAIDS.LEVEL.ANY'); + }); + + it('formats custom levels with the numeric badge', () => { + expect(pipe.transform(42)).toBe('RAIDS.LEVEL.CUSTOM 42'); + expect(pipe.transform(8)).toBe('RAIDS.LEVEL.CUSTOM 8'); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.ts new file mode 100644 index 00000000..10078ba4 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.ts @@ -0,0 +1,28 @@ +import { inject, Pipe, PipeTransform } from '@angular/core'; + +import { resolveLevel } from '../../core/models/raid-level.models'; +import { I18nService } from '../../core/services/i18n.service'; + +/** + * Resolve a stored raid/egg level integer to its display label. + * + * - Standard tiers (1-5) → "T1", "T2", ... + * - Mega (6), Elite (7) → "Mega", "Elite" + * - 9000 (PoracleNG wildcard) → "Any" + * - Anything else → "Level {n}" + */ +@Pipe({ + name: 'levelLabel', + standalone: true, +}) +export class LevelLabelPipe implements PipeTransform { + private readonly i18n = inject(I18nService); + + transform(value: number): string { + const opt = resolveLevel(value); + if (opt.category === 'custom') { + return this.i18n.instant(opt.labelKey) + ' ' + opt.badge; + } + return this.i18n.instant(opt.labelKey); + } +} 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 a9a70822..a2c42513 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -401,7 +401,27 @@ "CONFIRM_DELETE_ALL_MSG": "Er du sikker på, at du vil slette ALLE raid- og æg-alarmer? Denne handling kan ikke fortrydes.", "CONFIRM_BULK_DELETE_TITLE": "Slet valgte alarmer", "CONFIRM_BULK_DELETE_MSG": "Er du sikker på, at du vil slette {{count}} alarmer?", - "CONFIRM_DELETE_SELECTED": "Slet valgte" + "CONFIRM_DELETE_SELECTED": "Slet valgte", + "LEVEL": { + "T1": "T1", + "T2": "T2", + "T3": "T3", + "T4": "T4", + "T5": "T5", + "MEGA": "Mega", + "ELITE": "Elite", + "ANY": "Any", + "CUSTOM": "Level", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Special", + "SECTION_CUSTOM": "Custom", + "ADD": "Add level", + "ADD_PLACEHOLDER": "e.g. 42", + "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", + "INVALID": "Level must be 1 or higher.", + "DUPLICATE": "Level {{value}} is already in the list.", + "SR_REMOVE": "Remove custom level {{value}}" + } }, "QUESTS": { "PAGE_TITLE": "Quest-alarmer", 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 2d4fb0cf..a8319f44 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -401,7 +401,27 @@ "CONFIRM_DELETE_ALL_MSG": "Möchtest du wirklich ALLE Raid- und Ei-Alarme löschen? Diese Aktion kann nicht rückgängig gemacht werden.", "CONFIRM_BULK_DELETE_TITLE": "Ausgewählte Alarme löschen", "CONFIRM_BULK_DELETE_MSG": "Möchtest du wirklich {{count}} Alarme löschen?", - "CONFIRM_DELETE_SELECTED": "Ausgewählte löschen" + "CONFIRM_DELETE_SELECTED": "Ausgewählte löschen", + "LEVEL": { + "T1": "T1", + "T2": "T2", + "T3": "T3", + "T4": "T4", + "T5": "T5", + "MEGA": "Mega", + "ELITE": "Elite", + "ANY": "Any", + "CUSTOM": "Level", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Special", + "SECTION_CUSTOM": "Custom", + "ADD": "Add level", + "ADD_PLACEHOLDER": "e.g. 42", + "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", + "INVALID": "Level must be 1 or higher.", + "DUPLICATE": "Level {{value}} is already in the list.", + "SR_REMOVE": "Remove custom level {{value}}" + } }, "QUESTS": { "PAGE_TITLE": "Quest-Alarme", 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 c8d7f005..42b27083 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -401,7 +401,27 @@ "CONFIRM_DELETE_ALL_MSG": "Are you sure you want to delete ALL raid and egg alarms? This action cannot be undone.", "CONFIRM_BULK_DELETE_TITLE": "Delete Selected Alarms", "CONFIRM_BULK_DELETE_MSG": "Are you sure you want to delete {{count}} alarms?", - "CONFIRM_DELETE_SELECTED": "Delete Selected" + "CONFIRM_DELETE_SELECTED": "Delete Selected", + "LEVEL": { + "T1": "T1", + "T2": "T2", + "T3": "T3", + "T4": "T4", + "T5": "T5", + "MEGA": "Mega", + "ELITE": "Elite", + "ANY": "Any", + "CUSTOM": "Level", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Special", + "SECTION_CUSTOM": "Custom", + "ADD": "Add level", + "ADD_PLACEHOLDER": "e.g. 42", + "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", + "INVALID": "Level must be 1 or higher.", + "DUPLICATE": "Level {{value}} is already in the list.", + "SR_REMOVE": "Remove custom level {{value}}" + } }, "QUESTS": { "PAGE_TITLE": "Quest Alarms", 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 4a2f65b4..ebbde94d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -401,7 +401,27 @@ "CONFIRM_DELETE_ALL_MSG": "¿Seguro que quieres eliminar TODAS las alarmas de raid y huevo? Esta acción no se puede deshacer.", "CONFIRM_BULK_DELETE_TITLE": "Eliminar alarmas seleccionadas", "CONFIRM_BULK_DELETE_MSG": "¿Seguro que quieres eliminar {{count}} alarmas?", - "CONFIRM_DELETE_SELECTED": "Eliminar seleccionadas" + "CONFIRM_DELETE_SELECTED": "Eliminar seleccionadas", + "LEVEL": { + "T1": "T1", + "T2": "T2", + "T3": "T3", + "T4": "T4", + "T5": "T5", + "MEGA": "Mega", + "ELITE": "Elite", + "ANY": "Any", + "CUSTOM": "Level", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Special", + "SECTION_CUSTOM": "Custom", + "ADD": "Add level", + "ADD_PLACEHOLDER": "e.g. 42", + "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", + "INVALID": "Level must be 1 or higher.", + "DUPLICATE": "Level {{value}} is already in the list.", + "SR_REMOVE": "Remove custom level {{value}}" + } }, "QUESTS": { "PAGE_TITLE": "Alarmas de Misión", 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 b39de4a9..810f264c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -401,7 +401,27 @@ "CONFIRM_DELETE_ALL_MSG": "Es-tu sûr de vouloir supprimer TOUTES les alarmes Raid et Œuf ? Cette action est irréversible.", "CONFIRM_BULK_DELETE_TITLE": "Supprimer les alarmes sélectionnées", "CONFIRM_BULK_DELETE_MSG": "Es-tu sûr de vouloir supprimer {{count}} alarmes ?", - "CONFIRM_DELETE_SELECTED": "Supprimer la sélection" + "CONFIRM_DELETE_SELECTED": "Supprimer la sélection", + "LEVEL": { + "T1": "T1", + "T2": "T2", + "T3": "T3", + "T4": "T4", + "T5": "T5", + "MEGA": "Mega", + "ELITE": "Elite", + "ANY": "Any", + "CUSTOM": "Level", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Special", + "SECTION_CUSTOM": "Custom", + "ADD": "Add level", + "ADD_PLACEHOLDER": "e.g. 42", + "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", + "INVALID": "Level must be 1 or higher.", + "DUPLICATE": "Level {{value}} is already in the list.", + "SR_REMOVE": "Remove custom level {{value}}" + } }, "QUESTS": { "PAGE_TITLE": "Alarmes Quête", 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 1e58d1fb..d52fb9d8 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -401,7 +401,27 @@ "CONFIRM_DELETE_ALL_MSG": "Sei sicuro di voler eliminare TUTTI gli allarmi raid e uova? Questa azione non può essere annullata.", "CONFIRM_BULK_DELETE_TITLE": "Elimina Allarmi Selezionati", "CONFIRM_BULK_DELETE_MSG": "Sei sicuro di voler eliminare {{count}} allarmi?", - "CONFIRM_DELETE_SELECTED": "Elimina Selezionati" + "CONFIRM_DELETE_SELECTED": "Elimina Selezionati", + "LEVEL": { + "T1": "T1", + "T2": "T2", + "T3": "T3", + "T4": "T4", + "T5": "T5", + "MEGA": "Mega", + "ELITE": "Elite", + "ANY": "Any", + "CUSTOM": "Level", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Special", + "SECTION_CUSTOM": "Custom", + "ADD": "Add level", + "ADD_PLACEHOLDER": "e.g. 42", + "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", + "INVALID": "Level must be 1 or higher.", + "DUPLICATE": "Level {{value}} is already in the list.", + "SR_REMOVE": "Remove custom level {{value}}" + } }, "QUESTS": { "PAGE_TITLE": "Allarmi Missioni", 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 7687dfb6..4a26a8e2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -401,7 +401,27 @@ "CONFIRM_DELETE_ALL_MSG": "Weet je zeker dat je ALLE raid en ei alarmen wilt verwijderen? Dit kan niet ongedaan worden gemaakt.", "CONFIRM_BULK_DELETE_TITLE": "Geselecteerde Alarmen Verwijderen", "CONFIRM_BULK_DELETE_MSG": "Weet je zeker dat je {{count}} alarmen wilt verwijderen?", - "CONFIRM_DELETE_SELECTED": "Geselecteerde Verwijderen" + "CONFIRM_DELETE_SELECTED": "Geselecteerde Verwijderen", + "LEVEL": { + "T1": "T1", + "T2": "T2", + "T3": "T3", + "T4": "T4", + "T5": "T5", + "MEGA": "Mega", + "ELITE": "Elite", + "ANY": "Any", + "CUSTOM": "Level", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Special", + "SECTION_CUSTOM": "Custom", + "ADD": "Add level", + "ADD_PLACEHOLDER": "e.g. 42", + "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", + "INVALID": "Level must be 1 or higher.", + "DUPLICATE": "Level {{value}} is already in the list.", + "SR_REMOVE": "Remove custom level {{value}}" + } }, "QUESTS": { "PAGE_TITLE": "Quest Alarmen", 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 fc6c624b..c627ff77 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -401,7 +401,27 @@ "CONFIRM_DELETE_ALL_MSG": "Czy na pewno chcesz usunąć WSZYSTKIE alarmy rajdów i jajek? Tej akcji nie można cofnąć.", "CONFIRM_BULK_DELETE_TITLE": "Usuń zaznaczone alarmy", "CONFIRM_BULK_DELETE_MSG": "Czy na pewno chcesz usunąć {{count}} alarmów?", - "CONFIRM_DELETE_SELECTED": "Usuń zaznaczone" + "CONFIRM_DELETE_SELECTED": "Usuń zaznaczone", + "LEVEL": { + "T1": "T1", + "T2": "T2", + "T3": "T3", + "T4": "T4", + "T5": "T5", + "MEGA": "Mega", + "ELITE": "Elite", + "ANY": "Any", + "CUSTOM": "Level", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Special", + "SECTION_CUSTOM": "Custom", + "ADD": "Add level", + "ADD_PLACEHOLDER": "e.g. 42", + "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", + "INVALID": "Level must be 1 or higher.", + "DUPLICATE": "Level {{value}} is already in the list.", + "SR_REMOVE": "Remove custom level {{value}}" + } }, "QUESTS": { "PAGE_TITLE": "Alarmy zadań", 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 fe575a29..60b0c5f6 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 @@ -401,7 +401,27 @@ "CONFIRM_DELETE_ALL_MSG": "Tem certeza que deseja excluir TODOS os alarmes de raid e ovo? Esta ação não pode ser desfeita.", "CONFIRM_BULK_DELETE_TITLE": "Excluir Alarmes Selecionados", "CONFIRM_BULK_DELETE_MSG": "Tem certeza que deseja excluir {{count}} alarmes?", - "CONFIRM_DELETE_SELECTED": "Excluir Selecionados" + "CONFIRM_DELETE_SELECTED": "Excluir Selecionados", + "LEVEL": { + "T1": "T1", + "T2": "T2", + "T3": "T3", + "T4": "T4", + "T5": "T5", + "MEGA": "Mega", + "ELITE": "Elite", + "ANY": "Any", + "CUSTOM": "Level", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Special", + "SECTION_CUSTOM": "Custom", + "ADD": "Add level", + "ADD_PLACEHOLDER": "e.g. 42", + "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", + "INVALID": "Level must be 1 or higher.", + "DUPLICATE": "Level {{value}} is already in the list.", + "SR_REMOVE": "Remove custom level {{value}}" + } }, "QUESTS": { "PAGE_TITLE": "Alarmes de Quest", 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 47316704..6dd58508 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -401,7 +401,27 @@ "CONFIRM_DELETE_ALL_MSG": "Tens a certeza de que queres eliminar TODOS os alarmes de raid e ovos? Esta ação não pode ser revertida.", "CONFIRM_BULK_DELETE_TITLE": "Eliminar Alarmes Selecionados", "CONFIRM_BULK_DELETE_MSG": "Tens a certeza de que queres eliminar {{count}} alarmes?", - "CONFIRM_DELETE_SELECTED": "Eliminar Selecionados" + "CONFIRM_DELETE_SELECTED": "Eliminar Selecionados", + "LEVEL": { + "T1": "T1", + "T2": "T2", + "T3": "T3", + "T4": "T4", + "T5": "T5", + "MEGA": "Mega", + "ELITE": "Elite", + "ANY": "Any", + "CUSTOM": "Level", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Special", + "SECTION_CUSTOM": "Custom", + "ADD": "Add level", + "ADD_PLACEHOLDER": "e.g. 42", + "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", + "INVALID": "Level must be 1 or higher.", + "DUPLICATE": "Level {{value}} is already in the list.", + "SR_REMOVE": "Remove custom level {{value}}" + } }, "QUESTS": { "PAGE_TITLE": "Alarmes de Missões", 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 33defcee..d28f2da9 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -401,7 +401,27 @@ "CONFIRM_DELETE_ALL_MSG": "Är du säker på att du vill radera ALLA raid- och ägglarm? Denna åtgärd kan inte ångras.", "CONFIRM_BULK_DELETE_TITLE": "Radera valda larm", "CONFIRM_BULK_DELETE_MSG": "Är du säker på att du vill radera {{count}} larm?", - "CONFIRM_DELETE_SELECTED": "Radera valda" + "CONFIRM_DELETE_SELECTED": "Radera valda", + "LEVEL": { + "T1": "T1", + "T2": "T2", + "T3": "T3", + "T4": "T4", + "T5": "T5", + "MEGA": "Mega", + "ELITE": "Elite", + "ANY": "Any", + "CUSTOM": "Level", + "SECTION_STANDARD": "Standard", + "SECTION_SPECIAL": "Special", + "SECTION_CUSTOM": "Custom", + "ADD": "Add level", + "ADD_PLACEHOLDER": "e.g. 42", + "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", + "INVALID": "Level must be 1 or higher.", + "DUPLICATE": "Level {{value}} is already in the list.", + "SR_REMOVE": "Remove custom level {{value}}" + } }, "QUESTS": { "PAGE_TITLE": "Quest-larm", diff --git a/CHANGELOG.md b/CHANGELOG.md index 78442bf7..705259fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Fixed +- **Raid/Egg level selector hardcoded to 1–6** ([#259](https://github.com/PGAN-Dev/PoracleWeb.NET/issues/259)): the raid/egg add dialog's three level-pickers (raid checkboxes, egg checkboxes, boss-level dropdown) were all driven by a hardcoded `levels = [1, 2, 3, 4, 5, 6]` array, even though PoracleNG accepts arbitrary positive integers — users were already creating Elite Raids (level 7) and custom-level alarms via the bot's `!command` interface and the existing read-only edit dialog displayed them correctly. Replaced the three sites with a new `` shared component (Material 3 chip listbox in three sections: Standard, Special, Custom) that surfaces named labels for the common cases (T1–T5, Mega, Elite, "Any" for the 9000 sentinel), persists user-added custom levels per-user via `localStorage` so they're one-click on subsequent dialog opens, and snaps `9000` typed into the custom input to the canonical "Any" chip rather than creating a duplicate entry. Custom levels seeded from existing alarm data on dialog open via the new `resolveLevel(value)` resolver, which the raid alarm cards also adopt so the dialog and the cards speak the same vocabulary (an alarm at level 7 shows "Elite" everywhere, not "Level 7" in the dialog and "7" on the card). New i18n keys added under `RAIDS.LEVEL.*` in all 11 locales (translation volunteers can localize the placeholder English text in a follow-up — discussion #211). The boss tab also got a subtle correctness improvement: it used to default the level filter to `0` ("any") and pass that to PoracleNG, but the canonical "any" sentinel is `9000`; new alarms now use 9000 to match. Existing alarms saved with `level: 0` continue to render and edit fine, they just continue to use 0. - **Dependabot auto-merge workflow never fired on PRs**: `auto-merge-deps.yml` listed both `pull_request_target` and `push` as triggers, but in practice the workflow only ever ran for `push` events — every PR-event run for the last 100+ workflow runs was a `push` event, none were `pull_request_target`. Result: Dependabot PRs were never auto-approved (each one needed manual approval), and every push recorded a `failure` conclusion because the job's `if: github.event_name == 'pull_request_target'` gate skipped all steps. Removed the `push` trigger (matching `pr-labeler.yml`, which fires correctly with `pull_request_target` alone), dropped the job-level `if:`, and added a sentinel "Workflow ran" first step so non-Dependabot PRs record as success rather than zero-step failure. Follow-up to #231: that fix moved the gate to job level on the assumption GitHub would record skipped runs as success, but it records 0-job runs as failure regardless. - **Frontend CI `npm ci` failures on Dependabot PRs**: CI used Node 22's bundled npm 10.9.7, which strictly requires nested `chokidar@4.0.3` / `readdirp@4.1.2` lockfile entries that `@angular-devkit/*` packages declare as optional peers. Dependabot regenerates `package-lock.json` with a newer npm that prunes those entries, producing lockfiles npm 10.9.7's `npm ci` rejected with `EUSAGE`. Pinned npm 11 in the `frontend` CI job so the install resolution matches what Dependabot produces. Affects PRs #248, #250, #256, #261, #262. From 2c2f0aaf896a92505c28a4d54b75dcc54b7de660 Mon Sep 17 00:00:00 2001 From: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> Date: Fri, 22 May 2026 15:23:25 -0400 Subject: [PATCH 2/5] =?UTF-8?q?fix(raids):=20address=20#259=20review=20?= =?UTF-8?q?=E2=80=94=20compact=20layout,=20per-type=20palette,=20unblock?= =?UTF-8?q?=20save?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up on d58752e (the initial #259 redesign), addressing visual, correctness, and backend-validation issues found during testing. UI / UX - Collapse the three-section (Standard/Special/Custom) chip layout into one wrapping row per picker. Categories are encoded in chip content (T1 / "Mega · 6" / "42 ⊗") rather than container labels; cuts dialog height roughly in half. - Replace the heavy mat-form-field "+ Add custom level" with a chip-sized inline numeric input. Enter commits, Esc cancels, blur commits. Help text only renders when the input is open or there's a validation error. - Override Material 3 selected-chip font-weight so the selected state actually pops; bind `hideSingleSelectionIndicator` to `!multiple` so multi-select chips get a leading checkmark. - Cap card star icons to levels 1-7 (was 1-100) — alarms at level 23 no longer render 23 stars in the card. Per-type palette - Adding a custom level on the raid picker was leaking it into the egg and boss pickers. `CustomLevelStore` now keys palettes by `paletteKey` ("raid" / "egg" / "boss"), each persisted to its own localStorage slot. Required `paletteKey` input on ``. Any chip surfaced where PoracleNG actually honors it - Raid + boss pickers show the `Any` chip (PoracleNG treats level=9000 as the wildcard sentinel — see trackingRaid.go). - Egg picker deliberately omits Any: PoracleNG's trackingEgg.go only validates level >= 1 with no wildcard semantic, so an "Any egg" alarm at 9000 would simply never fire. Server-side fix that was blocking custom-level alarms - `[Range(0, 10)]` on `RaidCreate.Level`, `RaidUpdate.Level`, `EggCreate.Level`, `EggUpdate.Level` was rejecting custom integers (8+) and the new Any=9000 sentinel with 400 Bad Request before they could reach PoracleNG. Relaxed to `[Range(0, int.MaxValue)]` matching PoracleNG's actual range. Label vocabulary consistency - Edit dialog (raid/egg) now uses the same `resolveLevel` resolver as the cards via the new `LevelLabelPipe` — an alarm at level 7 reads "Elite" on the card AND in the edit dialog (was "Level 7" in the dialog before). Egg image alt-text in the card list now uses the pipe too. Error UX - Removing a custom chip (`⊗`) opens a 3-second snackbar with Undo — accidental click is recoverable; intentional removal still wipes the palette entry. State-machine clarity - `addInputOpen: signal(boolean)` replaced with an explicit `addMode: signal<'closed' | 'open'>` and named `isAddClosed()` / `isAddOpen()` getters in the template — removes the `!` negation pattern that prior renders sometimes appeared to misread. Tests - Updated `custom-level-store.service.spec.ts` for the keyed API. - Updated `level-selector.component.spec.ts` to set `paletteKey` per test and assert per-key isolation. - 697/697 frontend tests pass, 1063/1063 backend tests pass. i18n - Added `RAIDS.LEVEL.REMOVED` and `COMMON.UNDO` keys in all 11 locales (English placeholder text for non-en — translation volunteers per discussion #211). --- .../custom-level-store.service.spec.ts | 86 ++++---- .../services/custom-level-store.service.ts | 66 +++--- .../raids/raid-add-dialog.component.html | 20 +- .../raids/raid-add-dialog.component.ts | 4 +- .../raids/raid-edit-dialog.component.html | 2 +- .../raids/raid-edit-dialog.component.ts | 15 +- .../modules/raids/raid-list.component.html | 2 +- .../app/modules/raids/raid-list.component.ts | 8 +- .../level-selector.component.html | 191 ++++++++---------- .../level-selector.component.scss | 178 ++++++++++------ .../level-selector.component.spec.ts | 48 +++-- .../level-selector.component.ts | 117 ++++++----- .../ClientApp/src/assets/i18n/da.json | 4 +- .../ClientApp/src/assets/i18n/de.json | 4 +- .../ClientApp/src/assets/i18n/en.json | 4 +- .../ClientApp/src/assets/i18n/es.json | 4 +- .../ClientApp/src/assets/i18n/fr.json | 4 +- .../ClientApp/src/assets/i18n/it.json | 4 +- .../ClientApp/src/assets/i18n/nl.json | 4 +- .../ClientApp/src/assets/i18n/pl.json | 4 +- .../ClientApp/src/assets/i18n/pt-BR.json | 4 +- .../ClientApp/src/assets/i18n/pt.json | 4 +- .../ClientApp/src/assets/i18n/sv.json | 4 +- .../EggCreate.cs | 3 +- .../EggUpdate.cs | 3 +- .../RaidCreate.cs | 6 +- .../RaidUpdate.cs | 4 +- 27 files changed, 468 insertions(+), 329 deletions(-) diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.spec.ts index 225ad3b5..610615c6 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.spec.ts @@ -2,11 +2,14 @@ import { TestBed } from '@angular/core/testing'; import { CustomLevelStore } from './custom-level-store.service'; -const STORAGE_KEY = 'poracle.custom-raid-levels'; +const STORAGE_PREFIX = 'poracle.custom-levels'; describe('CustomLevelStore', () => { beforeEach(() => { - localStorage.removeItem(STORAGE_KEY); + // Clear any persisted state from previous tests + for (const key of Object.keys(localStorage)) { + if (key.startsWith(STORAGE_PREFIX)) localStorage.removeItem(key); + } TestBed.resetTestingModule(); }); @@ -17,81 +20,90 @@ describe('CustomLevelStore', () => { it('starts empty when localStorage has nothing', () => { const store = makeStore(); - expect(store.values()).toEqual([]); + expect(store.values('raid')).toEqual([]); + }); + + it('keeps separate palettes per key', () => { + const store = makeStore(); + store.add('raid', 42); + store.add('egg', 99); + expect(store.values('raid')).toEqual([42]); + expect(store.values('egg')).toEqual([99]); + expect(store.values('boss')).toEqual([]); }); - it('adds a custom level and persists it', () => { + it('persists per-key to separate localStorage slots', () => { const store = makeStore(); - expect(store.add(42)).toBe(true); - expect(store.values()).toEqual([42]); - expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([42]); + store.add('raid', 42); + store.add('egg', 99); + expect(JSON.parse(localStorage.getItem(`${STORAGE_PREFIX}.raid`)!)).toEqual([42]); + expect(JSON.parse(localStorage.getItem(`${STORAGE_PREFIX}.egg`)!)).toEqual([99]); }); it('rejects built-in levels (1-7 and 9000)', () => { const store = makeStore(); for (const level of [1, 2, 3, 4, 5, 6, 7, 9000]) { - expect(store.add(level)).toBe(false); + expect(store.add('raid', level)).toBe(false); } - expect(store.values()).toEqual([]); + expect(store.values('raid')).toEqual([]); }); it('rejects zero, negatives, and non-integers', () => { const store = makeStore(); - expect(store.add(0)).toBe(false); - expect(store.add(-3)).toBe(false); - expect(store.add(7.5)).toBe(false); - expect(store.add(Number.NaN)).toBe(false); - expect(store.values()).toEqual([]); + expect(store.add('raid', 0)).toBe(false); + expect(store.add('raid', -3)).toBe(false); + expect(store.add('raid', 7.5)).toBe(false); + expect(store.add('raid', Number.NaN)).toBe(false); + expect(store.values('raid')).toEqual([]); }); it('deduplicates on add', () => { const store = makeStore(); - store.add(42); - store.add(42); - expect(store.values()).toEqual([42]); + store.add('raid', 42); + store.add('raid', 42); + expect(store.values('raid')).toEqual([42]); }); it('removes a value', () => { const store = makeStore(); - store.add(42); - store.add(100); - store.remove(42); - expect(store.values()).toEqual([100]); - expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([100]); + store.add('raid', 42); + store.add('raid', 100); + store.remove('raid', 42); + expect(store.values('raid')).toEqual([100]); + expect(JSON.parse(localStorage.getItem(`${STORAGE_PREFIX}.raid`)!)).toEqual([100]); }); it('seeds custom values from existing alarms, skipping built-ins', () => { const store = makeStore(); - store.seedFrom([1, 7, 42, 9000, 99]); // 1, 7, 9000 are built-in - expect(store.values()).toEqual([42, 99]); + store.seedFrom('raid', [1, 7, 42, 9000, 99]); + expect(store.values('raid')).toEqual([42, 99]); }); - it('loads persisted values on construction', () => { - localStorage.setItem(STORAGE_KEY, JSON.stringify([8, 42, 99])); + it('loads persisted values on first access', () => { + localStorage.setItem(`${STORAGE_PREFIX}.raid`, JSON.stringify([8, 42, 99])); const store = makeStore(); - expect(store.values()).toEqual([8, 42, 99]); + expect(store.values('raid')).toEqual([8, 42, 99]); }); it('drops invalid entries from persisted state on load', () => { - localStorage.setItem(STORAGE_KEY, JSON.stringify([8, 'oops', -1, 9000, 42])); + localStorage.setItem(`${STORAGE_PREFIX}.raid`, JSON.stringify([8, 'oops', -1, 9000, 42])); const store = makeStore(); - expect(store.values()).toEqual([8, 42]); + expect(store.values('raid')).toEqual([8, 42]); }); - it('caps persisted entries at the LRU max', () => { + it('caps persisted entries at the LRU max per key', () => { const store = makeStore(); for (let i = 100; i < 130; i++) { - store.add(i); + store.add('raid', i); } - // 30 unique entries added, cap is 20 — first 10 should be dropped - expect(store.values().length).toBe(20); - expect(store.values()[0]).toBe(110); - expect(store.values()[19]).toBe(129); + expect(store.values('raid').length).toBe(20); + expect(store.values('raid')[0]).toBe(110); + expect(store.values('raid')[19]).toBe(129); }); it('survives malformed JSON in localStorage', () => { - localStorage.setItem(STORAGE_KEY, 'not-json-at-all'); + localStorage.setItem(`${STORAGE_PREFIX}.raid`, 'not-json-at-all'); const store = makeStore(); - expect(store.values()).toEqual([]); + expect(store.values('raid')).toEqual([]); }); }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.ts index a7c92898..197c9637 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.ts @@ -1,45 +1,43 @@ -import { Injectable, signal } from '@angular/core'; +import { Injectable, signal, WritableSignal } from '@angular/core'; import { ANY_LEVEL_VALUE, isBuiltInLevel } from '../models/raid-level.models'; -const STORAGE_KEY = 'poracle.custom-raid-levels'; +const STORAGE_PREFIX = 'poracle.custom-levels'; -/** Maximum number of custom levels to persist. Excess values are dropped LRU-style. */ +/** Maximum number of custom levels to persist per palette. Excess values are dropped LRU-style. */ const MAX_ENTRIES = 20; /** - * Per-user palette of custom raid/egg levels — anything outside the standard + * Per-key palette of custom raid/egg/boss levels — anything outside the standard * tiers (1-5), the special tiers (6/Mega, 7/Elite), and the 9000 "Any" sentinel. * - * Backed by localStorage so a user's "always alarm on level 8" preference - * survives across dialog opens. Reactive via a signal so the selector component - * can re-render on add/remove without an event subscription. - * - * The store is permissive about ingest (it'll accept anything `isBuiltInLevel` - * rejects) and strict about output (only positive non-built-in integers). + * Keyed by `paletteKey` (`raid` / `egg` / `boss`) so a custom level added on the + * raid picker doesn't leak into the egg or boss palettes. Each key persists to + * its own localStorage slot: `poracle.custom-levels.{key}`. */ @Injectable({ providedIn: 'root' }) export class CustomLevelStore { - private readonly _values = signal(this.load()); - readonly values = this._values.asReadonly(); + private readonly palettes = new Map>(); /** Add a value to the palette. Returns false if rejected (built-in or invalid). */ - add(value: number): boolean { + add(key: string, value: number): boolean { if (!Number.isInteger(value) || value < 1) return false; if (isBuiltInLevel(value)) return false; - this._values.update(current => { + const sig = this.signalFor(key); + sig.update(current => { if (current.includes(value)) return current; const next = [...current, value]; return next.length > MAX_ENTRIES ? next.slice(next.length - MAX_ENTRIES) : next; }); - this.persist(); + this.persist(key); return true; } /** Remove a value from the palette. */ - remove(value: number): void { - this._values.update(current => current.filter(v => v !== value)); - this.persist(); + remove(key: string, value: number): void { + const sig = this.signalFor(key); + sig.update(current => current.filter(v => v !== value)); + this.persist(key); } /** @@ -47,9 +45,10 @@ export class CustomLevelStore { * Silently filters out built-ins and invalid values; deduplicates with the * existing palette. */ - seedFrom(values: Iterable): void { + seedFrom(key: string, values: Iterable): void { let changed = false; - this._values.update(current => { + const sig = this.signalFor(key); + sig.update(current => { const seen = new Set(current); const next = [...current]; for (const v of values) { @@ -60,12 +59,17 @@ export class CustomLevelStore { } return next.length > MAX_ENTRIES ? next.slice(next.length - MAX_ENTRIES) : next; }); - if (changed) this.persist(); + if (changed) this.persist(key); + } + + /** Current values for a palette, reactively. Reads inside a computed track the underlying signal. */ + values(key: string): readonly number[] { + return this.signalFor(key)(); } - private load(): number[] { + private load(key: string): number[] { try { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = localStorage.getItem(`${STORAGE_PREFIX}.${key}`); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; @@ -75,12 +79,20 @@ export class CustomLevelStore { } } - private persist(): void { + private persist(key: string): void { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(this._values())); + localStorage.setItem(`${STORAGE_PREFIX}.${key}`, JSON.stringify(this.signalFor(key)())); } catch { - // Quota exceeded or storage disabled — the in-memory signal still works - // for the current session; we just lose persistence. + // Quota exceeded or storage disabled — in-memory signal still works. + } + } + + private signalFor(key: string): WritableSignal { + let sig = this.palettes.get(key); + if (!sig) { + sig = signal(this.load(key)); + this.palettes.set(key, sig); } + return sig; } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html index 7122f809..4d2d0448 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html @@ -39,10 +39,19 @@

{{ 'RAIDS.SPECIFIC_GYM' | translate }}

{{ 'RAIDS.RAID_LEVELS' | translate }}

- +

{{ 'RAIDS.EGG_LEVELS' | translate }}

- +
@@ -55,7 +64,12 @@

{{ 'RAIDS.EGG_LEVELS' | translate }}

}

{{ 'RAIDS.RAID_LEVEL_LABEL' | translate }}

- + diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts index ca65fd55..718b19a2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; @@ -61,6 +61,8 @@ export class RaidAddDialogComponent { /** Single-select Boss-tab level; defaults to PoracleNG's "any" sentinel (9000). */ bossLevel = signal(ANY_LEVEL_VALUE); + /** Stable array reference for the level selector input — prevents per-tick re-binding. */ + bossLevelArray = computed(() => [this.bossLevel()]); commonForm = this.fb.group({ clean: [false], diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.html index 917794d9..7f0682d0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.html @@ -4,7 +4,7 @@

{{ (data.type === 'raid' ? 'RAIDS.EDIT_RAID_TITLE' : 'RAIDS

{{ getTitle() }}

- {{ 'RAIDS.LEVEL_PREFIX' | translate }} {{ data.item.level }} + {{ data.item.level | levelLabel }}
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts index 199153c0..9257f5bc 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts @@ -13,6 +13,7 @@ import { MatTabsModule } from '@angular/material/tabs'; import { TranslateModule } from '@ngx-translate/core'; import { Raid, Egg, RaidUpdate, EggUpdate } from '../../core/models'; +import { resolveLevel } from '../../core/models/raid-level.models'; import { AuthService } from '../../core/services/auth.service'; import { EggService } from '../../core/services/egg.service'; import { I18nService } from '../../core/services/i18n.service'; @@ -21,6 +22,7 @@ import { RaidService } from '../../core/services/raid.service'; import { DeliveryPreviewComponent } from '../../shared/components/delivery-preview/delivery-preview.component'; import { GymPickerComponent } from '../../shared/components/gym-picker/gym-picker.component'; import { TemplateSelectorComponent } from '../../shared/components/template-selector/template-selector.component'; +import { LevelLabelPipe } from '../../shared/pipes/level-label.pipe'; export interface RaidEditDialogData { item: Raid | Egg; @@ -44,6 +46,7 @@ export interface RaidEditDialogData { TemplateSelectorComponent, DeliveryPreviewComponent, GymPickerComponent, + LevelLabelPipe, ], selector: 'app-raid-edit-dialog', standalone: true, @@ -86,13 +89,13 @@ export class RaidEditDialogComponent { getTitle(): string { if (this.data.type === 'egg') { - return this.i18n.instant('RAIDS.LEVEL_PREFIX') + ' ' + this.data.item.level + ' ' + this.i18n.instant('RAIDS.EGG_SUFFIX'); + return this.formatLevel(this.data.item.level) + ' ' + this.i18n.instant('RAIDS.EGG_SUFFIX'); } const raid = this.data.item as Raid; if (raid.pokemonId && raid.pokemonId !== 9000) { return this.i18n.instant('RAIDS.RAID_BOSS_NUM', { id: raid.pokemonId }); } - return this.i18n.instant('RAIDS.LEVEL_PREFIX') + ' ' + raid.level + ' ' + this.i18n.instant('RAIDS.RAID_SUFFIX'); + return this.formatLevel(raid.level) + ' ' + this.i18n.instant('RAIDS.RAID_SUFFIX'); } onDistanceModeChange(): void { @@ -166,4 +169,12 @@ export class RaidEditDialogComponent { }); } } + + private formatLevel(level: number): string { + const opt = resolveLevel(level); + if (opt.category === 'custom') { + return this.i18n.instant(opt.labelKey) + ' ' + opt.badge; + } + return this.i18n.instant(opt.labelKey); + } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.html index 6975b692..413eddd7 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.html @@ -188,7 +188,7 @@

{{ 'RAIDS.EMPTY_RAIDS_TITLE' | translate }}

diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts index 1151ce81..ac184dcc 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts @@ -25,6 +25,7 @@ import { TestAlertService } from '../../core/services/test-alert.service'; import { AlarmInfoComponent } from '../../shared/components/alarm-info/alarm-info.component'; import { ConfirmDialogComponent, ConfirmDialogData } from '../../shared/components/confirm-dialog/confirm-dialog.component'; import { DistanceDialogComponent } from '../../shared/components/distance-dialog/distance-dialog.component'; +import { LevelLabelPipe } from '../../shared/pipes/level-label.pipe'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -39,6 +40,7 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog MatTabsModule, TranslateModule, AlarmInfoComponent, + LevelLabelPipe, ], selector: 'app-raid-list', standalone: true, @@ -247,7 +249,11 @@ export class RaidListComponent implements OnInit { } getLevelStars(level: number): number[] { - if (level === 9000 || level > 100) return []; + // Stars are only meaningful for the standard Pokémon GO raid tiers (1-5) + // plus Mega (6) and Elite (7). For anything else (custom integers, + // the 9000 "Any" sentinel), the label already conveys the level and a + // stars row would either be wrong (e.g. 23 stars) or empty. + if (level < 1 || level > 7) return []; return Array.from({ length: level }, (_, i) => i); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html index f8170f62..d072cbfd 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html @@ -1,111 +1,84 @@ -
-
- - - @for (opt of standardLevels; track opt.value) { - - {{ opt.labelKey | translate }} - - } - -
- -
- - - @for (opt of specialLevels; track opt.value) { - - {{ opt.labelKey | translate }} - @if (opt.badge !== undefined) { - - } - - } - @if (showAny) { - - {{ anyLevel.labelKey | translate }} - - } - -
- -
- -
- - @for (opt of palette(); track opt.value) { - - {{ opt.badge }} - - - } - - - @if (!addInputOpen()) { - - } @else { - - - @if (addInputError()) { - {{ addInputError()! | translate }} - } @else { - {{ 'RAIDS.LEVEL.ADD_HELP' | translate }} - } - - - - } -
-
+ + } + + + @if (isAddClosed()) { + + } @else { + + + + }
+ +@if (isAddOpen() || addInputError()) { +

+ @if (addInputError()) { + + {{ addInputError()! | translate }} + } @else { + {{ 'RAIDS.LEVEL.ADD_HELP' | translate }} + } +

+} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.scss index 29812884..7cec7e1b 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.scss @@ -1,106 +1,152 @@ -.level-selector { - display: flex; - flex-direction: column; - gap: 14px; - margin: 8px 0 4px; +:host { + display: block; + background: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent); + border-radius: 8px; + padding: 10px 12px; } -.level-section { +.lv { display: flex; - flex-direction: column; + flex-wrap: wrap; + align-items: center; gap: 6px; + row-gap: 8px; } -.section-label { - margin: 0; - font-size: 0.7rem; - font-weight: 600; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--mat-sys-on-surface-variant); - opacity: 0.85; -} - -.chip-row { - display: block; +.lv-chips { + display: contents; ::ng-deep .mdc-evolution-chip-set__chips { - flex-wrap: wrap; - gap: 6px; + display: contents; } } -.chip-label { - font-weight: 500; +.lv-sep { + margin: 0 4px; + opacity: 0.55; } -.chip-badge { - margin-left: 4px; +.lv-num { font-variant-numeric: tabular-nums; - opacity: 0.7; } -.chip-badge-only { - font-variant-numeric: tabular-nums; - font-weight: 500; -} +mat-chip-option { + transition: + box-shadow 180ms ease, + transform 180ms ease; -.chip-remove { - width: 22px !important; - height: 22px !important; - line-height: 22px !important; - margin-left: 2px; - margin-right: -4px; + &.flash { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--mat-sys-primary) 38%, transparent); + } - ::ng-deep .mat-icon { - font-size: 16px; - width: 16px; - height: 16px; - line-height: 16px; + // Bump the Material 3 selected-chip emphasis — defaults are too quiet + ::ng-deep &.mdc-evolution-chip--selected .mdc-evolution-chip__cell--primary { + font-weight: 600; } } -.custom-section .custom-row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; +.lv-custom .mat-icon { + font-size: 18px; + width: 18px; + height: 18px; } -.add-chip { +.lv-add { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; height: 32px; - padding: 0 12px; - border-radius: 8px; - font-size: 0.875rem; + padding: 0 10px; + border: 1px dashed color-mix(in srgb, var(--mat-sys-outline) 80%, transparent); + border-radius: 16px; + background: transparent; + color: var(--mat-sys-on-surface-variant); + cursor: pointer; + transition: + background 150ms ease, + border-color 150ms ease, + color 150ms ease; + + &:hover { + background: color-mix(in srgb, var(--mat-sys-primary) 8%, transparent); + border-color: var(--mat-sys-primary); + color: var(--mat-sys-primary); + } - ::ng-deep .mat-icon { + mat-icon { font-size: 18px; width: 18px; height: 18px; - margin-right: 4px; } } -.add-input { - width: 180px; - - ::ng-deep .mat-mdc-form-field-subscript-wrapper { - margin-top: 2px; +.lv-add-open { + width: 88px; + padding: 0; + border-style: solid; + border-color: var(--mat-sys-primary); + background: var(--mat-sys-surface); + + input { + width: 100%; + height: 100%; + border: 0; + background: transparent; + padding: 0 10px; + font: inherit; + color: var(--mat-sys-on-surface); + outline: none; + + &::placeholder { + color: var(--mat-sys-on-surface-variant); + opacity: 0.7; + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &[type='number'] { + -moz-appearance: textfield; + } } } -mat-chip-option { - transition: box-shadow 200ms ease; +.lv-add-invalid { + border-color: var(--mat-sys-error); + animation: lv-shake 200ms ease; +} - &.flash { - box-shadow: 0 0 0 3px color-mix(in srgb, var(--mat-sys-primary) 35%, transparent); +@keyframes lv-shake { + 0%, + 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-2px); + } + 75% { + transform: translateX(2px); } } -.level-selector.single mat-chip-option { - // Single-select mode shows the active chip more emphatically since there's - // only one in flight. - &[aria-selected='true'] { - font-weight: 600; +.lv-help { + margin: 8px 0 0; + padding: 0; + font-size: 0.78rem; + color: var(--mat-sys-on-surface-variant); + display: flex; + align-items: center; + gap: 6px; + min-height: 18px; + + .lv-help-icon { + color: var(--mat-sys-error); + font-size: 16px; + width: 16px; + height: 16px; } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.spec.ts index 2849967d..160656b2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.spec.ts @@ -7,7 +7,7 @@ import { LevelSelectorComponent } from './level-selector.component'; import { ANY_LEVEL_VALUE } from '../../../core/models/raid-level.models'; import { CustomLevelStore } from '../../../core/services/custom-level-store.service'; -const STORAGE_KEY = 'poracle.custom-raid-levels'; +const STORAGE_PREFIX = 'poracle.custom-levels'; describe('LevelSelectorComponent', () => { let fixture: ComponentFixture; @@ -15,14 +15,17 @@ describe('LevelSelectorComponent', () => { let store: CustomLevelStore; beforeEach(() => { - localStorage.removeItem(STORAGE_KEY); + for (const k of Object.keys(localStorage)) { + if (k.startsWith(STORAGE_PREFIX)) localStorage.removeItem(k); + } TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [LevelSelectorComponent, NoopAnimationsModule], providers: [provideHttpClient(), provideTranslateService()], + imports: [LevelSelectorComponent, NoopAnimationsModule], }); fixture = TestBed.createComponent(LevelSelectorComponent); component = fixture.componentInstance; + component.paletteKey = 'test'; store = TestBed.inject(CustomLevelStore); }); @@ -33,9 +36,9 @@ describe('LevelSelectorComponent', () => { expect(component).toBeTruthy(); }); - it('seeds custom levels from incoming value', () => { + it('seeds custom levels from incoming value into the keyed palette', () => { component.value = [42, 1]; - expect(store.values()).toEqual([42]); + expect(store.values('test')).toEqual([42]); }); it('toggle adds/removes in multi-select', () => { @@ -109,8 +112,7 @@ describe('LevelSelectorComponent', () => { c.commitAddInput(); expect(emitted[emitted.length - 1]).toEqual([ANY_LEVEL_VALUE]); - // Should NOT have been added to the custom palette - expect(store.values()).not.toContain(ANY_LEVEL_VALUE); + expect(store.values('test')).not.toContain(ANY_LEVEL_VALUE); }); it('commitAddInput selects an existing built-in instead of adding a duplicate', () => { @@ -120,14 +122,14 @@ describe('LevelSelectorComponent', () => { component.valueChange.subscribe(v => emitted.push(v)); const c = component as unknown as { addInputValue: { set: (v: string) => void }; commitAddInput: () => void }; - c.addInputValue.set('5'); // already in STANDARD + c.addInputValue.set('5'); c.commitAddInput(); expect(emitted[emitted.length - 1]).toEqual([5]); - expect(store.values()).not.toContain(5); + expect(store.values('test')).not.toContain(5); }); - it('commitAddInput adds a new custom and selects it', () => { + it('commitAddInput adds a new custom into the keyed palette and selects it', () => { component.multiple = true; component.value = []; const emitted: number[][] = []; @@ -137,14 +139,25 @@ describe('LevelSelectorComponent', () => { c.addInputValue.set('42'); c.commitAddInput(); - expect(store.values()).toContain(42); + expect(store.values('test')).toContain(42); expect(emitted[emitted.length - 1]).toEqual([42]); }); - it('removeCustom evicts the value from the palette and the selection', () => { + it('palette is scoped by paletteKey — additions on one key do not leak into another', () => { + component.multiple = true; + component.value = []; + const c = component as unknown as { addInputValue: { set: (v: string) => void }; commitAddInput: () => void }; + c.addInputValue.set('42'); + c.commitAddInput(); + + expect(store.values('test')).toContain(42); + expect(store.values('other')).not.toContain(42); + }); + + it('removeCustom evicts the value from the keyed palette and the selection', () => { component.multiple = true; component.value = [42]; - expect(store.values()).toContain(42); + expect(store.values('test')).toContain(42); const emitted: number[][] = []; component.valueChange.subscribe(v => emitted.push(v)); @@ -152,20 +165,21 @@ describe('LevelSelectorComponent', () => { const c = component as unknown as { removeCustom: (v: number, e: MouseEvent) => void }; c.removeCustom(42, new MouseEvent('click')); - expect(store.values()).not.toContain(42); + expect(store.values('test')).not.toContain(42); expect(emitted[emitted.length - 1]).toEqual([]); }); - it('Escape cancels the add input and clears state', () => { + it('Escape cancels the add input', () => { const c = component as unknown as { openAddInput: () => void; addInputValue: { set: (v: string) => void; (): string }; - addInputOpen: () => boolean; + isAddOpen: () => boolean; + isAddClosed: () => boolean; onAddKeydown: (e: KeyboardEvent) => void; }; c.openAddInput(); c.addInputValue.set('99'); c.onAddKeydown(new KeyboardEvent('keydown', { key: 'Escape' })); - expect(c.addInputOpen()).toBe(false); + expect(c.isAddClosed()).toBe(true); }); }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.ts index c2b06f7c..e15eee63 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.ts @@ -1,10 +1,7 @@ -import { Component, computed, EventEmitter, inject, Input, Output, signal, ViewChild } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { MatButtonModule } from '@angular/material/button'; +import { Component, computed, ElementRef, EventEmitter, inject, Input, Output, signal, ViewChild } from '@angular/core'; import { MatChipsModule } from '@angular/material/chips'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; -import { MatInputModule } from '@angular/material/input'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; @@ -20,53 +17,53 @@ import { import { CustomLevelStore } from '../../../core/services/custom-level-store.service'; /** - * Three-section chip-based selector for raid/egg levels. Backed by - * {@link CustomLevelStore} so user-added custom values persist across sessions. + * Compact single-row chip selector for raid/egg/boss levels. Standard, special, + * and custom chips all live in one wrapping flow; categories are encoded in + * chip content (T1 vs "Mega · 6" vs "42 ⊗") rather than in container labels. + * + * Required input: `paletteKey` — scopes the custom-level palette so additions + * on the raid picker don't bleed into the egg or boss pickers. * * Use `multiple=true` for the raid/egg multi-select case. Use `multiple=false` * for the boss-level single-select. Set `showAny=true` to surface the 9000 - * "Any" sentinel as a first-class chip (typical for single-select). + * "Any" sentinel as a first-class chip — only do this where PoracleNG actually + * honors 9000 as a wildcard (raids and bosses, NOT eggs). */ @Component({ - imports: [ - FormsModule, - MatButtonModule, - MatChipsModule, - MatFormFieldModule, - MatIconModule, - MatInputModule, - MatTooltipModule, - TranslateModule, - ], + imports: [MatChipsModule, MatIconModule, MatSnackBarModule, MatTooltipModule, TranslateModule], selector: 'app-level-selector', standalone: true, styleUrl: './level-selector.component.scss', templateUrl: './level-selector.component.html', }) export class LevelSelectorComponent { + /** Explicit two-state machine for the add affordance — avoids `!` negation bugs in templates. */ + private readonly addMode = signal<'closed' | 'open'>('closed'); private readonly customLevels = inject(CustomLevelStore); + private readonly snackBar = inject(MatSnackBar); private readonly translate = inject(TranslateService); + @ViewChild('addInput') addInput?: ElementRef; /** Inline validation error key, if any. */ protected readonly addInputError = signal(null); - /** Whether the "+ Add level" input is currently expanded. */ - protected readonly addInputOpen = signal(false); /** Current text in the add-custom input. */ protected readonly addInputValue = signal(''); protected readonly anyLevel = ANY_LEVEL; - - @ViewChild('customInput') customInput?: { nativeElement: HTMLInputElement }; /** Value that should flash briefly after a duplicate add attempt. */ protected readonly flashValue = signal(null); + protected isAddClosed = () => this.addMode() === 'closed'; + protected isAddOpen = () => this.addMode() === 'open'; + @Input() multiple = true; + /** Identifier for the custom-level palette (`raid` / `egg` / `boss`). Required. */ + @Input({ required: true }) paletteKey!: string; + /** Custom palette (from the store, scoped by paletteKey). */ + protected readonly palette = computed(() => this.customLevels.values(this.paletteKey).map(makeCustomLevel)); - /** Custom palette (from the store). */ - protected readonly palette = computed(() => this.customLevels.values().map(makeCustomLevel)); /** Current selection. Internal signal; pushed in via `value` setter, out via `valueChange`. */ protected readonly selected = signal([]); - - /** When true, surface the 9000 "Any" sentinel as a dedicated chip in SPECIAL. */ + /** When true, surface the 9000 "Any" sentinel as a dedicated chip. */ @Input() showAny = false; protected readonly specialLevels = SPECIAL_LEVELS; protected readonly standardLevels = STANDARD_LEVELS; @@ -76,14 +73,14 @@ export class LevelSelectorComponent { set value(next: number[] | null | undefined) { const safe = (next ?? []).filter(v => Number.isInteger(v) && v >= 1); this.selected.set(safe); - // Surface any custom values from incoming alarms into the palette so they - // appear pre-selected in the CUSTOM row rather than being orphaned. + // Surface any custom values from incoming alarms into THIS palette so they + // appear pre-selected rather than orphaned. Scoped by paletteKey. const customs = safe.filter(v => !isBuiltInLevel(v)); - if (customs.length > 0) this.customLevels.seedFrom(customs); + if (customs.length > 0 && this.paletteKey) this.customLevels.seedFrom(this.paletteKey, customs); } protected cancelAddInput(): void { - this.addInputOpen.set(false); + this.addMode.set('closed'); this.addInputValue.set(''); this.addInputError.set(null); } @@ -100,21 +97,17 @@ export class LevelSelectorComponent { return; } - // The "Any" sentinel snaps to its dedicated chip — don't create a duplicate - // custom entry. If showAny is off, still treat it as a special case for clarity. - if (parsed === ANY_LEVEL_VALUE) { - if (this.showAny) { - this.cancelAddInput(); - if (!this.isSelected(ANY_LEVEL_VALUE)) this.toggle(ANY_LEVEL_VALUE); - this.flash(ANY_LEVEL_VALUE); - return; - } - // showAny is off but user wants Any — accept it as a custom value rather than blocking. + // The "Any" sentinel snaps to its dedicated chip when surfaced. + if (parsed === ANY_LEVEL_VALUE && this.showAny) { + this.closeAdd(); + if (!this.isSelected(ANY_LEVEL_VALUE)) this.toggle(ANY_LEVEL_VALUE); + this.flash(ANY_LEVEL_VALUE); + return; } // Duplicate of an existing built-in chip — flash it instead of erroring. if (isBuiltInLevel(parsed)) { - this.cancelAddInput(); + this.closeAdd(); if (!this.isSelected(parsed)) this.toggle(parsed); this.flash(parsed); return; @@ -128,8 +121,8 @@ export class LevelSelectorComponent { return; } - this.customLevels.add(parsed); - this.cancelAddInput(); + this.customLevels.add(this.paletteKey, parsed); + this.closeAdd(); if (!this.isSelected(parsed)) this.toggle(parsed); } @@ -137,6 +130,12 @@ export class LevelSelectorComponent { return this.selected().includes(value); } + protected onAddInput(event: Event): void { + const v = (event.target as HTMLInputElement).value; + this.addInputValue.set(v); + if (this.addInputError()) this.addInputError.set(null); + } + protected onAddKeydown(event: KeyboardEvent): void { if (event.key === 'Enter') { event.preventDefault(); @@ -148,18 +147,33 @@ export class LevelSelectorComponent { } protected openAddInput(): void { - this.addInputOpen.set(true); + this.addMode.set('open'); this.addInputValue.set(''); this.addInputError.set(null); - queueMicrotask(() => this.customInput?.nativeElement.focus()); + queueMicrotask(() => this.addInput?.nativeElement.focus()); } protected removeCustom(value: number, event: MouseEvent): void { event.stopPropagation(); - this.customLevels.remove(value); - if (this.selected().includes(value)) { - this.toggle(value); + const key = this.paletteKey; + const wasSelected = this.selected().includes(value); + this.customLevels.remove(key, value); + if (wasSelected) { + const next = this.selected().filter(v => v !== value); + this.selected.set(next); + this.valueChange.emit(next); } + const ref = this.snackBar.open(this.translate.instant('RAIDS.LEVEL.REMOVED', { value }), this.translate.instant('COMMON.UNDO'), { + duration: 3000, + }); + ref.onAction().subscribe(() => { + this.customLevels.add(key, value); + if (wasSelected) { + const next = [...this.selected(), value]; + this.selected.set(next); + this.valueChange.emit(next); + } + }); } protected toggle(value: number): void { @@ -168,13 +182,18 @@ export class LevelSelectorComponent { if (this.multiple) { next = current.includes(value) ? current.filter(v => v !== value) : [...current, value]; } else { - // Single-select: clicking the active chip clears; otherwise replace. next = current.includes(value) && current.length === 1 ? [] : [value]; } this.selected.set(next); this.valueChange.emit(next); } + private closeAdd(): void { + this.addMode.set('closed'); + this.addInputValue.set(''); + this.addInputError.set(null); + } + private flash(value: number): void { this.flashValue.set(value); setTimeout(() => { 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 a2c42513..4244507e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -420,7 +420,8 @@ "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", "INVALID": "Level must be 1 or higher.", "DUPLICATE": "Level {{value}} is already in the list.", - "SR_REMOVE": "Remove custom level {{value}}" + "SR_REMOVE": "Remove custom level {{value}}", + "REMOVED": "Removed level {{value}}" } }, "QUESTS": { @@ -1316,6 +1317,7 @@ "EDIT": "Rediger", "ADD": "Tilføj", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Bekræft", "DELETE_ALL": "Slet alle", "CLOSE": "Luk", 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 a8319f44..9db2b61a 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -420,7 +420,8 @@ "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", "INVALID": "Level must be 1 or higher.", "DUPLICATE": "Level {{value}} is already in the list.", - "SR_REMOVE": "Remove custom level {{value}}" + "SR_REMOVE": "Remove custom level {{value}}", + "REMOVED": "Removed level {{value}}" } }, "QUESTS": { @@ -1316,6 +1317,7 @@ "EDIT": "Bearbeiten", "ADD": "Hinzufügen", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Bestätigen", "DELETE_ALL": "Alle löschen", "CLOSE": "Schließen", 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 42b27083..543217ca 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -420,7 +420,8 @@ "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", "INVALID": "Level must be 1 or higher.", "DUPLICATE": "Level {{value}} is already in the list.", - "SR_REMOVE": "Remove custom level {{value}}" + "SR_REMOVE": "Remove custom level {{value}}", + "REMOVED": "Removed level {{value}}" } }, "QUESTS": { @@ -1316,6 +1317,7 @@ "EDIT": "Edit", "ADD": "Add", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Confirm", "DELETE_ALL": "Delete All", "CLOSE": "Close", 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 ebbde94d..cd4fa01d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -420,7 +420,8 @@ "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", "INVALID": "Level must be 1 or higher.", "DUPLICATE": "Level {{value}} is already in the list.", - "SR_REMOVE": "Remove custom level {{value}}" + "SR_REMOVE": "Remove custom level {{value}}", + "REMOVED": "Removed level {{value}}" } }, "QUESTS": { @@ -1316,6 +1317,7 @@ "EDIT": "Editar", "ADD": "Añadir", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Confirmar", "DELETE_ALL": "Eliminar todo", "CLOSE": "Cerrar", 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 810f264c..67aa632d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -420,7 +420,8 @@ "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", "INVALID": "Level must be 1 or higher.", "DUPLICATE": "Level {{value}} is already in the list.", - "SR_REMOVE": "Remove custom level {{value}}" + "SR_REMOVE": "Remove custom level {{value}}", + "REMOVED": "Removed level {{value}}" } }, "QUESTS": { @@ -1316,6 +1317,7 @@ "EDIT": "Modifier", "ADD": "Ajouter", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Confirmer", "DELETE_ALL": "Tout supprimer", "CLOSE": "Fermer", 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 d52fb9d8..48d3727f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -420,7 +420,8 @@ "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", "INVALID": "Level must be 1 or higher.", "DUPLICATE": "Level {{value}} is already in the list.", - "SR_REMOVE": "Remove custom level {{value}}" + "SR_REMOVE": "Remove custom level {{value}}", + "REMOVED": "Removed level {{value}}" } }, "QUESTS": { @@ -1316,6 +1317,7 @@ "EDIT": "Modifica", "ADD": "Aggiungi", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Conferma", "DELETE_ALL": "Elimina Tutto", "CLOSE": "Chiudi", 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 4a26a8e2..ce7f5530 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -420,7 +420,8 @@ "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", "INVALID": "Level must be 1 or higher.", "DUPLICATE": "Level {{value}} is already in the list.", - "SR_REMOVE": "Remove custom level {{value}}" + "SR_REMOVE": "Remove custom level {{value}}", + "REMOVED": "Removed level {{value}}" } }, "QUESTS": { @@ -1316,6 +1317,7 @@ "EDIT": "Bewerken", "ADD": "Toevoegen", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Bevestigen", "DELETE_ALL": "Alles Verwijderen", "CLOSE": "Sluiten", 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 c627ff77..5cbd2975 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -420,7 +420,8 @@ "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", "INVALID": "Level must be 1 or higher.", "DUPLICATE": "Level {{value}} is already in the list.", - "SR_REMOVE": "Remove custom level {{value}}" + "SR_REMOVE": "Remove custom level {{value}}", + "REMOVED": "Removed level {{value}}" } }, "QUESTS": { @@ -1316,6 +1317,7 @@ "EDIT": "Edytuj", "ADD": "Dodaj", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Potwierdź", "DELETE_ALL": "Usuń wszystko", "CLOSE": "Zamknij", 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 60b0c5f6..2e194678 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 @@ -420,7 +420,8 @@ "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", "INVALID": "Level must be 1 or higher.", "DUPLICATE": "Level {{value}} is already in the list.", - "SR_REMOVE": "Remove custom level {{value}}" + "SR_REMOVE": "Remove custom level {{value}}", + "REMOVED": "Removed level {{value}}" } }, "QUESTS": { @@ -1316,6 +1317,7 @@ "EDIT": "Editar", "ADD": "Adicionar", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Confirmar", "DELETE_ALL": "Excluir Tudo", "CLOSE": "Fechar", 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 6dd58508..7f23eef9 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -420,7 +420,8 @@ "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", "INVALID": "Level must be 1 or higher.", "DUPLICATE": "Level {{value}} is already in the list.", - "SR_REMOVE": "Remove custom level {{value}}" + "SR_REMOVE": "Remove custom level {{value}}", + "REMOVED": "Removed level {{value}}" } }, "QUESTS": { @@ -1316,6 +1317,7 @@ "EDIT": "Editar", "ADD": "Adicionar", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Confirmar", "DELETE_ALL": "Eliminar Tudo", "CLOSE": "Fechar", 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 d28f2da9..3d4fa4e4 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -420,7 +420,8 @@ "ADD_HELP": "Any positive integer your server uses. 9000 means \"any level\".", "INVALID": "Level must be 1 or higher.", "DUPLICATE": "Level {{value}} is already in the list.", - "SR_REMOVE": "Remove custom level {{value}}" + "SR_REMOVE": "Remove custom level {{value}}", + "REMOVED": "Removed level {{value}}" } }, "QUESTS": { @@ -1316,6 +1317,7 @@ "EDIT": "Redigera", "ADD": "Lägg till", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Bekräfta", "DELETE_ALL": "Radera alla", "CLOSE": "Stäng", diff --git a/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs index 61527641..7aef59ff 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/EggCreate.cs @@ -19,7 +19,8 @@ public int Distance [Range(0, 4)] public int Team { get; set; } = 4; - [Range(0, 10)] + // PoracleNG accepts any positive integer as an egg level. See #259. + [Range(0, int.MaxValue)] public int Level { get; set; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs index 2356e53f..e60d21b7 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/EggUpdate.cs @@ -22,7 +22,8 @@ public int? Team get; set; } - [Range(0, 10)] + // See EggCreate.Level — PoracleNG accepts arbitrary positive integers. + [Range(0, int.MaxValue)] public int? Level { get; set; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/RaidCreate.cs b/Core/Pgan.PoracleWebNet.Core.Models/RaidCreate.cs index b2a05a83..cc4e5f74 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/RaidCreate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/RaidCreate.cs @@ -25,7 +25,11 @@ public int Distance [Range(0, 4)] public int Team { get; set; } = 4; - [Range(0, 10)] + // PoracleNG accepts any positive integer as a raid level, plus 9000 as the + // "any level" wildcard. The previous [Range(0, 10)] rejected the wildcard + // and any custom server-defined tiers (Elite at 7+, custom 8+) before they + // could reach PoracleNG. See #259. + [Range(0, int.MaxValue)] public int Level { get; set; diff --git a/Core/Pgan.PoracleWebNet.Core.Models/RaidUpdate.cs b/Core/Pgan.PoracleWebNet.Core.Models/RaidUpdate.cs index 4df5f239..6a1db197 100644 --- a/Core/Pgan.PoracleWebNet.Core.Models/RaidUpdate.cs +++ b/Core/Pgan.PoracleWebNet.Core.Models/RaidUpdate.cs @@ -22,7 +22,9 @@ public int? Team get; set; } - [Range(0, 10)] + // See RaidCreate.Level — PoracleNG accepts arbitrary positive integers + // (plus 9000 as the wildcard). + [Range(0, int.MaxValue)] public int? Level { get; set; From 12be778757898234b4ae1fd42632637013413ebc Mon Sep 17 00:00:00 2001 From: hokiepokedad2 <38219945+hokiepokedad2@users.noreply.github.com> Date: Fri, 22 May 2026 15:55:58 -0400 Subject: [PATCH 3/5] fix(raids): align level selector with WatWowMap masterfile (19 named levels) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the v2 review-pass (2c2f0aa). Follow-up driven by the issue reporter pointing to the canonical Pokémon GO raid level vocabulary in the WatWowMap masterfile — there are 19 named raid types (1-Star through Coordinated 2), not 7, and the prior UI labeled level 7 as "Elite" when the masterfile says it's "Mega Legendary". Backend - `GET /api/masterdata/raid-levels` returns the canonical list with per-level integer, category, and singular/plural English names. - New `IRaidLevelService` / `RaidLevelService` returns a baked-in snapshot of the masterfile. A TODO documents how to swap the implementation for a live fetch from raw.githubusercontent.com/WatWowMap/Masterfile-Generator without changing the wire contract. - 6 new unit tests cover the service + controller endpoint. Frontend - `RaidLevelService` (Angular) calls the new API on first dialog use, caches the result in a signal. Falls back to `KNOWN_LEVELS` baked-in constants when the network fails or before resolve. - `raid-level.models.ts` rewritten around 19 canonical levels keyed by `RAIDS.LEVEL.RAID_1` through `RAID_19` (plus `_PLURAL` variants). Removed the bogus `T1`-`T5` / `MEGA` / `ELITE` keys. - `LevelSelectorComponent` simplified to a `pickerType` input (`'raid' | 'egg' | 'boss'`). Inputs: • raid → primary chips 1-7, overflow menu for 8-19, Any chip, +Add • egg → star tiers (1-5) only, +Add (no overflow, no Any) • boss → single-select with the same primary + overflow as raid "More raid types…" overlay menu (mat-menu) surfaces the 12 less common levels without crowding the chip row. - i18n: 19 singular + 19 plural keys in all 11 locales, with English placeholders for the 10 non-en locales (volunteers per #211). - Card star icons now render only for the literal 1-5 "N Star Raid" tier (was 1-7, producing ~23 stars for custom-level alarms). - Label vocabulary consistency: alarm at level 7 now reads "Mega Legendary Raid" on the card and in the edit dialog (was "Elite" on the card, "Level 7" in the edit dialog). Forward compatibility - Any positive integer remains addable via the `+ Add` chip. When the WatWowMap masterfile adds raid_20+ in the future, the backend service can pick it up automatically (once the live-fetch path is wired); existing custom alarms at that level continue to work. Tests: 711/711 frontend, 1069/1069 backend. Lint + prettier clean. --- .../ServiceCollectionExtensions.cs | 1 + .../Controllers/MasterDataController.cs | 161 +++++++------ .../app/core/models/raid-level.models.spec.ts | 139 ++++++----- .../src/app/core/models/raid-level.models.ts | 101 +++++--- .../custom-level-store.service.spec.ts | 15 +- .../core/services/raid-level.service.spec.ts | 83 +++++++ .../app/core/services/raid-level.service.ts | 99 ++++++++ .../raids/raid-add-dialog.component.html | 20 +- .../raids/raid-edit-dialog.component.ts | 2 +- .../app/modules/raids/raid-list.component.ts | 13 +- .../level-selector.component.html | 46 ++-- .../level-selector.component.scss | 37 +++ .../level-selector.component.spec.ts | 76 +++--- .../level-selector.component.ts | 134 +++++++---- .../app/shared/pipes/level-label.pipe.spec.ts | 17 +- .../src/app/shared/pipes/level-label.pipe.ts | 9 +- .../ClientApp/src/assets/i18n/da.json | 55 ++++- .../ClientApp/src/assets/i18n/de.json | 55 ++++- .../ClientApp/src/assets/i18n/en.json | 55 ++++- .../ClientApp/src/assets/i18n/es.json | 55 ++++- .../ClientApp/src/assets/i18n/fr.json | 55 ++++- .../ClientApp/src/assets/i18n/it.json | 55 ++++- .../ClientApp/src/assets/i18n/nl.json | 55 ++++- .../ClientApp/src/assets/i18n/pl.json | 55 ++++- .../ClientApp/src/assets/i18n/pt-BR.json | 55 ++++- .../ClientApp/src/assets/i18n/pt.json | 55 ++++- .../ClientApp/src/assets/i18n/sv.json | 55 ++++- CHANGELOG.md | 2 +- .../Services/IRaidLevelService.cs | 18 ++ .../RaidLevelInfo.cs | 25 ++ .../RaidLevelService.cs | 58 +++++ .../MasterDataControllerRaidLevelsTests.cs | 54 +++++ .../Controllers/MasterDataControllerTests.cs | 222 +++++++++--------- .../Services/RaidLevelServiceTests.cs | 76 ++++++ 34 files changed, 1521 insertions(+), 492 deletions(-) create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.spec.ts create mode 100644 Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.ts create mode 100644 Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IRaidLevelService.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Models/RaidLevelInfo.cs create mode 100644 Core/Pgan.PoracleWebNet.Core.Services/RaidLevelService.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerRaidLevelsTests.cs create mode 100644 Tests/Pgan.PoracleWebNet.Tests/Services/RaidLevelServiceTests.cs diff --git a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs index be23019d..b255baf2 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Configuration/ServiceCollectionExtensions.cs @@ -77,6 +77,7 @@ public static IServiceCollection AddPoracleServices(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs index 641e5315..a527210e 100644 --- a/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs +++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/MasterDataController.cs @@ -1,72 +1,89 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Pgan.PoracleWebNet.Core.Abstractions.Services; - -namespace Pgan.PoracleWebNet.Api.Controllers; - -[Route("api/masterdata")] -public class MasterDataController(IMasterDataService masterDataService, IPoracleApiProxy poracleApiProxy) : BaseApiController -{ - private readonly IMasterDataService _masterDataService = masterDataService; - private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; - - [AllowAnonymous] - [HttpGet("pokemon")] - public async Task GetPokemon() - { - var data = await this._masterDataService.GetPokemonDataAsync(); - if (data == null) - { - await this._masterDataService.RefreshCacheAsync(); - data = await this._masterDataService.GetPokemonDataAsync(); - } - - if (data == null) - { - return this.NotFound(new - { - message = "Pokemon data not available." - }); - } - - return this.Content(data, "application/json"); - } - - [AllowAnonymous] - [HttpGet("items")] - public async Task GetItems() - { - var data = await this._masterDataService.GetItemDataAsync(); - if (data == null) - { - await this._masterDataService.RefreshCacheAsync(); - data = await this._masterDataService.GetItemDataAsync(); - } - - if (data == null) - { - return this.NotFound(new - { - message = "Item data not available." - }); - } - - return this.Content(data, "application/json"); - } - - [AllowAnonymous] - [HttpGet("grunts")] - public async Task GetGrunts() - { - var grunts = await this._poracleApiProxy.GetGruntsAsync(); - if (grunts == null) - { - return this.NotFound(new - { - message = "Grunt data not available." - }); - } - - return this.Content(grunts, "application/json"); - } -} +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Api.Controllers; + +[Route("api/masterdata")] +public class MasterDataController( + IMasterDataService masterDataService, + IPoracleApiProxy poracleApiProxy, + IRaidLevelService raidLevelService) : BaseApiController +{ + private readonly IMasterDataService _masterDataService = masterDataService; + private readonly IPoracleApiProxy _poracleApiProxy = poracleApiProxy; + private readonly IRaidLevelService _raidLevelService = raidLevelService; + + [AllowAnonymous] + [HttpGet("pokemon")] + public async Task GetPokemon() + { + var data = await this._masterDataService.GetPokemonDataAsync(); + if (data == null) + { + await this._masterDataService.RefreshCacheAsync(); + data = await this._masterDataService.GetPokemonDataAsync(); + } + + if (data == null) + { + return this.NotFound(new + { + message = "Pokemon data not available." + }); + } + + return this.Content(data, "application/json"); + } + + [AllowAnonymous] + [HttpGet("items")] + public async Task GetItems() + { + var data = await this._masterDataService.GetItemDataAsync(); + if (data == null) + { + await this._masterDataService.RefreshCacheAsync(); + data = await this._masterDataService.GetItemDataAsync(); + } + + if (data == null) + { + return this.NotFound(new + { + message = "Item data not available." + }); + } + + return this.Content(data, "application/json"); + } + + /// + /// Canonical raid-level vocabulary (currently 19 levels from the WatWowMap masterfile). + /// Cached server-side; the frontend uses this to render the level selector and + /// fall back to bare integers for any level not in the list. + /// + [AllowAnonymous] + [HttpGet("raid-levels")] + public async Task GetRaidLevels() + { + var levels = await this._raidLevelService.GetAllAsync(); + return this.Ok(levels); + } + + [AllowAnonymous] + [HttpGet("grunts")] + public async Task GetGrunts() + { + var grunts = await this._poracleApiProxy.GetGruntsAsync(); + if (grunts == null) + { + return this.NotFound(new + { + message = "Grunt data not available." + }); + } + + return this.Content(grunts, "application/json"); + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.spec.ts index 44ee21bb..e7c00ab0 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.spec.ts @@ -1,84 +1,115 @@ import { ANY_LEVEL, ANY_LEVEL_VALUE, - isBuiltInLevel, + EGG_LEVELS, + isKnownLevel, + KNOWN_LEVELS, makeCustomLevel, + MEGA_LEVELS, + OVERFLOW_RAID_LEVELS, + PRIMARY_RAID_LEVELS, resolveLevel, - SPECIAL_LEVELS, - STANDARD_LEVELS, + STAR_LEVELS, } from './raid-level.models'; -describe('raid-level.models', () => { - describe('resolveLevel', () => { - it('returns the ANY_LEVEL option for the 9000 sentinel', () => { - expect(resolveLevel(9000)).toEqual(ANY_LEVEL); - expect(resolveLevel(ANY_LEVEL_VALUE)).toEqual(ANY_LEVEL); +describe('raid-level.models (canonical 19 levels)', () => { + describe('KNOWN_LEVELS', () => { + it('has 19 entries covering integers 1-19 in order', () => { + expect(KNOWN_LEVELS.length).toBe(19); + KNOWN_LEVELS.forEach((opt, i) => expect(opt.value).toBe(i + 1)); }); - it('returns the standard option for tiers 1-5', () => { - for (const level of [1, 2, 3, 4, 5]) { - const opt = resolveLevel(level); - expect(opt.value).toBe(level); - expect(opt.category).toBe('standard'); - expect(opt.labelKey).toBe(`RAIDS.LEVEL.T${level}`); - } + it('partitions correctly by category', () => { + const byCategory = KNOWN_LEVELS.reduce>((acc, l) => { + (acc[l.category] ||= []).push(l.value); + return acc; + }, {}); + expect(byCategory['star']).toEqual([1, 2, 3, 4, 5]); + expect(byCategory['mega']).toEqual([6, 7]); + expect(byCategory['special']).toEqual([8, 9, 10]); + expect(byCategory['shadow']).toEqual([11, 12, 13, 14, 15]); + expect(byCategory['superMega']).toEqual([16, 17]); + expect(byCategory['coordinated']).toEqual([18, 19]); }); - it('returns Mega for level 6 and Elite for level 7', () => { - expect(resolveLevel(6).labelKey).toBe('RAIDS.LEVEL.MEGA'); - expect(resolveLevel(6).category).toBe('special'); - expect(resolveLevel(6).badge).toBe(6); - expect(resolveLevel(7).labelKey).toBe('RAIDS.LEVEL.ELITE'); - expect(resolveLevel(7).category).toBe('special'); + it('points level 7 at Mega Legendary, level 9 at Elite (fixes prior mislabel)', () => { + const seven = KNOWN_LEVELS.find(l => l.value === 7)!; + const nine = KNOWN_LEVELS.find(l => l.value === 9)!; + expect(seven.labelKey).toBe('RAIDS.LEVEL.RAID_7'); + expect(seven.category).toBe('mega'); + expect(nine.labelKey).toBe('RAIDS.LEVEL.RAID_9'); + expect(nine.category).toBe('special'); }); - it('returns a custom option for unrecognized levels', () => { - const opt = resolveLevel(42); - expect(opt.value).toBe(42); - expect(opt.category).toBe('custom'); - expect(opt.badge).toBe(42); - expect(opt.labelKey).toBe('RAIDS.LEVEL.CUSTOM'); + it('every entry has both singular and plural keys', () => { + KNOWN_LEVELS.forEach(opt => { + expect(opt.labelKey).toBe(`RAIDS.LEVEL.RAID_${opt.value}`); + expect(opt.pluralKey).toBe(`RAIDS.LEVEL.RAID_${opt.value}_PLURAL`); + }); }); + }); - it('returns custom for level 0 and negative inputs (PoracleNG rejects them but the resolver is permissive)', () => { - expect(resolveLevel(0).category).toBe('custom'); - expect(resolveLevel(-1).category).toBe('custom'); + describe('derived groupings', () => { + it('STAR_LEVELS is 1-5', () => { + expect(STAR_LEVELS.map(l => l.value)).toEqual([1, 2, 3, 4, 5]); }); - }); - describe('isBuiltInLevel', () => { - it('is true for standard, special, and any', () => { - expect(isBuiltInLevel(1)).toBe(true); - expect(isBuiltInLevel(5)).toBe(true); - expect(isBuiltInLevel(6)).toBe(true); - expect(isBuiltInLevel(7)).toBe(true); - expect(isBuiltInLevel(ANY_LEVEL_VALUE)).toBe(true); + it('EGG_LEVELS mirrors STAR_LEVELS (eggs only have star tiers)', () => { + expect(EGG_LEVELS.map(l => l.value)).toEqual([1, 2, 3, 4, 5]); }); - it('is false for custom levels', () => { - expect(isBuiltInLevel(8)).toBe(false); - expect(isBuiltInLevel(42)).toBe(false); - expect(isBuiltInLevel(0)).toBe(false); + it('MEGA_LEVELS is 6-7', () => { + expect(MEGA_LEVELS.map(l => l.value)).toEqual([6, 7]); + }); + + it('PRIMARY_RAID_LEVELS is 1-7', () => { + expect(PRIMARY_RAID_LEVELS.map(l => l.value)).toEqual([1, 2, 3, 4, 5, 6, 7]); + }); + + it('OVERFLOW_RAID_LEVELS is 8-19', () => { + expect(OVERFLOW_RAID_LEVELS.map(l => l.value)).toEqual([8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); }); }); - describe('makeCustomLevel', () => { - it('builds a custom option that round-trips through resolveLevel', () => { - const custom = makeCustomLevel(66); - expect(resolveLevel(66)).toEqual(custom); + describe('resolveLevel', () => { + it('returns ANY_LEVEL for the 9000 sentinel', () => { + expect(resolveLevel(ANY_LEVEL_VALUE)).toEqual(ANY_LEVEL); + }); + + it('returns the canonical option for any value 1-19', () => { + for (let v = 1; v <= 19; v++) { + const opt = resolveLevel(v); + expect(opt.value).toBe(v); + expect(opt.labelKey).toBe(`RAIDS.LEVEL.RAID_${v}`); + expect(opt.category).not.toBe('custom'); + } + }); + + it('returns a custom option for unrecognized values (20+, negatives, 0)', () => { + expect(resolveLevel(42).category).toBe('custom'); + expect(resolveLevel(20).category).toBe('custom'); + expect(resolveLevel(0).category).toBe('custom'); + expect(resolveLevel(-1).category).toBe('custom'); }); }); - describe('STANDARD_LEVELS and SPECIAL_LEVELS', () => { - it('have non-overlapping values', () => { - const std = new Set(STANDARD_LEVELS.map(l => l.value)); - const sp = new Set(SPECIAL_LEVELS.map(l => l.value)); - for (const v of std) expect(sp.has(v)).toBe(false); + describe('isKnownLevel', () => { + it('is true for 1-19 and 9000', () => { + for (let v = 1; v <= 19; v++) expect(isKnownLevel(v)).toBe(true); + expect(isKnownLevel(ANY_LEVEL_VALUE)).toBe(true); + }); + + it('is false for 0, negatives, and 20+', () => { + expect(isKnownLevel(0)).toBe(false); + expect(isKnownLevel(-3)).toBe(false); + expect(isKnownLevel(20)).toBe(false); + expect(isKnownLevel(42)).toBe(false); }); + }); - it('do not include the ANY sentinel', () => { - const all = [...STANDARD_LEVELS, ...SPECIAL_LEVELS].map(l => l.value); - expect(all).not.toContain(ANY_LEVEL_VALUE); + describe('makeCustomLevel', () => { + it('round-trips through resolveLevel', () => { + expect(resolveLevel(66)).toEqual(makeCustomLevel(66)); }); }); }); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.ts index 7320b52f..9fcfe5f3 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.ts @@ -1,64 +1,105 @@ -// PoracleNG accepts any positive integer as a raid/egg level. The UI presents -// a curated vocabulary (Standard tiers, Special tiers like Mega/Elite, and a -// per-user Custom palette) on top of that integer space, plus the canonical -// "Any" sentinel (9000) that PoracleNG treats as a wildcard. +// Canonical raid level vocabulary, sourced from the WatWowMap masterfile: +// https://github.com/WatWowMap/Masterfile-Generator/blob/main/master-latest-poracle-v2.json // -// `resolveLevel(value)` is the single place that maps a stored integer to the -// option it should render as — used by both the level selector dialog and the -// alarm cards so the vocabulary stays consistent across surfaces. +// PoracleNG accepts any positive integer as a raid/egg level. The UI maps the +// 19 currently-known integers to their canonical names; users can still add +// arbitrary integers via the custom input for forward compatibility with new +// raid types that haven't shipped to the frontend yet. +// +// Backend matching is purely integer-keyed — names are pure UI vocabulary. +// `resolveLevel(value)` is the single mapping from stored integer → display +// option, used by the selector dialog and the alarm cards alike so all +// surfaces speak the same vocabulary. -export type LevelCategory = 'standard' | 'special' | 'any' | 'custom'; +export type LevelCategory = 'star' | 'mega' | 'special' | 'shadow' | 'superMega' | 'coordinated' | 'any' | 'custom'; export interface LevelOption { - /** Optional integer shown as inline metadata (e.g., 6 next to "Mega"). */ - badge?: number; + /** Coarse grouping for the selector overflow menu and category badges. */ category: LevelCategory; - /** ngx-translate key for the human label (e.g., `RAIDS.LEVEL.MEGA`). */ + /** ngx-translate key for the singular human label. */ labelKey: string; + /** ngx-translate key for the plural form (used in card titles). */ + pluralKey?: string; /** Backend integer. PoracleNG accepts any positive integer. */ value: number; } -/** PoracleNG's wildcard sentinel — matches any raid level. */ +/** PoracleNG's wildcard sentinel for raid matching — matches any raid level. */ export const ANY_LEVEL_VALUE = 9000 as const; -export const STANDARD_LEVELS: readonly LevelOption[] = [ - { category: 'standard', labelKey: 'RAIDS.LEVEL.T1', value: 1 }, - { category: 'standard', labelKey: 'RAIDS.LEVEL.T2', value: 2 }, - { category: 'standard', labelKey: 'RAIDS.LEVEL.T3', value: 3 }, - { category: 'standard', labelKey: 'RAIDS.LEVEL.T4', value: 4 }, - { category: 'standard', labelKey: 'RAIDS.LEVEL.T5', value: 5 }, +/** + * The 19 known raid levels. Order matters for menu rendering; categories cluster + * naturally by integer (1-5 star, 6-7 mega, 8-10 special, 11-15 shadow, + * 16-17 super mega, 18-19 coordinated). + */ +export const KNOWN_LEVELS: readonly LevelOption[] = [ + { category: 'star', labelKey: 'RAIDS.LEVEL.RAID_1', pluralKey: 'RAIDS.LEVEL.RAID_1_PLURAL', value: 1 }, + { category: 'star', labelKey: 'RAIDS.LEVEL.RAID_2', pluralKey: 'RAIDS.LEVEL.RAID_2_PLURAL', value: 2 }, + { category: 'star', labelKey: 'RAIDS.LEVEL.RAID_3', pluralKey: 'RAIDS.LEVEL.RAID_3_PLURAL', value: 3 }, + { category: 'star', labelKey: 'RAIDS.LEVEL.RAID_4', pluralKey: 'RAIDS.LEVEL.RAID_4_PLURAL', value: 4 }, + { category: 'star', labelKey: 'RAIDS.LEVEL.RAID_5', pluralKey: 'RAIDS.LEVEL.RAID_5_PLURAL', value: 5 }, + { category: 'mega', labelKey: 'RAIDS.LEVEL.RAID_6', pluralKey: 'RAIDS.LEVEL.RAID_6_PLURAL', value: 6 }, + { category: 'mega', labelKey: 'RAIDS.LEVEL.RAID_7', pluralKey: 'RAIDS.LEVEL.RAID_7_PLURAL', value: 7 }, + { category: 'special', labelKey: 'RAIDS.LEVEL.RAID_8', pluralKey: 'RAIDS.LEVEL.RAID_8_PLURAL', value: 8 }, + { category: 'special', labelKey: 'RAIDS.LEVEL.RAID_9', pluralKey: 'RAIDS.LEVEL.RAID_9_PLURAL', value: 9 }, + { category: 'special', labelKey: 'RAIDS.LEVEL.RAID_10', pluralKey: 'RAIDS.LEVEL.RAID_10_PLURAL', value: 10 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_11', pluralKey: 'RAIDS.LEVEL.RAID_11_PLURAL', value: 11 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_12', pluralKey: 'RAIDS.LEVEL.RAID_12_PLURAL', value: 12 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_13', pluralKey: 'RAIDS.LEVEL.RAID_13_PLURAL', value: 13 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_14', pluralKey: 'RAIDS.LEVEL.RAID_14_PLURAL', value: 14 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_15', pluralKey: 'RAIDS.LEVEL.RAID_15_PLURAL', value: 15 }, + { category: 'superMega', labelKey: 'RAIDS.LEVEL.RAID_16', pluralKey: 'RAIDS.LEVEL.RAID_16_PLURAL', value: 16 }, + { category: 'superMega', labelKey: 'RAIDS.LEVEL.RAID_17', pluralKey: 'RAIDS.LEVEL.RAID_17_PLURAL', value: 17 }, + { category: 'coordinated', labelKey: 'RAIDS.LEVEL.RAID_18', pluralKey: 'RAIDS.LEVEL.RAID_18_PLURAL', value: 18 }, + { category: 'coordinated', labelKey: 'RAIDS.LEVEL.RAID_19', pluralKey: 'RAIDS.LEVEL.RAID_19_PLURAL', value: 19 }, ]; -export const SPECIAL_LEVELS: readonly LevelOption[] = [ - { badge: 6, category: 'special', labelKey: 'RAIDS.LEVEL.MEGA', value: 6 }, - { badge: 7, category: 'special', labelKey: 'RAIDS.LEVEL.ELITE', value: 7 }, -]; +/** Values 1-5: the visually star-rendered "N Star Raid" tier. */ +export const STAR_LEVELS: readonly LevelOption[] = KNOWN_LEVELS.filter(l => l.category === 'star'); + +/** Eggs only realistically use 1-5 in current Pokémon GO. */ +export const EGG_LEVELS: readonly LevelOption[] = STAR_LEVELS; + +/** Mega + Mega Legendary (6, 7). The primary "common but not star" tier. */ +export const MEGA_LEVELS: readonly LevelOption[] = KNOWN_LEVELS.filter(l => l.category === 'mega'); + +/** Levels surfaced in the primary chip row of the raid picker. */ +export const PRIMARY_RAID_LEVELS: readonly LevelOption[] = [...STAR_LEVELS, ...MEGA_LEVELS]; + +/** Levels relegated to the "More raid types…" overflow on the raid picker. */ +export const OVERFLOW_RAID_LEVELS: readonly LevelOption[] = KNOWN_LEVELS.filter(l => l.category !== 'star' && l.category !== 'mega'); export const ANY_LEVEL: LevelOption = { category: 'any', labelKey: 'RAIDS.LEVEL.ANY', + pluralKey: 'RAIDS.LEVEL.ANY', value: ANY_LEVEL_VALUE, }; -/** Build a display option for an arbitrary integer level. */ +/** Build a display option for an arbitrary integer level (unknown to the masterfile). */ export function makeCustomLevel(value: number): LevelOption { - return { badge: value, category: 'custom', labelKey: 'RAIDS.LEVEL.CUSTOM', value }; + return { category: 'custom', labelKey: 'RAIDS.LEVEL.CUSTOM', pluralKey: 'RAIDS.LEVEL.CUSTOM_PLURAL', value }; } /** - * Resolve a raw stored integer to its display option. Used by the dialog - * (to highlight the right chip) and by alarm cards (to render the right label). + * Resolve a raw stored integer to its display option. Returns the canonical + * known option if recognized, the ANY sentinel for 9000, or a custom-category + * option otherwise. */ export function resolveLevel(value: number): LevelOption { if (value === ANY_LEVEL_VALUE) return ANY_LEVEL; - return STANDARD_LEVELS.find(l => l.value === value) ?? SPECIAL_LEVELS.find(l => l.value === value) ?? makeCustomLevel(value); + return KNOWN_LEVELS.find(l => l.value === value) ?? makeCustomLevel(value); +} + +/** True if `value` is one of the masterfile-known levels (1-19) or the ANY sentinel. */ +export function isKnownLevel(value: number): boolean { + return value === ANY_LEVEL_VALUE || KNOWN_LEVELS.some(l => l.value === value); } /** - * True if `value` is one of the standard or special levels (i.e., something - * the selector renders as a built-in chip rather than a custom one). + * Backward-compat alias retained while callers migrate. Equivalent to `isKnownLevel`. + * @deprecated Use isKnownLevel. */ export function isBuiltInLevel(value: number): boolean { - return value === ANY_LEVEL_VALUE || STANDARD_LEVELS.some(l => l.value === value) || SPECIAL_LEVELS.some(l => l.value === value); + return isKnownLevel(value); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.spec.ts index 610615c6..b075f9e4 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/custom-level-store.service.spec.ts @@ -40,11 +40,12 @@ describe('CustomLevelStore', () => { expect(JSON.parse(localStorage.getItem(`${STORAGE_PREFIX}.egg`)!)).toEqual([99]); }); - it('rejects built-in levels (1-7 and 9000)', () => { + it('rejects all known levels (1-19 and 9000)', () => { const store = makeStore(); - for (const level of [1, 2, 3, 4, 5, 6, 7, 9000]) { + for (let level = 1; level <= 19; level++) { expect(store.add('raid', level)).toBe(false); } + expect(store.add('raid', 9000)).toBe(false); expect(store.values('raid')).toEqual([]); }); @@ -80,15 +81,17 @@ describe('CustomLevelStore', () => { }); it('loads persisted values on first access', () => { - localStorage.setItem(`${STORAGE_PREFIX}.raid`, JSON.stringify([8, 42, 99])); + // 22/42/99 are above the canonical 1-19 known range, so they survive the load filter + localStorage.setItem(`${STORAGE_PREFIX}.raid`, JSON.stringify([22, 42, 99])); const store = makeStore(); - expect(store.values('raid')).toEqual([8, 42, 99]); + expect(store.values('raid')).toEqual([22, 42, 99]); }); it('drops invalid entries from persisted state on load', () => { - localStorage.setItem(`${STORAGE_PREFIX}.raid`, JSON.stringify([8, 'oops', -1, 9000, 42])); + // 1-19 and 9000 are known levels — filtered out. Strings and negatives also dropped. + localStorage.setItem(`${STORAGE_PREFIX}.raid`, JSON.stringify([22, 'oops', -1, 9000, 42, 5])); const store = makeStore(); - expect(store.values('raid')).toEqual([8, 42]); + expect(store.values('raid')).toEqual([22, 42]); }); it('caps persisted entries at the LRU max per key', () => { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.spec.ts new file mode 100644 index 00000000..c3cf5c63 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.spec.ts @@ -0,0 +1,83 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; + +import { RaidLevelService } from './raid-level.service'; +import { KNOWN_LEVELS } from '../models/raid-level.models'; + +describe('RaidLevelService', () => { + let service: RaidLevelService; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(RaidLevelService); + http = TestBed.inject(HttpTestingController); + }); + + it('starts with the baked-in 19 levels before any fetch', () => { + expect(service.levels().length).toBe(KNOWN_LEVELS.length); + expect(service.levels()[0].value).toBe(1); + expect(service.loaded()).toBe(false); + }); + + it('replaces the list with the API payload on successful load', () => { + service.load(); + const req = http.expectOne(r => r.url.endsWith('/api/masterdata/raid-levels')); + req.flush([ + { name: '1 Star Raid', namePlural: '1 Star Raids', category: 'star', value: 1 }, + { name: 'Future Raid', namePlural: 'Future Raids', category: 'special', value: 20 }, + ]); + + expect(service.loaded()).toBe(true); + expect(service.levels().length).toBe(2); + expect(service.levels()[1].value).toBe(20); + expect(service.levels()[1].labelKey).toBe('RAIDS.LEVEL.RAID_20'); + expect(service.levels()[1].pluralKey).toBe('RAIDS.LEVEL.RAID_20_PLURAL'); + }); + + it('keeps the baked-in fallback when the API fails', () => { + service.load(); + const req = http.expectOne(r => r.url.endsWith('/api/masterdata/raid-levels')); + req.error(new ProgressEvent('network'), { status: 500, statusText: 'Server Error' }); + + expect(service.loaded()).toBe(true); + expect(service.levels().length).toBe(KNOWN_LEVELS.length); + }); + + it('keeps the baked-in fallback when the API returns an empty list', () => { + service.load(); + const req = http.expectOne(r => r.url.endsWith('/api/masterdata/raid-levels')); + req.flush([]); + + expect(service.loaded()).toBe(true); + expect(service.levels().length).toBe(KNOWN_LEVELS.length); + }); + + it('coerces unknown category strings to "custom"', () => { + service.load(); + const req = http.expectOne(r => r.url.endsWith('/api/masterdata/raid-levels')); + req.flush([{ name: 'Whatever', namePlural: 'Whatevers', category: 'invented-category', value: 99 }]); + + expect(service.levels()[0].category).toBe('custom'); + }); + + it('subsequent load() calls are no-ops once loaded', () => { + service.load(); + const req = http.expectOne(r => r.url.endsWith('/api/masterdata/raid-levels')); + req.flush([{ name: 'Legendary Raid', namePlural: 'Legendary Raids', category: 'star', value: 5 }]); + + service.load(); // second call should not issue another HTTP request + http.expectNone(r => r.url.endsWith('/api/masterdata/raid-levels')); + }); + + it('byValue map exposes a lookup keyed by value', () => { + expect(service.byValue().get(7)?.labelKey).toBe('RAIDS.LEVEL.RAID_7'); + expect(service.byValue().get(9000)).toBeUndefined(); + }); + + afterEach(() => http.verify()); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.ts new file mode 100644 index 00000000..e052c92d --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.ts @@ -0,0 +1,99 @@ +import { HttpClient } from '@angular/common/http'; +import { computed, inject, Injectable, signal } from '@angular/core'; +import { catchError, of, take } from 'rxjs'; + +import { environment } from '../../../environments/environment'; +import { KNOWN_LEVELS, LevelOption } from '../models/raid-level.models'; + +/** API payload shape from GET /api/masterdata/raid-levels. */ +interface RaidLevelInfoDto { + category: string; + name: string; + namePlural: string; + value: number; +} + +/** + * Fetches the canonical raid-level list from the API on app load and caches + * it in a signal. The hardcoded `KNOWN_LEVELS` constant acts as a fallback: + * if the network call fails, or before it resolves, callers still get the + * baked-in 19 levels. New levels appearing in the API response (raid_20+ + * once Niantic ships them) surface automatically without a frontend change. + */ +@Injectable({ providedIn: 'root' }) +export class RaidLevelService { + /** Hot signal of the current level list. Starts with the baked-in defaults. */ + private readonly _levels = signal(KNOWN_LEVELS); + + /** Returns true once the fetch has resolved (success OR failure). */ + private readonly _loaded = signal(false); + + private readonly http = inject(HttpClient); + + /** + * Lookup by value. Used by alarm cards/labels so the displayed name follows + * the live list when the API extends it. + */ + readonly byValue = computed(() => { + const map = new Map(); + for (const l of this._levels()) map.set(l.value, l); + return map; + }); + + /** Reactive read-only handle for components. */ + readonly levels = this._levels.asReadonly(); + + readonly loaded = this._loaded.asReadonly(); + + /** + * Kick off a one-time fetch. Safe to call multiple times — subsequent calls + * are no-ops while a request is in flight or after one has succeeded. + */ + load(): void { + if (this._loaded()) return; + this.http + .get(`${environment.apiUrl}/api/masterdata/raid-levels`) + .pipe( + take(1), + catchError(() => of(null)), + ) + .subscribe(dtos => { + if (dtos && dtos.length > 0) { + this._levels.set(dtos.map(toLevelOption)); + } + this._loaded.set(true); + }); + } +} + +/** + * Map the server-side DTO to the frontend `LevelOption`. We trust the integer + * + category from the server; i18n keys are derived deterministically from the + * value so translations stay in our locale files (the masterfile is English-only). + * The server's `name` / `namePlural` are exposed as backup strings that the + * label pipe can fall back to when an i18n key is missing. + */ +function toLevelOption(dto: RaidLevelInfoDto): LevelOption { + return { + category: normalizeCategory(dto.category), + labelKey: `RAIDS.LEVEL.RAID_${dto.value}`, + pluralKey: `RAIDS.LEVEL.RAID_${dto.value}_PLURAL`, + value: dto.value, + }; +} + +function normalizeCategory(c: string): LevelOption['category'] { + switch (c) { + case 'star': + case 'mega': + case 'special': + case 'shadow': + case 'superMega': + case 'coordinated': + case 'any': + case 'custom': + return c; + default: + return 'custom'; + } +} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html index 4d2d0448..5dd85d08 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-add-dialog.component.html @@ -39,19 +39,10 @@

{{ 'RAIDS.SPECIFIC_GYM' | translate }}

{{ 'RAIDS.RAID_LEVELS' | translate }}

- +

{{ 'RAIDS.EGG_LEVELS' | translate }}

- +
@@ -64,12 +55,7 @@

{{ 'RAIDS.EGG_LEVELS' | translate }}

}

{{ 'RAIDS.RAID_LEVEL_LABEL' | translate }}

- +
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts index 9257f5bc..ddc2338d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-edit-dialog.component.ts @@ -173,7 +173,7 @@ export class RaidEditDialogComponent { private formatLevel(level: number): string { const opt = resolveLevel(level); if (opt.category === 'custom') { - return this.i18n.instant(opt.labelKey) + ' ' + opt.badge; + return this.i18n.instant(opt.labelKey) + ' ' + opt.value; } return this.i18n.instant(opt.labelKey); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts index ac184dcc..e5e61f70 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/raids/raid-list.component.ts @@ -249,11 +249,12 @@ export class RaidListComponent implements OnInit { } getLevelStars(level: number): number[] { - // Stars are only meaningful for the standard Pokémon GO raid tiers (1-5) - // plus Mega (6) and Elite (7). For anything else (custom integers, - // the 9000 "Any" sentinel), the label already conveys the level and a - // stars row would either be wrong (e.g. 23 stars) or empty. - if (level < 1 || level > 7) return []; + // Stars are only meaningful for the literal "N Star Raid" tier (levels 1-5 + // per the WatWowMap masterfile). Levels 6+ (Mega, Mega Legendary, Ultra + // Beast, Elite, Primal, Shadow, Super Mega, Coordinated, customs) carry a + // semantic name that the title already conveys — a stars row would be + // misleading (e.g. "Elite Raid" is not a 9-star tier). + if (level < 1 || level > 5) return []; return Array.from({ length: level }, (_, i) => i); } @@ -267,7 +268,7 @@ export class RaidListComponent implements OnInit { getRaidLevelName(level: number): string { const opt = resolveLevel(level); if (opt.category === 'custom') { - return this.i18n.instant(opt.labelKey) + ' ' + opt.badge; + return this.i18n.instant(opt.labelKey) + ' ' + opt.value; } return this.i18n.instant(opt.labelKey); } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html index d072cbfd..00ebb613 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html @@ -4,7 +4,7 @@ [hideSingleSelectionIndicator]="!multiple" class="lv-chips" [attr.aria-label]="'RAIDS.RAID_LEVELS' | translate"> - @for (opt of standardLevels; track opt.value) { + @for (opt of primaryLevels(); track opt.value) { } - @for (opt of specialLevels; track opt.value) { - - {{ opt.labelKey | translate }} - - - - } @if (showAny) { } + @for (opt of selectedOverflowChips(); track opt.value) { + + {{ opt.labelKey | translate }} + + } @for (opt of palette(); track opt.value) { - {{ opt.badge }} + [attr.aria-label]="(opt.labelKey | translate) + ' ' + opt.value"> + {{ opt.value }} } + @if (overflowLevels().length > 0) { + + + @for (opt of overflowLevels(); track opt.value) { + + } + + } + @if (isAddClosed()) {