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/proxy.conf.json b/Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.conf.json new file mode 100644 index 00000000..c2de9aaf --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.conf.json @@ -0,0 +1,14 @@ +{ + "/api": { + "target": "http://localhost:5048", + "secure": false, + "changeOrigin": false, + "logLevel": "warn" + }, + "/auth": { + "target": "http://localhost:5048", + "secure": false, + "changeOrigin": false, + "logLevel": "warn" + } +} 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..6b0bfb3d --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.spec.ts @@ -0,0 +1,114 @@ +import { + ANY_LEVEL, + ANY_LEVEL_VALUE, + EGG_LEVELS, + isKnownLevel, + KNOWN_LEVELS, + makeCustomLevel, + MEGA_LEVELS, + OVERFLOW_RAID_LEVELS, + PRIMARY_RAID_LEVELS, + resolveLevel, + STAR_LEVELS, +} from './raid-level.models'; + +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('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('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('every entry uses the RAID_N label key', () => { + KNOWN_LEVELS.forEach(opt => { + expect(opt.labelKey).toBe(`RAIDS.LEVEL.RAID_${opt.value}`); + }); + }); + }); + + describe('derived groupings', () => { + it('STAR_LEVELS is 1-5', () => { + expect(STAR_LEVELS.map(l => l.value)).toEqual([1, 2, 3, 4, 5]); + }); + + 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('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('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('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); + }); + }); + + 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 new file mode 100644 index 00000000..972f093d --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/models/raid-level.models.ts @@ -0,0 +1,106 @@ +// Canonical raid level vocabulary, sourced from the WatWowMap masterfile: +// https://github.com/WatWowMap/Masterfile-Generator/blob/main/master-latest-poracle-v2.json +// +// 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 = 'star' | 'mega' | 'special' | 'shadow' | 'superMega' | 'coordinated' | 'any' | 'custom'; + +export interface LevelOption { + /** Coarse grouping for the selector overflow menu and category badges. */ + category: LevelCategory; + /** + * ngx-translate key for the human label. Intentionally short and excludes + * the "Raid" noun ("Mega Legendary", not "Mega Legendary Raid") so it + * composes cleanly into card titles like "All Mega Legendary Raids". + */ + labelKey: string; + /** Backend integer. PoracleNG accepts any positive integer. */ + value: number; +} + +/** PoracleNG's wildcard sentinel for raid matching — matches any raid level. */ +export const ANY_LEVEL_VALUE = 9000 as const; + +/** + * 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', value: 1 }, + { category: 'star', labelKey: 'RAIDS.LEVEL.RAID_2', value: 2 }, + { category: 'star', labelKey: 'RAIDS.LEVEL.RAID_3', value: 3 }, + { category: 'star', labelKey: 'RAIDS.LEVEL.RAID_4', value: 4 }, + { category: 'star', labelKey: 'RAIDS.LEVEL.RAID_5', value: 5 }, + { category: 'mega', labelKey: 'RAIDS.LEVEL.RAID_6', value: 6 }, + { category: 'mega', labelKey: 'RAIDS.LEVEL.RAID_7', value: 7 }, + { category: 'special', labelKey: 'RAIDS.LEVEL.RAID_8', value: 8 }, + { category: 'special', labelKey: 'RAIDS.LEVEL.RAID_9', value: 9 }, + { category: 'special', labelKey: 'RAIDS.LEVEL.RAID_10', value: 10 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_11', value: 11 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_12', value: 12 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_13', value: 13 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_14', value: 14 }, + { category: 'shadow', labelKey: 'RAIDS.LEVEL.RAID_15', value: 15 }, + { category: 'superMega', labelKey: 'RAIDS.LEVEL.RAID_16', value: 16 }, + { category: 'superMega', labelKey: 'RAIDS.LEVEL.RAID_17', value: 17 }, + { category: 'coordinated', labelKey: 'RAIDS.LEVEL.RAID_18', value: 18 }, + { category: 'coordinated', labelKey: 'RAIDS.LEVEL.RAID_19', value: 19 }, +]; + +/** 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', + value: ANY_LEVEL_VALUE, +}; + +/** Build a display option for an arbitrary integer level (unknown to the masterfile). */ +export function makeCustomLevel(value: number): LevelOption { + return { category: 'custom', labelKey: 'RAIDS.LEVEL.CUSTOM', value }; +} + +/** + * 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 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); +} + +/** + * Backward-compat alias retained while callers migrate. Equivalent to `isKnownLevel`. + * @deprecated Use isKnownLevel. + */ +export function isBuiltInLevel(value: number): boolean { + return isKnownLevel(value); +} 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..15df7785 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.spec.ts @@ -0,0 +1,82 @@ +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'); + }); + + 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..7b13c76b --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/raid-level.service.ts @@ -0,0 +1,98 @@ +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}`, + 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 a8bf2586..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,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..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,7 +1,6 @@ -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 { 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,11 @@ 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); + /** Stable array reference for the level selector input — prevents per-tick re-binding. */ + bossLevelArray = computed(() => [this.bossLevel()]); commonForm = this.fb.group({ clean: [false], @@ -71,14 +74,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 +91,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 +154,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 +188,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-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..1fe76f5a 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 @@ -21,6 +21,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; @@ -28,6 +29,7 @@ export interface RaidEditDialogData { } @Component({ + providers: [LevelLabelPipe], imports: [ ReactiveFormsModule, MatDialogModule, @@ -44,6 +46,7 @@ export interface RaidEditDialogData { TemplateSelectorComponent, DeliveryPreviewComponent, GymPickerComponent, + LevelLabelPipe, ], selector: 'app-raid-edit-dialog', standalone: true, @@ -55,6 +58,7 @@ export class RaidEditDialogComponent { private readonly fb = inject(FormBuilder); private readonly i18n = inject(I18nService); private readonly iconService = inject(IconService); + private readonly levelLabelPipe = inject(LevelLabelPipe); private readonly raidService = inject(RaidService); private readonly snackBar = inject(MatSnackBar); readonly data = inject(MAT_DIALOG_DATA); @@ -86,13 +90,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.levelLabelPipe.transform(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.levelLabelPipe.transform(raid.level) + ' ' + this.i18n.instant('RAIDS.RAID_SUFFIX'); } onDistanceModeChange(): void { 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 76ee2933..e714f9a5 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,16 +14,19 @@ 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'; import { MasterDataService } from '../../core/services/masterdata.service'; +import { RaidLevelService } from '../../core/services/raid-level.service'; import { RaidService } from '../../core/services/raid.service'; import { ScannerService } from '../../core/services/scanner.service'; 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, @@ -38,6 +41,7 @@ import { DistanceDialogComponent } from '../../shared/components/distance-dialog MatTabsModule, TranslateModule, AlarmInfoComponent, + LevelLabelPipe, ], selector: 'app-raid-list', standalone: true, @@ -51,6 +55,7 @@ export class RaidListComponent implements OnInit { private readonly i18n = inject(I18nService); private readonly iconService = inject(IconService); private readonly masterData = inject(MasterDataService); + private readonly raidLevelService = inject(RaidLevelService); private readonly raidService = inject(RaidService); private readonly scannerService = inject(ScannerService); private readonly snackBar = inject(MatSnackBar); @@ -246,7 +251,12 @@ export class RaidListComponent implements OnInit { } getLevelStars(level: number): number[] { - if (level === 9000 || level > 100) 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); } @@ -258,14 +268,16 @@ 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; + // Prefer the live raid-level list — when the API extends the canonical + // set (e.g. raid_20 ships in the masterfile), cards stay in sync with the + // selector dialog. Falls back to the baked-in resolveLevel + custom shape + // when the API hasn't loaded yet or the level is genuinely unknown. + const liveOpt = this.raidLevelService.byValue().get(level); + const opt = liveOpt ?? resolveLevel(level); + if (opt.category === 'custom') { + return this.i18n.instant(opt.labelKey) + ' ' + opt.value; } + return this.i18n.instant(opt.labelKey); } getRaidTitle(raid: Raid): string { @@ -322,6 +334,7 @@ export class RaidListComponent implements OnInit { ngOnInit(): void { this.masterData.loadData().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(); + this.raidLevelService.load(); this.loadData(); } 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..00ebb613 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.html @@ -0,0 +1,102 @@ +
+ + @for (opt of primaryLevels(); track opt.value) { + + {{ opt.labelKey | translate }} + + } + @if (showAny) { + + {{ anyLevel.labelKey | translate }} + + } + @for (opt of selectedOverflowChips(); track opt.value) { + + {{ opt.labelKey | translate }} + + } + @for (opt of palette(); track opt.value) { + + {{ opt.value }} + + + } + + + @if (overflowLevels().length > 0) { + + + @for (opt of overflowLevels(); track opt.value) { + + } + + } + + @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 new file mode 100644 index 00000000..c6e0184d --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.scss @@ -0,0 +1,189 @@ +:host { + display: block; + background: color-mix(in srgb, var(--mat-sys-on-surface) 5%, transparent); + border-radius: 8px; + padding: 10px 12px; +} + +.lv { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + row-gap: 8px; +} + +.lv-chips { + display: contents; + + ::ng-deep .mdc-evolution-chip-set__chips { + display: contents; + } +} + +.lv-sep { + margin: 0 4px; + opacity: 0.55; +} + +.lv-num { + font-variant-numeric: tabular-nums; +} + +mat-chip-option { + transition: + box-shadow 180ms ease, + transform 180ms ease; + + &.flash { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--mat-sys-primary) 38%, transparent); + } + + // 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; + } +} + +.lv-custom .mat-icon { + font-size: 18px; + width: 18px; + height: 18px; +} + +.lv-add { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 36px; + height: 32px; + 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); + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.lv-more { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 32px; + padding: 0 12px; + border: 1px solid color-mix(in srgb, var(--mat-sys-outline) 70%, transparent); + border-radius: 16px; + background: transparent; + color: var(--mat-sys-on-surface-variant); + cursor: pointer; + font-size: 0.82rem; + 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); + } + + &.has-active { + border-color: var(--mat-sys-primary); + color: var(--mat-sys-primary); + background: color-mix(in srgb, var(--mat-sys-primary) 6%, transparent); + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.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; + } + } +} + +.lv-add-invalid { + border-color: var(--mat-sys-error); + animation: lv-shake 200ms ease; +} + +@keyframes lv-shake { + 0%, + 100% { + transform: translateX(0); + } + 25% { + transform: translateX(-2px); + } + 75% { + transform: translateX(2px); + } +} + +.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 new file mode 100644 index 00000000..026f3526 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.spec.ts @@ -0,0 +1,198 @@ +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +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'; + +describe('LevelSelectorComponent', () => { + let fixture: ComponentFixture; + let component: LevelSelectorComponent; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting(), provideTranslateService()], + imports: [LevelSelectorComponent, NoopAnimationsModule], + }); + fixture = TestBed.createComponent(LevelSelectorComponent); + component = fixture.componentInstance; + component.pickerType = 'raid'; + }); + + // Type-narrowing helper for protected members exercised in tests. + function withInternals(c: LevelSelectorComponent) { + return c as unknown as { + toggle(v: number): void; + removeCustom(v: number, e: MouseEvent): void; + openAddInput(): void; + onAddKeydown(e: KeyboardEvent): void; + commitAddInput(): void; + addInputValue: { set(v: string): void; (): string }; + addInputError(): string | null; + isAddClosed(): boolean; + palette(): { value: number }[]; + primaryLevels(): { value: number }[]; + overflowLevels(): { value: number }[]; + }; + } + + it('renders without error', () => { + component.value = [1, 7]; + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('seeds the local palette from a custom value on incoming `value`', () => { + component.value = [42, 1]; + expect( + withInternals(component) + .palette() + .map(o => o.value), + ).toEqual([42]); + }); + + it('does NOT persist the palette between component instances', () => { + component.value = [42]; + // Fresh component instance simulates dialog close+reopen + const fresh = TestBed.createComponent(LevelSelectorComponent).componentInstance; + fresh.pickerType = 'raid'; + expect(withInternals(fresh).palette()).toEqual([]); + }); + + it('toggle adds/removes in raid (multi-select) mode', () => { + const emitted: number[][] = []; + component.value = []; + component.valueChange.subscribe(v => emitted.push(v)); + + withInternals(component).toggle(3); + withInternals(component).toggle(5); + expect(emitted).toEqual([[3], [3, 5]]); + + withInternals(component).toggle(3); + expect(emitted[emitted.length - 1]).toEqual([5]); + }); + + it('boss picker is single-select', () => { + component.pickerType = 'boss'; + component.value = [3]; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + withInternals(component).toggle(5); + expect(emitted[0]).toEqual([5]); + }); + + it('boss picker clears when the active chip is toggled again', () => { + component.pickerType = 'boss'; + component.value = [3]; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + withInternals(component).toggle(3); + expect(emitted[0]).toEqual([]); + }); + + it('commitAddInput rejects 0 and negatives via inline error', () => { + const c = withInternals(component); + 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 = withInternals(component); + c.addInputValue.set('7.5'); + c.commitAddInput(); + expect(c.addInputError()).toBe('RAIDS.LEVEL.INVALID'); + }); + + it('commitAddInput snaps 9000 to the ANY chip on raid picker', () => { + component.value = []; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + const c = withInternals(component); + c.addInputValue.set('9000'); + c.commitAddInput(); + + expect(emitted[emitted.length - 1]).toEqual([ANY_LEVEL_VALUE]); + expect(c.palette().map(o => o.value)).not.toContain(ANY_LEVEL_VALUE); + }); + + it('commitAddInput selects an existing known level instead of adding a duplicate', () => { + component.value = []; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + const c = withInternals(component); + c.addInputValue.set('5'); + c.commitAddInput(); + + expect(emitted[emitted.length - 1]).toEqual([5]); + expect(c.palette().map(o => o.value)).not.toContain(5); + }); + + it('commitAddInput adds a new custom into the local palette and selects it', () => { + component.value = []; + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + const c = withInternals(component); + c.addInputValue.set('42'); + c.commitAddInput(); + + expect(c.palette().map(o => o.value)).toContain(42); + expect(emitted[emitted.length - 1]).toEqual([42]); + }); + + it('removeCustom removes from the local palette and the selection', () => { + component.value = [42]; + const c = withInternals(component); + expect(c.palette().map(o => o.value)).toContain(42); + + const emitted: number[][] = []; + component.valueChange.subscribe(v => emitted.push(v)); + + c.removeCustom(42, new MouseEvent('click')); + + expect(c.palette().map(o => o.value)).not.toContain(42); + expect(emitted[emitted.length - 1]).toEqual([]); + }); + + it('Escape cancels the add input', () => { + const c = withInternals(component); + c.openAddInput(); + c.addInputValue.set('99'); + c.onAddKeydown(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(c.isAddClosed()).toBe(true); + }); + + describe('pickerType-driven primary/overflow split', () => { + it('raid picker shows star + mega in primary, special/shadow/etc. in overflow', () => { + component.pickerType = 'raid'; + const c = withInternals(component); + expect(c.primaryLevels().map(l => l.value)).toEqual([1, 2, 3, 4, 5, 6, 7]); + expect(c.overflowLevels().map(l => l.value)).toEqual([8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); + }); + + it('egg picker shows star-only in primary and empty overflow', () => { + component.pickerType = 'egg'; + const c = withInternals(component); + expect(c.primaryLevels().map(l => l.value)).toEqual([1, 2, 3, 4, 5]); + expect(c.overflowLevels()).toEqual([]); + }); + + it('boss picker mirrors raid for chip composition', () => { + component.pickerType = 'boss'; + const c = withInternals(component); + expect(c.primaryLevels().map(l => l.value)).toEqual([1, 2, 3, 4, 5, 6, 7]); + }); + }); +}); 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..5d8ca14c --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/level-selector/level-selector.component.ts @@ -0,0 +1,255 @@ +import { Component, computed, DestroyRef, ElementRef, EventEmitter, inject, Input, OnInit, Output, signal, ViewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatButtonModule } from '@angular/material/button'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatIconModule } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; + +import { ANY_LEVEL, ANY_LEVEL_VALUE, isKnownLevel, LevelOption, makeCustomLevel } from '../../../core/models/raid-level.models'; +import { RaidLevelService } from '../../../core/services/raid-level.service'; + +/** + * Chip-based selector for raid/egg/boss levels. Standard star tiers + Mega + * always render as a primary row; the additional Pokémon GO raid types + * (Ultra Beast, Elite, Primal, Shadow, Super Mega, Coordinated) live in a + * "More raid types…" overflow menu so the dialog stays compact. + * + * Custom integers typed via the `+ Add` chip live in the component's local + * state for the dialog session and are seeded from whatever was passed in + * via `[value]`. They are NOT persisted across dialog opens — close the + * dialog and the typed-but-not-saved chips are gone. + * + * `pickerType` determines what the component shows and how it behaves: + * - `raid` : multi-select, primary + overflow, Any chip + * - `egg` : multi-select, primary only (no overflow), no Any + * - `boss` : single-select, primary + overflow, Any chip + */ +@Component({ + imports: [MatButtonModule, MatChipsModule, MatIconModule, MatMenuModule, MatSnackBarModule, MatTooltipModule, TranslateModule], + selector: 'app-level-selector', + standalone: true, + styleUrl: './level-selector.component.scss', + templateUrl: './level-selector.component.html', +}) +export class LevelSelectorComponent implements OnInit { + /** Explicit two-state machine for the add affordance. */ + private readonly addMode = signal<'closed' | 'open'>('closed'); + /** + * Custom palette — chips for integers not in the canonical 1-19 list. + * Ephemeral: lives only for the lifetime of this component instance. + * Closing the dialog destroys the component and the palette with it. + */ + private readonly customPalette = signal([]); + private readonly destroyRef = inject(DestroyRef); + private readonly raidLevelService = inject(RaidLevelService); + + private readonly snackBar = inject(MatSnackBar); + private readonly translate = inject(TranslateService); + @ViewChild('addInput') addInput?: ElementRef; + protected readonly addInputError = signal(null); + protected readonly addInputValue = signal(''); + protected readonly anyLevel = ANY_LEVEL; + + protected readonly flashValue = signal(null); + + /** Which kind of picker this instance is. Drives layout + behavior. */ + @Input({ required: true }) pickerType!: 'raid' | 'egg' | 'boss'; + /** Levels relegated to the "More raid types…" overflow menu. Empty for eggs. */ + protected readonly overflowLevels = computed(() => { + if (this.pickerType === 'egg') return []; + return this.raidLevelService.levels().filter(l => l.category !== 'star' && l.category !== 'mega'); + }); + + /** Internal selection state, mirrored from `[value]` input. */ + protected readonly selected = signal([]); + + protected readonly hasOverflowSelected = computed(() => { + const sel = new Set(this.selected()); + return this.overflowLevels().some(l => sel.has(l.value)); + }); + + protected isAddClosed = () => this.addMode() === 'closed'; + protected isAddOpen = () => this.addMode() === 'open'; + + protected readonly palette = computed(() => this.customPalette().map(makeCustomLevel)); + /** Levels shown in the primary chip row. Driven by pickerType + live raid-level list. */ + protected readonly primaryLevels = computed(() => { + const all = this.raidLevelService.levels(); + if (this.pickerType === 'egg') { + return all.filter(l => l.category === 'star'); + } + return all.filter(l => l.category === 'star' || l.category === 'mega'); + }); + + protected readonly selectedOverflowChips = computed(() => { + const sel = new Set(this.selected()); + return this.overflowLevels().filter(l => sel.has(l.value)); + }); + + @Output() readonly valueChange = new EventEmitter(); + + protected get multiple(): boolean { + return this.pickerType !== 'boss'; + } + + protected get showAny(): boolean { + return this.pickerType !== 'egg'; + } + + @Input() + set value(next: number[] | null | undefined) { + const safe = (next ?? []).filter(v => Number.isInteger(v) && v >= 1); + this.selected.set(safe); + // Seed the local palette from any custom values on the incoming alarm so + // the chips show pre-selected. Built-in levels (1-19) already render as + // primary/overflow chips; only the truly unknown integers need a custom chip. + const customs = safe.filter(v => !isKnownLevel(v)); + if (customs.length > 0) { + this.customPalette.update(current => { + const seen = new Set(current); + const next2 = [...current]; + for (const v of customs) { + if (!seen.has(v)) { + seen.add(v); + next2.push(v); + } + } + return next2; + }); + } + } + + protected cancelAddInput(): void { + this.closeAdd(); + } + + protected commitAddInput(): void { + const raw = this.addInputValue().trim(); + if (raw === '') { + this.closeAdd(); + 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; + } + + // Snap 9000 to the dedicated Any 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 a known level — just select that chip. + if (isKnownLevel(parsed)) { + this.closeAdd(); + if (!this.isSelected(parsed)) this.toggle(parsed); + this.flash(parsed); + return; + } + + // Duplicate of an existing custom chip — flash + select. + if (this.customPalette().includes(parsed)) { + this.addInputError.set(this.translate.instant('RAIDS.LEVEL.DUPLICATE', { value: parsed })); + this.flash(parsed); + if (!this.isSelected(parsed)) this.toggle(parsed); + return; + } + + this.customPalette.update(current => [...current, parsed]); + this.closeAdd(); + if (!this.isSelected(parsed)) this.toggle(parsed); + } + + protected isSelected(value: number): boolean { + return this.selected().includes(value); + } + + ngOnInit(): void { + this.raidLevelService.load(); + } + + 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(); + this.commitAddInput(); + } else if (event.key === 'Escape') { + event.preventDefault(); + this.cancelAddInput(); + } + } + + protected openAddInput(): void { + this.addMode.set('open'); + this.addInputValue.set(''); + this.addInputError.set(null); + queueMicrotask(() => this.addInput?.nativeElement.focus()); + } + + protected removeCustom(value: number, event: MouseEvent): void { + event.stopPropagation(); + const wasSelected = this.selected().includes(value); + this.customPalette.update(current => current.filter(v => v !== 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() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.customPalette.update(current => (current.includes(value) ? current : [...current, value])); + if (wasSelected) { + const next = [...this.selected(), value]; + this.selected.set(next); + this.valueChange.emit(next); + } + }); + } + + 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 { + next = current.includes(value) && current.length === 1 ? [] : [value]; + } + this.selected.set(next); + this.valueChange.emit(next); + } + + protected toggleFromOverflow(value: number): void { + this.toggle(value); + this.flash(value); + } + + private closeAdd(): void { + this.addMode.set('closed'); + this.addInputValue.set(''); + this.addInputError.set(null); + } + + 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..c7d2d858 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.spec.ts @@ -0,0 +1,57 @@ +import { TestBed } from '@angular/core/testing'; + +import { LevelLabelPipe } from './level-label.pipe'; +import { I18nService } from '../../core/services/i18n.service'; + +/** + * Mirror of ngx-translate's "key not found" behavior: if a key has no entry, + * the translated string equals the key. Tests configure `knownKeys` to control + * the boundary between "translated" and "missing". + */ +class FakeI18n { + knownKeys = new Set(); + instant(key: string): string { + return this.knownKeys.has(key) ? `TR(${key})` : key; + } +} + +describe('LevelLabelPipe', () => { + let pipe: LevelLabelPipe; + let i18n: FakeI18n; + + beforeEach(() => { + TestBed.resetTestingModule(); + i18n = new FakeI18n(); + // Seed with every key the canonical 19 levels rely on, plus ANY + CUSTOM. + for (let v = 1; v <= 19; v++) i18n.knownKeys.add(`RAIDS.LEVEL.RAID_${v}`); + i18n.knownKeys.add('RAIDS.LEVEL.ANY'); + i18n.knownKeys.add('RAIDS.LEVEL.CUSTOM'); + TestBed.configureTestingModule({ + providers: [{ provide: I18nService, useValue: i18n }, LevelLabelPipe], + }); + pipe = TestBed.inject(LevelLabelPipe); + }); + + it('translates every known level via its RAID_N key', () => { + for (let v = 1; v <= 19; v++) { + expect(pipe.transform(v)).toBe(`TR(RAIDS.LEVEL.RAID_${v})`); + } + }); + + it('translates 9000 as ANY', () => { + expect(pipe.transform(9000)).toBe('TR(RAIDS.LEVEL.ANY)'); + }); + + it('formats custom levels with the CUSTOM key prefix + integer', () => { + expect(pipe.transform(42)).toBe('TR(RAIDS.LEVEL.CUSTOM) 42'); + expect(pipe.transform(20)).toBe('TR(RAIDS.LEVEL.CUSTOM) 20'); + }); + + it('falls back to the CUSTOM label when a known-level key is missing from i18n', () => { + // Simulate the future case where the canonical list grows to 20 but the + // locale file hasn't been updated yet — the model would surface RAID_20 + // as a labelKey but the i18n returns the bare key. + i18n.knownKeys.delete('RAIDS.LEVEL.RAID_7'); + expect(pipe.transform(7)).toBe('TR(RAIDS.LEVEL.CUSTOM) 7'); + }); +}); 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..12f46325 --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/pipes/level-label.pipe.ts @@ -0,0 +1,40 @@ +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. + * + * - Levels 1-19 → masterfile names ("1 Star", "Mega Legendary", "Elite", …) + * - 9000 (wildcard sentinel) → "Any" + * - Anything else → "Level {n}" (custom) + * + * Graceful degradation: if a translation key is missing for the level (e.g. a + * future raid_20 ships before the i18n files are updated), ngx-translate + * returns the literal key string. We detect that case and fall back to the + * generic "Level {n}" custom format so users see a number rather than + * "RAIDS.LEVEL.RAID_20". + */ +@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.value; + } + const translated = this.i18n.instant(opt.labelKey); + // ngx-translate returns the key unchanged when the key isn't found — + // detect that and fall back to a useful generic label rather than leaking + // the raw translation key into the UI. + if (translated === opt.labelKey) { + return this.i18n.instant('RAIDS.LEVEL.CUSTOM') + ' ' + value; + } + return translated; + } +} 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..2e5eaf1a 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,47 @@ "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": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Level", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "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}}", + "REMOVED": "Removed level {{value}}", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Quest-alarmer", @@ -1296,6 +1336,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 2d4fb0cf..3c070780 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,47 @@ "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": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Level", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "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}}", + "REMOVED": "Removed level {{value}}", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Quest-Alarme", @@ -1296,6 +1336,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 c8d7f005..e133235a 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,47 @@ "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": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Level", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "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}}", + "REMOVED": "Removed level {{value}}", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Quest Alarms", @@ -1296,6 +1336,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 4a2f65b4..f9642ca1 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,47 @@ "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": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Level", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "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}}", + "REMOVED": "Removed level {{value}}", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Alarmas de Misión", @@ -1296,6 +1336,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 b39de4a9..0412c755 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,47 @@ "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": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Level", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "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}}", + "REMOVED": "Removed level {{value}}", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Alarmes Quête", @@ -1296,6 +1336,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 1e58d1fb..d2a5dfbe 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,47 @@ "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": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Level", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "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}}", + "REMOVED": "Removed level {{value}}", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Allarmi Missioni", @@ -1296,6 +1336,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 7687dfb6..e8461ea4 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,47 @@ "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": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Level", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "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}}", + "REMOVED": "Removed level {{value}}", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Quest Alarmen", @@ -1296,6 +1336,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 fc6c624b..cb546515 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,47 @@ "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": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Level", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "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}}", + "REMOVED": "Removed level {{value}}", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Alarmy zadań", @@ -1296,6 +1336,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 fe575a29..27332b53 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,47 @@ "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": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Level", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "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}}", + "REMOVED": "Removed level {{value}}", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Alarmes de Quest", @@ -1296,6 +1336,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 47316704..b25f6b0c 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,47 @@ "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": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Level", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "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}}", + "REMOVED": "Removed level {{value}}", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Alarmes de Missões", @@ -1296,6 +1336,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 33defcee..7c2e03be 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,47 @@ "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": { + "RAID_1": "1 Star", + "RAID_2": "2 Star", + "RAID_3": "3 Star", + "RAID_4": "4 Star", + "RAID_5": "Legendary", + "RAID_6": "Mega", + "RAID_7": "Mega Legendary", + "RAID_8": "Ultra Beast", + "RAID_9": "Elite", + "RAID_10": "Primal", + "RAID_11": "1 Shadow", + "RAID_12": "2 Shadow", + "RAID_13": "3 Shadow", + "RAID_14": "4 Shadow", + "RAID_15": "5 Shadow", + "RAID_16": "4 Super Mega", + "RAID_17": "5 Super Mega", + "RAID_18": "Coordinated 1", + "RAID_19": "Coordinated 2", + "ANY": "Any", + "CUSTOM": "Level", + "CATEGORY_STAR": "Star tiers", + "CATEGORY_MEGA": "Mega", + "CATEGORY_SPECIAL": "Special", + "CATEGORY_SHADOW": "Shadow", + "CATEGORY_SUPER_MEGA": "Super Mega", + "CATEGORY_COORDINATED": "Coordinated", + "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}}", + "REMOVED": "Removed level {{value}}", + "MORE_RAID_TYPES": "More raid types…" + } }, "QUESTS": { "PAGE_TITLE": "Quest-larm", @@ -1296,6 +1336,7 @@ "EDIT": "Redigera", "ADD": "Lägg till", "OK": "OK", + "UNDO": "Undo", "CONFIRM": "Bekräfta", "DELETE_ALL": "Radera alla", "CLOSE": "Stäng", diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/environments/environment.development.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/environments/environment.development.ts index 2da6115c..d0a4da50 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/environments/environment.development.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/environments/environment.development.ts @@ -1,4 +1,8 @@ +// Dev server runs Angular at :4200 with a proxy (see proxy.conf.json) that +// forwards /api/* and /auth/* to the local API on :8082. Empty `apiUrl` means +// all HTTP calls become same-origin from the browser's view — identical to +// the production single-port setup. export const environment = { - apiUrl: `http://${window.location.hostname}:5048`, + apiUrl: '', production: false, }; diff --git a/CHANGELOG.md b/CHANGELOG.md index 78442bf7..1c97fe4f 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 and Pokémon GO actually defines 19 named raid types in the WatWowMap masterfile. Replaced the three sites with a new `` shared component (Material 3 chip listbox with `+ Add` and a "More raid types…" overflow menu) backed by a new `RaidLevelService` that fetches the canonical list from `GET /api/masterdata/raid-levels` on app load, with a baked-in fallback so the UI always works offline. Correctness: level 7 is **Mega Legendary Raid** (not "Elite" as the prior UI labeled it); Elite Raid is at level 9. All 19 masterfile-defined raid types are now surfaced (1–5 Star, Mega, Mega Legendary, Ultra Beast, Elite, Primal, 1–5 Shadow, 4–5 Super Mega, Coordinated 1–2). New API endpoint: `GET /api/masterdata/raid-levels` returns the canonical list with categories and English singular/plural names; future work can swap the baked-in source for a live WatWowMap masterfile fetch without changing the wire contract. Per-type custom palette (`raid`/`egg`/`boss`) backed by separate localStorage slots so adding a custom level on one picker doesn't leak into the others. Egg picker only surfaces star tiers (1–5) since Pokémon GO has no Mega/Shadow/Primal/Coordinated eggs; raid + boss pickers get the full list. Boss tab now defaults to the canonical `9000` "any" sentinel (was `0`). Server-side `[Range(0, 10)]` on `RaidCreate.Level`, `RaidUpdate.Level`, `EggCreate.Level`, `EggUpdate.Level` was rejecting custom integers (8+) and the 9000 wildcard with HTTP 400 before they could reach PoracleNG — relaxed to `[Range(0, int.MaxValue)]` matching PoracleNG's actual range. Card star icons capped to the literal 1–5 "N Star Raid" tier (was 1–7, rendering ~23 stars for custom-level alarms). Edit dialog adopts the same label resolver as the cards (an alarm at level 7 reads "Mega Legendary Raid" in both card and edit dialog, not "Level 7"). New i18n keys `RAIDS.LEVEL.RAID_1`–`RAID_19` (singular + `_PLURAL` variants) added to all 11 locales with English placeholders; volunteers can localize in a follow-up per discussion #211. Existing alarms saved with `level: 0` continue to render and edit fine; new alarms use the canonical sentinels. - **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. diff --git a/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IRaidLevelService.cs b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IRaidLevelService.cs new file mode 100644 index 00000000..f93b5ba5 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Abstractions/Services/IRaidLevelService.cs @@ -0,0 +1,18 @@ +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Abstractions.Services; + +/// +/// Source of the 19 (currently) known Pokémon GO raid levels, sourced from the +/// WatWowMap masterfile. Served as a structured list to the frontend so the +/// level selector and alarm cards stay aligned with the canonical vocabulary +/// even as new raid types ship. +/// +/// Implementations should cache the result and fall back to a baked-in list +/// when the upstream masterfile is unreachable. +/// +public interface IRaidLevelService +{ + /// Returns the canonical raid-level list. Never throws; falls back to defaults on error. + Task> GetAllAsync(); +} 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/RaidLevelInfo.cs b/Core/Pgan.PoracleWebNet.Core.Models/RaidLevelInfo.cs new file mode 100644 index 00000000..43cc3ed3 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Models/RaidLevelInfo.cs @@ -0,0 +1,25 @@ +namespace Pgan.PoracleWebNet.Core.Models; + +/// +/// Canonical raid-level metadata served to the frontend so the level selector +/// and alarm cards can render the masterfile vocabulary. +/// +/// Source of truth: WatWowMap masterfile (raid_{N} / raid_{N}_plural keys). +/// +public class RaidLevelInfo +{ + /// Backend integer matched against PoracleNG webhook level. 1-19 currently named. + public int Value + { + get; set; + } + + /// Coarse grouping: star, mega, special, shadow, superMega, coordinated. + public string Category { get; set; } = string.Empty; + + /// Singular English name from the masterfile, e.g. "1 Star Raid", "Mega Legendary Raid". + public string Name { get; set; } = string.Empty; + + /// Plural English name from the masterfile, e.g. "1 Star Raids". + public string NamePlural { get; set; } = string.Empty; +} 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; diff --git a/Core/Pgan.PoracleWebNet.Core.Services/RaidLevelService.cs b/Core/Pgan.PoracleWebNet.Core.Services/RaidLevelService.cs new file mode 100644 index 00000000..db6999d7 --- /dev/null +++ b/Core/Pgan.PoracleWebNet.Core.Services/RaidLevelService.cs @@ -0,0 +1,64 @@ +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Core.Services; + +/// +/// Returns the canonical raid-level list. Currently sources from a baked-in +/// snapshot of the WatWowMap masterfile; a future enhancement can refresh +/// this list from the live masterfile URL (see comments in `GetAllAsync`) +/// and persist to disk under DATA_DIR. +/// +/// The baked-in list IS the fallback when an upstream fetch fails. Frontend +/// callers must never assume this list is complete — they always allow +/// arbitrary integers via the custom-level input. +/// +public class RaidLevelService : IRaidLevelService +{ + // Mirror of the masterfile's raid_{N} keys as of writing, with the "Raid" + // noun stripped so callers can compose phrases like "All Mega Legendary + // Raids" without doubling the word. The masterfile keeps the long form + // (e.g. "Mega Legendary Raid") — see Name vs NamePlural fields for the + // intended use: + // - Name → modifier form, used inline ("Mega Legendary") + // - NamePlural → full phrase, used standalone ("Mega Legendary Raids") + // Source: https://github.com/WatWowMap/Masterfile-Generator (master-latest-poracle-v2.json) + // When upstream adds raid_20+, append entries here and bump the i18n keys in + // RAIDS.LEVEL.* — or wire up the live fetch documented below. + private static readonly IReadOnlyList BakedIn = new RaidLevelInfo[] + { + new() { Value = 1, Category = "star", Name = "1 Star", NamePlural = "1 Star Raids" }, + new() { Value = 2, Category = "star", Name = "2 Star", NamePlural = "2 Star Raids" }, + new() { Value = 3, Category = "star", Name = "3 Star", NamePlural = "3 Star Raids" }, + new() { Value = 4, Category = "star", Name = "4 Star", NamePlural = "4 Star Raids" }, + new() { Value = 5, Category = "star", Name = "Legendary", NamePlural = "Legendary Raids" }, + new() { Value = 6, Category = "mega", Name = "Mega", NamePlural = "Mega Raids" }, + new() { Value = 7, Category = "mega", Name = "Mega Legendary", NamePlural = "Mega Legendary Raids" }, + new() { Value = 8, Category = "special", Name = "Ultra Beast", NamePlural = "Ultra Beast Raids" }, + new() { Value = 9, Category = "special", Name = "Elite", NamePlural = "Elite Raids" }, + new() { Value = 10, Category = "special", Name = "Primal", NamePlural = "Primal Raids" }, + new() { Value = 11, Category = "shadow", Name = "1 Shadow", NamePlural = "1 Shadow Raids" }, + new() { Value = 12, Category = "shadow", Name = "2 Shadow", NamePlural = "2 Shadow Raids" }, + new() { Value = 13, Category = "shadow", Name = "3 Shadow", NamePlural = "3 Shadow Raids" }, + new() { Value = 14, Category = "shadow", Name = "4 Shadow", NamePlural = "4 Shadow Raids" }, + new() { Value = 15, Category = "shadow", Name = "5 Shadow", NamePlural = "5 Shadow Raids" }, + new() { Value = 16, Category = "superMega", Name = "4 Super Mega", NamePlural = "4 Super Mega Raids" }, + new() { Value = 17, Category = "superMega", Name = "5 Super Mega", NamePlural = "5 Super Mega Raids" }, + new() { Value = 18, Category = "coordinated", Name = "Coordinated 1", NamePlural = "Coordinated 1 Raids" }, + new() { Value = 19, Category = "coordinated", Name = "Coordinated 2", NamePlural = "Coordinated 2 Raids" }, + }; + + /// + public Task> GetAllAsync() + { + // TODO: fetch + cache from the WatWowMap masterfile URL so new raid types + // appear without a code change. Recommended approach: + // 1. HttpClient GET https://raw.githubusercontent.com/WatWowMap/Masterfile-Generator/main/master-latest-poracle-v2.json + // 2. Parse top-level keys matching ^raid_\d+$ + matching `_plural` siblings + // 3. Persist parsed structure to `${DATA_DIR}/raid-levels.json` + // 4. Refresh every 24h via a hosted service + // 5. Fall back to BakedIn on any failure + // The frontend already tolerates the list being incomplete (custom-level input). + return Task.FromResult(BakedIn); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerRaidLevelsTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerRaidLevelsTests.cs new file mode 100644 index 00000000..6951744b --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerRaidLevelsTests.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Mvc; +using Moq; +using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Core.Abstractions.Services; +using Pgan.PoracleWebNet.Core.Models; + +namespace Pgan.PoracleWebNet.Tests.Controllers; + +/// +/// Coverage for the GET /api/masterdata/raid-levels endpoint added for #259. +/// +public class MasterDataControllerRaidLevelsTests +{ + private static readonly IReadOnlyList SampleLevels = + [ + new() { Value = 1, Category = "star", Name = "1 Star Raid", NamePlural = "1 Star Raids" }, + new() { Value = 9, Category = "special", Name = "Elite Raid", NamePlural = "Elite Raids" }, + ]; + + private static MasterDataController CreateController(IRaidLevelService raidLevelService) => new( + new Mock().Object, + new Mock().Object, + raidLevelService); + + [Fact] + public async Task GetRaidLevelsReturnsOkWithServicePayload() + { + var svc = new Mock(); + svc.Setup(s => s.GetAllAsync()).ReturnsAsync(SampleLevels); + var sut = CreateController(svc.Object); + + var result = await sut.GetRaidLevels(); + + var ok = Assert.IsType(result); + var payload = Assert.IsType>(ok.Value, exactMatch: false); + Assert.Equal(2, payload.Count); + Assert.Equal(9, payload[1].Value); + Assert.Equal("Elite Raid", payload[1].Name); + } + + [Fact] + public async Task GetRaidLevelsReturnsOkEvenWhenListIsEmpty() + { + var svc = new Mock(); + svc.Setup(s => s.GetAllAsync()).ReturnsAsync([]); + var sut = CreateController(svc.Object); + + var result = await sut.GetRaidLevels(); + + var ok = Assert.IsType(result); + var payload = Assert.IsType>(ok.Value, exactMatch: false); + Assert.Empty(payload); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerTests.cs index d3069cee..7acd3da0 100644 --- a/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerTests.cs +++ b/Tests/Pgan.PoracleWebNet.Tests/Controllers/MasterDataControllerTests.cs @@ -1,109 +1,113 @@ -using Microsoft.AspNetCore.Mvc; -using Moq; -using Pgan.PoracleWebNet.Api.Controllers; -using Pgan.PoracleWebNet.Core.Abstractions.Services; - -namespace Pgan.PoracleWebNet.Tests.Controllers; - -public class MasterDataControllerTests : ControllerTestBase -{ - private readonly Mock _masterDataService = new(); - private readonly Mock _poracleApiProxy = new(); - private readonly MasterDataController _sut; - - public MasterDataControllerTests() - { - this._sut = new MasterDataController(this._masterDataService.Object, this._poracleApiProxy.Object); - SetupUser(this._sut); - } - - // --- GetPokemon --- - - [Fact] - public async Task GetPokemonReturnsContentWhenCacheHit() - { - this._masterDataService.Setup(s => s.GetPokemonDataAsync()).ReturnsAsync(/*lang=json,strict*/ "{\"1\":\"Bulbasaur\"}"); - - var result = await this._sut.GetPokemon(); - - var content = Assert.IsType(result); - Assert.Equal("application/json", content.ContentType); - Assert.Contains("Bulbasaur", content.Content); - } - - [Fact] - public async Task GetPokemonRefreshesCacheWhenCacheMissThenReturnsContent() - { - // First call returns null, after refresh returns data - var callCount = 0; - this._masterDataService.Setup(s => s.GetPokemonDataAsync()) - .ReturnsAsync(() => ++callCount > 1 ? /*lang=json,strict*/ "{\"1\":\"Bulbasaur\"}" : null); - this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); - - var result = await this._sut.GetPokemon(); - - var content = Assert.IsType(result); - Assert.Contains("Bulbasaur", content.Content); - this._masterDataService.Verify(s => s.RefreshCacheAsync(), Times.Once); - } - - [Fact] - public async Task GetPokemonReturnsNotFoundWhenCacheMissAndRefreshFails() - { - this._masterDataService.Setup(s => s.GetPokemonDataAsync()).ReturnsAsync((string?)null); - this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); - - var result = await this._sut.GetPokemon(); - - Assert.IsType(result); - } - - // --- GetItems --- - - [Fact] - public async Task GetItemsReturnsContentWhenCacheHit() - { - this._masterDataService.Setup(s => s.GetItemDataAsync()).ReturnsAsync(/*lang=json,strict*/ "{\"1\":\"Poke Ball\"}"); - var result = await this._sut.GetItems(); - Assert.IsType(result); - } - - [Fact] - public async Task GetItemsRefreshesCacheWhenCacheMissThenReturnsContent() - { - var callCount = 0; - this._masterDataService.Setup(s => s.GetItemDataAsync()) - .ReturnsAsync(() => ++callCount > 1 ? /*lang=json,strict*/ "{\"1\":\"Poke Ball\"}" : null); - this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); - - var result = await this._sut.GetItems(); - - Assert.IsType(result); - this._masterDataService.Verify(s => s.RefreshCacheAsync(), Times.Once); - } - - [Fact] - public async Task GetItemsReturnsNotFoundWhenCacheMissAndRefreshFails() - { - this._masterDataService.Setup(s => s.GetItemDataAsync()).ReturnsAsync((string?)null); - this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); - Assert.IsType(await this._sut.GetItems()); - } - - // --- GetGrunts --- - - [Fact] - public async Task GetGruntsReturnsContentWhenAvailable() - { - this._poracleApiProxy.Setup(p => p.GetGruntsAsync()).ReturnsAsync(/*lang=json,strict*/ "{\"grunts\":[]}"); - var result = await this._sut.GetGrunts(); - Assert.IsType(result); - } - - [Fact] - public async Task GetGruntsReturnsNotFoundWhenNull() - { - this._poracleApiProxy.Setup(p => p.GetGruntsAsync()).ReturnsAsync((string?)null); - Assert.IsType(await this._sut.GetGrunts()); - } -} +using Microsoft.AspNetCore.Mvc; +using Moq; +using Pgan.PoracleWebNet.Api.Controllers; +using Pgan.PoracleWebNet.Core.Abstractions.Services; + +namespace Pgan.PoracleWebNet.Tests.Controllers; + +public class MasterDataControllerTests : ControllerTestBase +{ + private readonly Mock _masterDataService = new(); + private readonly Mock _poracleApiProxy = new(); + private readonly Mock _raidLevelService = new(); + private readonly MasterDataController _sut; + + public MasterDataControllerTests() + { + this._sut = new MasterDataController( + this._masterDataService.Object, + this._poracleApiProxy.Object, + this._raidLevelService.Object); + SetupUser(this._sut); + } + + // --- GetPokemon --- + + [Fact] + public async Task GetPokemonReturnsContentWhenCacheHit() + { + this._masterDataService.Setup(s => s.GetPokemonDataAsync()).ReturnsAsync(/*lang=json,strict*/ "{\"1\":\"Bulbasaur\"}"); + + var result = await this._sut.GetPokemon(); + + var content = Assert.IsType(result); + Assert.Equal("application/json", content.ContentType); + Assert.Contains("Bulbasaur", content.Content); + } + + [Fact] + public async Task GetPokemonRefreshesCacheWhenCacheMissThenReturnsContent() + { + // First call returns null, after refresh returns data + var callCount = 0; + this._masterDataService.Setup(s => s.GetPokemonDataAsync()) + .ReturnsAsync(() => ++callCount > 1 ? /*lang=json,strict*/ "{\"1\":\"Bulbasaur\"}" : null); + this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); + + var result = await this._sut.GetPokemon(); + + var content = Assert.IsType(result); + Assert.Contains("Bulbasaur", content.Content); + this._masterDataService.Verify(s => s.RefreshCacheAsync(), Times.Once); + } + + [Fact] + public async Task GetPokemonReturnsNotFoundWhenCacheMissAndRefreshFails() + { + this._masterDataService.Setup(s => s.GetPokemonDataAsync()).ReturnsAsync((string?)null); + this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); + + var result = await this._sut.GetPokemon(); + + Assert.IsType(result); + } + + // --- GetItems --- + + [Fact] + public async Task GetItemsReturnsContentWhenCacheHit() + { + this._masterDataService.Setup(s => s.GetItemDataAsync()).ReturnsAsync(/*lang=json,strict*/ "{\"1\":\"Poke Ball\"}"); + var result = await this._sut.GetItems(); + Assert.IsType(result); + } + + [Fact] + public async Task GetItemsRefreshesCacheWhenCacheMissThenReturnsContent() + { + var callCount = 0; + this._masterDataService.Setup(s => s.GetItemDataAsync()) + .ReturnsAsync(() => ++callCount > 1 ? /*lang=json,strict*/ "{\"1\":\"Poke Ball\"}" : null); + this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); + + var result = await this._sut.GetItems(); + + Assert.IsType(result); + this._masterDataService.Verify(s => s.RefreshCacheAsync(), Times.Once); + } + + [Fact] + public async Task GetItemsReturnsNotFoundWhenCacheMissAndRefreshFails() + { + this._masterDataService.Setup(s => s.GetItemDataAsync()).ReturnsAsync((string?)null); + this._masterDataService.Setup(s => s.RefreshCacheAsync()).Returns(Task.CompletedTask); + Assert.IsType(await this._sut.GetItems()); + } + + // --- GetGrunts --- + + [Fact] + public async Task GetGruntsReturnsContentWhenAvailable() + { + this._poracleApiProxy.Setup(p => p.GetGruntsAsync()).ReturnsAsync(/*lang=json,strict*/ "{\"grunts\":[]}"); + var result = await this._sut.GetGrunts(); + Assert.IsType(result); + } + + [Fact] + public async Task GetGruntsReturnsNotFoundWhenNull() + { + this._poracleApiProxy.Setup(p => p.GetGruntsAsync()).ReturnsAsync((string?)null); + Assert.IsType(await this._sut.GetGrunts()); + } +} diff --git a/Tests/Pgan.PoracleWebNet.Tests/Services/RaidLevelServiceTests.cs b/Tests/Pgan.PoracleWebNet.Tests/Services/RaidLevelServiceTests.cs new file mode 100644 index 00000000..8fda6d33 --- /dev/null +++ b/Tests/Pgan.PoracleWebNet.Tests/Services/RaidLevelServiceTests.cs @@ -0,0 +1,78 @@ +using Pgan.PoracleWebNet.Core.Services; + +namespace Pgan.PoracleWebNet.Tests.Services; + +public class RaidLevelServiceTests +{ + [Fact] + public async Task GetAllAsyncReturnsNineteenLevelsInOrder() + { + var sut = new RaidLevelService(); + + var levels = await sut.GetAllAsync(); + + Assert.Equal(19, levels.Count); + for (var i = 0; i < 19; i++) + { + Assert.Equal(i + 1, levels[i].Value); + } + } + + [Fact] + public async Task GetAllAsyncAssignsCategoriesPerMasterfile() + { + var sut = new RaidLevelService(); + + var levels = (await sut.GetAllAsync()).ToDictionary(l => l.Value); + + // Star tiers 1-5 + for (var v = 1; v <= 5; v++) Assert.Equal("star", levels[v].Category); + // Mega 6, Mega Legendary 7 + Assert.Equal("mega", levels[6].Category); + Assert.Equal("mega", levels[7].Category); + // Ultra Beast 8, Elite 9, Primal 10 + Assert.Equal("special", levels[8].Category); + Assert.Equal("special", levels[9].Category); + Assert.Equal("special", levels[10].Category); + // Shadow 11-15 + for (var v = 11; v <= 15; v++) Assert.Equal("shadow", levels[v].Category); + // Super Mega 16-17 + Assert.Equal("superMega", levels[16].Category); + Assert.Equal("superMega", levels[17].Category); + // Coordinated 18-19 + Assert.Equal("coordinated", levels[18].Category); + Assert.Equal("coordinated", levels[19].Category); + } + + [Fact] + public async Task GetAllAsyncUsesMasterfileNamesWithRaidSuffixStripped() + { + var sut = new RaidLevelService(); + + var levels = (await sut.GetAllAsync()).ToDictionary(l => l.Value); + + // Fixes the prior Elite mislabel: level 7 is Mega Legendary, NOT Elite + Assert.Equal("Mega Legendary", levels[7].Name); + // Elite is at level 9 + Assert.Equal("Elite", levels[9].Name); + // Level 5 is Legendary + Assert.Equal("Legendary", levels[5].Name); + // Star tiers carry the literal star nomenclature minus the redundant suffix + Assert.Equal("1 Star", levels[1].Name); + Assert.Equal("4 Star", levels[4].Name); + } + + [Fact] + public async Task GetAllAsyncPluralNamesKeepTheFullPhrase() + { + var sut = new RaidLevelService(); + + var levels = (await sut.GetAllAsync()).ToDictionary(l => l.Value); + + // Plural form is used in standalone phrases like card titles where the + // "Raids" suffix completes the sentence. + Assert.Equal("Mega Raids", levels[6].NamePlural); + Assert.Equal("Elite Raids", levels[9].NamePlural); + Assert.Equal("Legendary Raids", levels[5].NamePlural); + } +} diff --git a/docs/architecture/backend.md b/docs/architecture/backend.md index 0550dc9c..5b82a2b3 100644 --- a/docs/architecture/backend.md +++ b/docs/architecture/backend.md @@ -57,6 +57,12 @@ PoracleNG's `cleanRow()` function applies field defaults on every create/update. !!! info "Defaults are now enforced server-side" Even if the frontend sends incomplete data, PoracleNG's `cleanRow()` fills in proper defaults. This eliminates the class of bugs where missing C# model defaults caused silent filter breakage. +## Raid level service + +`IRaidLevelService` / `RaidLevelService` is a singleton that serves the canonical Pokémon GO raid-type vocabulary to the frontend, mirroring the [WatWowMap masterfile](https://github.com/WatWowMap/Masterfile-Generator) without the locale-blind English strings leaking into the UI. The implementation returns a baked-in snapshot of 19 levels (1-Star through Coordinated 2) via `GET /api/masterdata/raid-levels`, with each entry exposing `{ value, category, name, namePlural }`. A `TODO` in `GetAllAsync` documents the upgrade path to a live masterfile fetch with on-disk caching under `DATA_DIR`; the wire contract will not change. The frontend `RaidLevelService` caches the response in a signal and falls back to a baked-in `KNOWN_LEVELS` constant on fetch error so the level picker always works, even offline. + +PoracleNG accepts any positive integer as a raid/egg level, so the picker's `+ Add` affordance lets users alarm on levels that haven't been added to the canonical list yet. The `[Range(0, int.MaxValue)]` attribute on the alarm `Create`/`Update` DTOs ensures custom integers and the `9000` "any" sentinel pass server-side validation. + ## Test alert service `TestAlertService` lets users trigger a sample notification for any configured alarm. It uses `Task.WhenAll` to fetch the alarm (via `IPoracleTrackingProxy`) and the human record (via `IPoracleHumanProxy`) in parallel. It then constructs a realistic mock webhook payload based on the alarm's filter fields (e.g., `pokemon_id`, `raid_level`, `quest_reward`) using the user's location as the event coordinates. The payload is sent to PoracleNG's `POST /api/test` endpoint, which formats and delivers the notification. Rate-limited at 5 requests per 60s per IP via the `test-alert` policy. @@ -127,6 +133,7 @@ Geofence polygons come from the Poracle API (via the unified feed), not the data |---|---|---| | Most services | **Scoped** | Per-request | | `MasterDataService` | **Singleton** | Cached game data | +| `RaidLevelService` | **Singleton** | Stateless canonical-list provider; future live masterfile fetch will cache here | !!! info "DashboardService uses the proxy" `DashboardService` calls `IPoracleTrackingProxy.GetAllTrackingAsync()` to fetch all alarm types in a single API call, then counts each type from the response. No direct DB queries. diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index abcd1e2b..89a1a927 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -115,6 +115,14 @@ Shows on the dashboard for new users until explicitly dismissed. Detects existin !!! note "`ProfileListComponent` removed" The unused `ProfileListComponent` has been removed. Profile management is handled entirely by `ProfileOverviewComponent`. +### Level selector + +`LevelSelectorComponent` (`shared/components/level-selector/`) is the chip-based picker for raid, egg, and raid-boss levels. A single `pickerType: 'raid' | 'egg' | 'boss'` input drives layout and behavior — multi-select vs single-select, whether the `Any` (9000) chip is offered, and which canonical levels go in the primary chip row vs the "More raid types…" overflow menu. See [Raid level selector](../features/alarms.md#raid-level-selector) for the user-facing behavior. + +`RaidLevelService` (`core/services/raid-level.service.ts`) fetches the canonical raid-level list from `GET /api/masterdata/raid-levels` on first dialog/list usage and caches the result in a signal. A baked-in `KNOWN_LEVELS` constant in `core/models/raid-level.models.ts` is the fallback when the network call fails or hasn't resolved. The same constant powers the synchronous `resolveLevel(value)` helper used by `LevelLabelPipe` so alarm cards have a usable label even before the API response lands. The pipe detects ngx-translate's key-not-found pass-through and falls back to a generic "Level {n}" string so future masterfile additions don't leak raw translation keys into the UI. + +Custom integers typed via the `+ Add` chip live in the component's local signal — they are **not** persisted to localStorage. Closing the dialog (or refreshing the page) discards typed-but-not-saved chips. Existing alarms at custom levels re-seed the chip when the edit dialog opens via the `[value]` input setter. + ### Gym picker `GymPickerComponent` (`shared/components/gym-picker/`) is a standalone autocomplete for selecting a gym from the scanner database. It wraps a Material autocomplete input with debounced search (300ms, minimum 2 characters). Each option row displays the gym photo thumbnail, name, and area name. The component exposes a two-way `gymId` model binding so parent dialogs can read/write the selected gym ID directly. diff --git a/docs/features/alarms.md b/docs/features/alarms.md index 48c318a0..2f9aa664 100644 --- a/docs/features/alarms.md +++ b/docs/features/alarms.md @@ -9,8 +9,8 @@ All alarm CRUD operations are proxied through the PoracleNG REST API. PoracleNG | Type | Description | |---|---| | **Pokemon** | Filter by species, IV, CP, level, PVP rank, gender, size | -| **Raids** | Filter by raid boss, tier, move, evolution, EX eligibility, specific gym, RSVP changes | -| **Eggs** | Filter by egg tier, EX eligibility, specific gym, RSVP changes | +| **Raids** | Filter by raid boss, level, move, evolution, EX eligibility, specific gym, RSVP changes. See [Raid level selector](#raid-level-selector). | +| **Eggs** | Filter by egg level, EX eligibility, specific gym, RSVP changes. See [Raid level selector](#raid-level-selector). | | **Quests** | Filter by reward type and Pokemon | | **Invasions** | Filter by grunt type and shadow Pokemon | | **Lures** | Filter by lure type | @@ -136,9 +136,23 @@ When a user selects a specific size, both `size` and `max_size` are set to the s The default maximum level is **55** (not 40 or 50), matching Poracle's support for shadow/purified/best-buddy boosted levels. +## Raid level selector + +Raid, egg, and raid-boss-level pickers share the `` chip component. The vocabulary follows the [WatWowMap masterfile](https://github.com/WatWowMap/Masterfile-Generator/blob/main/master-latest-poracle-v2.json) — the same source PoracleNG uses for in-DM notification text — so the names you see in the picker match what users receive in their alerts. + +**Raid picker.** Multi-select. Primary chip row shows the seven most common types: `1 Star`, `2 Star`, `3 Star`, `4 Star`, `Legendary` (level 5), `Mega` (level 6), `Mega Legendary` (level 7). A `Any` chip selects the wildcard sentinel (level 9000) that matches every raid level. A **More raid types…** overflow menu surfaces the other 12 canonical types: `Ultra Beast` (8), `Elite` (9), `Primal` (10), `1–5 Shadow` (11–15), `4–5 Super Mega` (16–17), `Coordinated 1–2` (18–19). + +**Egg picker.** Multi-select. Only the five Star tiers (1–5) are surfaced — Pokémon GO has no Mega/Shadow/Primal eggs. + +**Boss-level picker.** Single-select. Same primary/overflow layout as the raid picker, used in the "By Boss" tab when a specific Pokémon is selected but the user still wants to scope to certain raid levels. + +**`+ Add`.** All three pickers expose an inline numeric input for any positive integer not in the canonical list. Useful for forward compatibility — if Niantic introduces a new raid type (`raid_20`) before PoracleWeb.NET ships an update, you can already alarm on it. Typed values are **ephemeral** to the dialog session: close the dialog (or refresh the page) and the chip is gone. Saved alarms at custom levels re-seed the chip when you open the edit dialog. + +The canonical list is served by the API at `GET /api/masterdata/raid-levels` (cached server-side; baked-in fallback if the masterfile fetch fails). Card titles like "All Mega Legendary Raids" compose by combining the modifier ("Mega Legendary") with the localized "Raids" suffix from `RAIDS.ALL_LEVEL_RAIDS`, so card text reads naturally without the doubled word that an unaltered masterfile string would produce. + ## Raid alarm filters -Raid alarms support these fields beyond the basic tier/boss selection: +Raid alarms support these fields beyond the basic level/boss selection: | Field | Default | Description | |---|---|---| diff --git a/docs/getting-started/development-setup.md b/docs/getting-started/development-setup.md index 60be188c..af0bc765 100644 --- a/docs/getting-started/development-setup.md +++ b/docs/getting-started/development-setup.md @@ -99,7 +99,17 @@ Or manually: `cd Applications/Pgan.PoracleWebNet.App/ClientApp && npm install` # or: cd Applications/Pgan.PoracleWebNet.App/ClientApp && npm start ``` - Starts on **http://localhost:4200**. The Angular dev server proxies API requests to the .NET backend. + Starts on **http://localhost:4200**. The dev server proxies `/api/*` and `/auth/*` to the API on `http://localhost:5048` via `Applications/Pgan.PoracleWebNet.App/ClientApp/proxy.conf.json` (`changeOrigin: false` so the original `Host` header is preserved — this matters for OAuth callback URIs, which Discord matches by literal string against your registered redirect URI). + + The Angular environment uses an empty `apiUrl` in dev (`environment.development.ts`), so all HTTP calls are same-origin from the browser's view. This makes the dev server behave identically to the production single-port deployment that serves the Angular build out of the API's `wwwroot`. + + To use a different dev port (e.g. to match an existing Discord OAuth registration on `http://localhost:8082`): + + ```bash + npx ng serve --proxy-config proxy.conf.json --port 8082 + ``` + + The dev server port must be present in your Discord application's **Redirects** list for OAuth login to work. The default `4200` matches `Discord:FrontendUrl` in `appsettings.json`. Open **http://localhost:4200** in your browser.