diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata-late-arrival.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata-late-arrival.spec.ts new file mode 100644 index 00000000..6df06ced --- /dev/null +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata-late-arrival.spec.ts @@ -0,0 +1,98 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideTranslateService } from '@ngx-translate/core'; + +import { ConfigService } from './config.service'; +import { MasterDataService } from './masterdata.service'; + +/** + * Masterdata almost never wins the race against the first render: every alarm list paints its cards + * from the alarm rows, which come back first, and resolves each species name through + * `MasterDataService`. If the maps are not reactive, that first paint is also the last one and the + * cards keep the `Pokemon #1` fallback until something else happens to redraw them -- which is what + * a route change does, and why the names look right on the second visit. + * + * Every test here flushes the responses *after* the first read, because seeding the service first + * passes just as happily against the broken code. + */ +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'app-masterdata-host', + standalone: true, + template: '

{{ masterData.getPokemonName(1) }}

', +}) +class MasterDataHostComponent { + readonly masterData = inject(MasterDataService); +} + +describe('MasterDataService — data arriving after first render', () => { + const API = 'http://test-api'; + let httpMock: HttpTestingController; + let service: MasterDataService; + + function flushMasterData(): void { + httpMock.expectOne(`${API}/api/masterdata/pokemon`).flush({ '1': 'Bulbasaur' }); + httpMock.expectOne(`${API}/api/masterdata/items`).flush({ '1': 'Poke Ball' }); + httpMock.expectOne(`${API}/api/masterdata/moves`).flush({ '13': 'Wrap' }); + httpMock.expectOne(`${API}/api/masterdata/costumes`).flush({ '85': 'Halloween 2025' }); + httpMock.expectOne(req => req.url === `${API}/api/masterdata/monsters`).flush({}); + } + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideTranslateService(), + { provide: ConfigService, useValue: { apiHost: API } }, + ], + }); + service = TestBed.inject(MasterDataService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('repaints a rendered species name once the names land', () => { + const fixture = TestBed.createComponent(MasterDataHostComponent); + service.loadData().subscribe(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('h3').textContent).toBe('Pokemon #1'); + + flushMasterData(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('h3').textContent).toBe('Bulbasaur'); + }); + + it('keeps the fallback when the names never land', () => { + const fixture = TestBed.createComponent(MasterDataHostComponent); + service.loadData().subscribe(); + fixture.detectChanges(); + + httpMock.expectOne(`${API}/api/masterdata/pokemon`).error(new ProgressEvent('error'), { status: 500, statusText: 'Error' }); + httpMock.match(`${API}/api/masterdata/items`); + httpMock.match(`${API}/api/masterdata/moves`); + httpMock.match(`${API}/api/masterdata/costumes`); + httpMock.match(req => req.url === `${API}/api/masterdata/monsters`); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('h3').textContent).toBe('Pokemon #1'); + }); + + it('re-resolves item and move names read before the load finished', () => { + const item = TestBed.runInInjectionContext(() => service.getItemName(1)); + const move = TestBed.runInInjectionContext(() => service.getMoveName(13)); + expect(item).toBe('Item #1'); + expect(move).toBe('Move #13'); + + service.loadData().subscribe(); + flushMasterData(); + + expect(service.getItemName(1)).toBe('Poke Ball'); + expect(service.getMoveName(13)).toBe('Wrap'); + }); +}); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata.service.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata.service.ts index bc38d373..52f7a5e5 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata.service.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/core/services/masterdata.service.ts @@ -31,19 +31,25 @@ const UNTRANSLATED_KEY = /^(poke|poke_type|form)_\d+$/; @Injectable({ providedIn: 'root' }) export class MasterDataService { private readonly config = inject(ConfigService); - private costumeMap = new Map(); + /** + * The name maps are signals, not plain Maps, because they are filled long after the first paint. + * Every reader below runs inside a template or a computed, so a signal read is what makes the + * alarm cards repaint when the names finally land -- a mutated Map would leave them showing + * `Pokemon #1` until something unrelated redrew them. See the late-arrival spec. + */ + private readonly costumeMap = signal(new Map()); private readonly evoBaseMap = new Map(); private readonly formsMap = signal(new Map()); private readonly http = inject(HttpClient); private readonly i18n = inject(I18nService); - private itemMap = new Map(); + private readonly itemMap = signal(new Map()); private loaded = false; /** Locale of the data currently in the maps, so a display-language change can be detected. */ private loadedLocale = ''; private loadRequested = false; - private moveMap = new Map(); - private pokemonMap = new Map(); + private readonly moveMap = signal(new Map()); + private readonly pokemonMap = signal(new Map()); private readonly ready$ = new ReplaySubject(1); private readonly typeLabels = signal(new Map()); private readonly typesMap = signal(new Map()); @@ -62,12 +68,12 @@ export class MasterDataService { /** Whether any costume names loaded. False means the dialogs offer only the two sentinels. */ costumesAvailable(): boolean { - return this.costumeMap.size > 0; + return this.costumeMap().size > 0; } getAllItems(): { id: number; name: string }[] { const entries: { id: number; name: string }[] = []; - this.itemMap.forEach((name, id) => { + this.itemMap().forEach((name, id) => { entries.push({ id, name }); }); entries.sort((a, b) => a.name.localeCompare(b.name)); @@ -77,7 +83,7 @@ export class MasterDataService { getAllPokemon(): PokemonEntry[] { const types = this.typesMap(); const entries: PokemonEntry[] = [{ id: 0, name: 'All Pokemon' }]; - this.pokemonMap.forEach((name, id) => { + this.pokemonMap().forEach((name, id) => { entries.push({ id, name, types: types.get(id) }); }); entries.sort((a, b) => a.id - b.id); @@ -107,7 +113,7 @@ export class MasterDataService { * unknown form renders. */ getCostumeName(id: number): string { - return this.costumeMap.get(id) ?? this.i18n.instant('POKEMON.COSTUME_FALLBACK', { id }); + return this.costumeMap().get(id) ?? this.i18n.instant('POKEMON.COSTUME_FALLBACK', { id }); } /** @@ -119,7 +125,7 @@ export class MasterDataService { */ getCostumes(): { id: number; name: string }[] { const entries: { id: number; name: string }[] = []; - this.costumeMap.forEach((name, id) => { + this.costumeMap().forEach((name, id) => { entries.push({ id, name }); }); entries.sort((a, b) => b.id - a.id); @@ -138,16 +144,16 @@ export class MasterDataService { } getItemName(id: number): string { - return this.itemMap.get(id) ?? `Item #${id}`; + return this.itemMap().get(id) ?? `Item #${id}`; } getMoveName(id: number): string { - return this.moveMap.get(id) ?? `Move #${id}`; + return this.moveMap().get(id) ?? `Move #${id}`; } getPokemonName(id: number): string { if (id === 0) return 'All Pokemon'; - return this.pokemonMap.get(id) ?? `Pokemon #${id}`; + return this.pokemonMap().get(id) ?? `Pokemon #${id}`; } getPokemonTypes(id: number): string[] { @@ -179,7 +185,7 @@ export class MasterDataService { * A null payload (upstream unreachable) leaves the English names from /api/masterdata/pokemon in * place rather than blanking the selector. */ - private applyMonsters(monsters: null | Record): void { + private applyMonsters(monsters: null | Record, names: Map): void { if (!monsters) return; const namesById = new Map(); @@ -244,7 +250,7 @@ export class MasterDataService { forms.sort((a, b) => a.name.localeCompare(b.name)); } - namesById.forEach((name, id) => this.pokemonMap.set(id, name)); + namesById.forEach((name, id) => names.set(id, name)); this.formsMap.set(grouped); this.typesMap.set(typeMap); this.typeLabels.set(typeLabelMap); @@ -309,35 +315,44 @@ export class MasterDataService { this.ready$.next(true); }, next: ({ costumes, items, monsters, moves, pokemon }) => { - this.pokemonMap.clear(); + // Each map is rebuilt whole and published once. Mutating the live map in place would not + // notify anything reading it, and would briefly show a half-filled list to anything that + // did. + const pokemonNames = new Map(); if (pokemon) { Object.entries(pokemon).forEach(([id, name]) => { - this.pokemonMap.set(Number(id), name as string); + pokemonNames.set(Number(id), name as string); }); } - this.itemMap.clear(); + const itemNames = new Map(); if (items) { Object.entries(items).forEach(([id, name]) => { - this.itemMap.set(Number(id), name as string); + itemNames.set(Number(id), name as string); }); } - this.costumeMap.clear(); + const costumeNames = new Map(); if (costumes) { Object.entries(costumes).forEach(([id, name]) => { - this.costumeMap.set(Number(id), name as string); + costumeNames.set(Number(id), name as string); }); } - this.moveMap.clear(); + const moveNames = new Map(); if (moves) { Object.entries(moves).forEach(([id, name]) => { - this.moveMap.set(Number(id), name as string); + moveNames.set(Number(id), name as string); }); } - this.applyMonsters(monsters); + // Translated species names overwrite the English ones, so this runs before publishing. + this.applyMonsters(monsters, pokemonNames); + + this.itemMap.set(itemNames); + this.costumeMap.set(costumeNames); + this.moveMap.set(moveNames); + this.pokemonMap.set(pokemonNames); this.loaded = true; this.ready$.next(true); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.html index 55ab60b0..5f606d57 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.html @@ -8,85 +8,83 @@

{{ 'QUESTS.ADD_DIALOG_TITLE' | translate }}

{{ 'QUESTS.TAB_REWARDS' | translate }}
- - -
- - @if (selectedPokemonIds().length > 0) { -

{{ 'QUESTS.SELECTION_COUNT' | translate: { count: selectedPokemonIds().length } }}

+ + + {{ 'QUESTS.REWARD_TYPE' | translate }} + + @for (kind of rewardKinds; track kind.value) { + @if (kind.value !== 5 || supportsPokecoins()) { + {{ kind.label | translate }} } -
-
- -
- - {{ 'QUESTS.ITEM_REWARD' | translate }} - - {{ 'QUESTS.ANY_ITEM' | translate }} - @for (item of questItems(); track item.id) { - - - {{ item.name }} - - } - - - - {{ 'QUESTS.MIN_AMOUNT' | translate }} - - {{ 'QUESTS.MIN_AMOUNT_HINT' | translate }} - -
-
- -
- - @if (selectedMegaPokemonIds().length > 0) { -

{{ 'QUESTS.SELECTION_COUNT' | translate: { count: selectedMegaPokemonIds().length } }}

- } - - {{ 'QUESTS.MIN_AMOUNT' | translate }} - - {{ 'QUESTS.MIN_AMOUNT_HINT' | translate }} - -
-
- -
- - @if (selectedCandyPokemonIds().length > 0) { -

{{ 'QUESTS.SELECTION_COUNT' | translate: { count: selectedCandyPokemonIds().length } }}

- } - - {{ 'QUESTS.MIN_AMOUNT' | translate }} - - {{ 'QUESTS.MIN_AMOUNT_HINT' | translate }} - -
-
- -
- - {{ 'QUESTS.MIN_STARDUST' | translate }} - - {{ 'QUESTS.MIN_STARDUST_HINT' | translate }} - -
-
- - @if (supportsPokecoins()) { - -
- - {{ 'QUESTS.MIN_POKECOINS' | translate }} - - {{ 'QUESTS.MIN_POKECOINS_HINT' | translate }} - -
-
+ } + + + + @switch (rewardKind) { + @case (0) { + + @if (selectedPokemonIds().length > 0) { +

{{ 'QUESTS.SELECTION_COUNT' | translate: { count: selectedPokemonIds().length } }}

+ } + } + @case (1) { + + {{ 'QUESTS.ITEM_REWARD' | translate }} + + {{ 'QUESTS.ANY_ITEM' | translate }} + @for (item of questItems(); track item.id) { + + + {{ item.name }} + + } + + + + {{ 'QUESTS.MIN_AMOUNT' | translate }} + + {{ 'QUESTS.MIN_AMOUNT_HINT' | translate }} + + } + @case (2) { + + @if (selectedMegaPokemonIds().length > 0) { +

{{ 'QUESTS.SELECTION_COUNT' | translate: { count: selectedMegaPokemonIds().length } }}

+ } + + {{ 'QUESTS.MIN_AMOUNT' | translate }} + + {{ 'QUESTS.MIN_AMOUNT_HINT' | translate }} + } -
+ @case (3) { + + @if (selectedCandyPokemonIds().length > 0) { +

{{ 'QUESTS.SELECTION_COUNT' | translate: { count: selectedCandyPokemonIds().length } }}

+ } + + {{ 'QUESTS.MIN_AMOUNT' | translate }} + + {{ 'QUESTS.MIN_AMOUNT_HINT' | translate }} + + } + @case (4) { + + {{ 'QUESTS.MIN_STARDUST' | translate }} + + {{ 'QUESTS.MIN_STARDUST_HINT' | translate }} + + } + @case (5) { + + {{ 'QUESTS.MIN_POKECOINS' | translate }} + + {{ 'QUESTS.MIN_POKECOINS_HINT' | translate }} + + } + }
diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.scss index 7b8d7526..001181c2 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.scss @@ -23,23 +23,8 @@ mat-dialog-content { :host ::ng-deep .alarm-tabs .mat-mdc-tab-body-wrapper { padding: 0 24px; } -:host ::ng-deep .reward-tabs .mat-mdc-tab-labels { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; -} -:host ::ng-deep .reward-tabs .mat-mdc-tab-labels::-webkit-scrollbar { - display: none; -} -:host ::ng-deep .reward-tabs .mat-mdc-tab-label-container { - overflow: visible; -} -// Five reward types want 520px of Material's default tab padding in a 464px rail, so the group -// paginated and put Stardust behind an arrow at every width. Trimming the padding fits all five on a -// desktop dialog with room to spare; phone width still scrolls, as it did with four. -:host ::ng-deep .reward-tabs .mdc-tab { - min-width: 0; - padding: 0 12px; +.reward-kind-field { + margin-bottom: 4px; } @media (max-width: 599px) { mat-dialog-content { diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.spec.ts index 8e911bad2..edcd4568 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.spec.ts @@ -113,7 +113,7 @@ describe('QuestAddDialogComponent', () => { it('creates a stardust rule from the amount alone', () => { // PoracleNG matches stardust on the reward column, not the amount one, so the floor travels there. - component.tabIndex = 4; + component.rewardKind = 4; component.stardustForm.controls.reward.setValue(1500); component.save(); @@ -124,7 +124,7 @@ describe('QuestAddDialogComponent', () => { }); it('treats a stardust rule with no floor as every stardust quest', () => { - component.tabIndex = 4; + component.rewardKind = 4; component.save(); @@ -132,7 +132,7 @@ describe('QuestAddDialogComponent', () => { }); it('sends the minimum amount with an item rule', () => { - component.tabIndex = 1; + component.rewardKind = 1; component.itemForm.controls.reward.setValue(1301); component.itemForm.controls.amount.setValue(3); @@ -144,7 +144,7 @@ describe('QuestAddDialogComponent', () => { }); it('sends the minimum amount with a mega energy rule, on every selected pokemon', () => { - component.tabIndex = 2; + component.rewardKind = 2; component.selectedMegaPokemonIds.set([6, 9]); component.megaForm.controls.amount.setValue(50); @@ -157,7 +157,7 @@ describe('QuestAddDialogComponent', () => { }); it('sends the minimum amount with a candy rule', () => { - component.tabIndex = 3; + component.rewardKind = 3; component.selectedCandyPokemonIds.set([133]); component.candyForm.controls.amount.setValue(5); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.ts index 8dbab130..52b09db7 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.component.ts @@ -99,6 +99,23 @@ export class QuestAddDialogComponent { /** Quest-relevant items (balls, berries, potions, revives, TMs, etc.) */ readonly questItems = signal<{ id: number; name: string }[]>([]); + /** Which reward the alarm is for. `save()` and `canSave()` switch on it. */ + rewardKind = 0; + + /** + * The reward types, with the numbers `save()` switches on written down rather than inferred from + * render order. Pokecoins is hidden on a PoracleNG that would refuse it (see `supportsPokecoins`); + * declaring its value keeps every other type where it was whether it renders or not. + */ + readonly rewardKinds: { label: string; value: number }[] = [ + { label: 'QUESTS.TAB_POKEMON', value: 0 }, + { label: 'QUESTS.TAB_ITEMS', value: 1 }, + { label: 'QUESTS.TAB_MEGA_ENERGY', value: 2 }, + { label: 'QUESTS.TAB_CANDY', value: 3 }, + { label: 'QUESTS.TAB_STARDUST', value: 4 }, + { label: 'QUESTS.TAB_POKECOINS', value: 5 }, + ]; + saving = signal(false); /** @@ -118,6 +135,7 @@ export class QuestAddDialogComponent { selectedCandyPokemonIds = signal([]); selectedMegaPokemonIds = signal([]); + selectedPokemonIds = signal([]); /** @@ -130,8 +148,6 @@ export class QuestAddDialogComponent { readonly summaryService = inject(SummaryScheduleService); - tabIndex = 0; - constructor() { this.masterData.loadData().subscribe(() => { // Filter to quest-relevant items (exclude tickets, passes, storage, etc.) @@ -145,7 +161,7 @@ export class QuestAddDialogComponent { } canSave(): boolean { - switch (this.tabIndex) { + switch (this.rewardKind) { case 0: return this.selectedPokemonIds().length > 0; case 1: @@ -192,7 +208,7 @@ export class QuestAddDialogComponent { const creates: ReturnType[] = []; - switch (this.tabIndex) { + switch (this.rewardKind) { case 0: for (const pokemonId of this.selectedPokemonIds()) { creates.push( @@ -340,9 +356,9 @@ export class QuestAddDialogComponent { /** * Whether this PoracleNG can store pokecoin quest rewards at all. * - * PoracleNG below 5.2.0 answers 400 "Unrecognised reward_type value", so the tab is absent rather - * than present-and-failing. It is the last tab, which keeps every other tab's index stable whether it - * renders or not -- `tabIndex` is positional and the save switch reads it. + * PoracleNG below 5.2.0 answers 400 "Unrecognised reward_type value", so the reward type is absent + * rather than present-and-failing. Hiding it shifts nothing: the values in `rewardKinds` are + * declared, not positional. */ supportsPokecoins(): boolean { return this.questService.pokecoinsSupported(); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.pokecoins.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.pokecoins.spec.ts index 4d83041f..b02a774b 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.pokecoins.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/modules/quests/quest-add-dialog.pokecoins.spec.ts @@ -18,8 +18,8 @@ import { QuestService } from '../../core/services/quest.service'; * Pokecoin quests are offered only when the PoracleNG behind this install can store them. * * PoracleNG below 5.2.0 answers 400 "Unrecognised reward_type value" -- confirmed by POSTing to a live - * 5.1.0 -- so the tab is absent rather than present-and-failing. The pairing matters more than either - * half: a test that only proved the tab hides on an old server would pass just as well if it never + * 5.1.0 -- so the reward type is absent rather than present-and-failing. The pairing matters more than + * either half: a test that only proved it hides on an old server would pass just as well if it never * rendered at all. */ describe('QuestAddDialogComponent — pokecoins capability', () => { @@ -66,46 +66,67 @@ describe('QuestAddDialogComponent — pokecoins capability', () => { fixture.detectChanges(); } - /** Reward tab labels, in order. `tabIndex` is positional, so the order is load-bearing. */ - function rewardTabLabels(): string[] { - return Array.from(fixture.nativeElement.querySelectorAll('.reward-tabs .mat-mdc-tab .mdc-tab__text-label')).map(el => - (el as HTMLElement).textContent!.trim(), - ); + /** The reward types offered, as `value` numbers. `save()` switches on them, so they are load-bearing. */ + function rewardKindValues(): number[] { + return component.rewardKinds.filter(kind => kind.value !== 5 || component.supportsPokecoins()).map(kind => kind.value); } - it('offers no pokecoins tab against a PoracleNG that would refuse it', () => { + /** The reward types as they render, in order. */ + function renderedRewardOptions(): string[] { + const trigger = fixture.nativeElement.querySelector('.reward-kind-field mat-select') as HTMLElement; + trigger.querySelector('.mat-mdc-select-trigger')!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + fixture.detectChanges(); + const options = Array.from(document.querySelectorAll('mat-option')).map(el => (el as HTMLElement).textContent!.trim()); + (document.querySelector('.cdk-overlay-backdrop') as HTMLElement | null)?.click(); + fixture.detectChanges(); + return options; + } + + it('offers no pokecoins reward against a PoracleNG that would refuse it', () => { setup(false); expect(component.supportsPokecoins()).toBe(false); - expect(rewardTabLabels()).toHaveLength(5); + expect(renderedRewardOptions()).toHaveLength(5); }); - it('offers a pokecoins tab against a 5.2.0 or newer PoracleNG', () => { + it('offers a pokecoins reward against a 5.2.0 or newer PoracleNG', () => { setup(true); expect(component.supportsPokecoins()).toBe(true); - expect(rewardTabLabels()).toHaveLength(6); + expect(renderedRewardOptions()).toHaveLength(6); }); /** - * The five original tabs must keep their indices whether the sixth renders or not: `save()` reads - * `tabIndex` positionally, so a tab inserted anywhere but the end would file every reward under the - * wrong type. + * `save()` switches on `rewardKind`, so the numbers are the contract. They are declared per reward + * type rather than taken from render order, which is what makes the pokecoins one safe to hide. */ - it('leaves the existing tab indices untouched when the pokecoins tab appears', () => { + it('leaves the existing reward type values untouched when pokecoins appears', () => { setup(false); - const withoutPokecoins = rewardTabLabels(); + const withoutPokecoins = rewardKindValues(); + + setup(true); + const withPokecoins = rewardKindValues(); + + expect(withoutPokecoins).toEqual([0, 1, 2, 3, 4]); + expect(withPokecoins).toEqual([0, 1, 2, 3, 4, 5]); + }); + /** + * The six reward types are chosen from one control, not a strip that runs out of room. A nested tab + * strip clipped the sixth label to "Pok" and hid it behind a pagination arrow at dialog width -- and + * would do the same to the fifth in the several locales whose words are longer than English's. + */ + it('puts every reward type in one control rather than a strip that can overflow', () => { setup(true); - const withPokecoins = rewardTabLabels(); - expect(withPokecoins.slice(0, 5)).toEqual(withoutPokecoins); + expect(fixture.nativeElement.querySelectorAll('.reward-kind-field mat-select')).toHaveLength(1); + expect(fixture.nativeElement.querySelectorAll('.reward-tabs')).toHaveLength(0); }); it('creates a pokecoin quest with the minimum in the reward slot', () => { setup(true); - component.tabIndex = 5; + component.rewardKind = 5; component.pokecoinsForm.controls.reward.setValue(50); component.save(); @@ -119,7 +140,7 @@ describe('QuestAddDialogComponent — pokecoins capability', () => { it('treats a minimum of 0 as every pokecoin quest, not as an incomplete form', () => { setup(true); - component.tabIndex = 5; + component.rewardKind = 5; expect(component.canSave()).toBe(true); }); @@ -128,7 +149,7 @@ describe('QuestAddDialogComponent — pokecoins capability', () => { it('still creates a stardust quest against an older PoracleNG', () => { setup(false); - component.tabIndex = 4; + component.rewardKind = 4; component.stardustForm.controls.reward.setValue(1000); component.save(); diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.html b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.html index 822e2820..fe7d26fe 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.html +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.html @@ -30,39 +30,61 @@

- - {{ 'PROFILES.ACTIVE_HOURS_HOUR' | translate }} - - @for (h of hourOptions; track h) { - {{ formatHour(h) }} - } - - - - {{ 'PROFILES.ACTIVE_HOURS_MINUTE' | translate }} - - @for (m of minuteOptions; track m) { - {{ formatMinute(m) }} - } - - + +
+ @if (repeatEnabled()) { + {{ 'PROFILES.ACTIVE_HOURS_STARTS_AT' | translate }} + } +
+ + {{ 'PROFILES.ACTIVE_HOURS_HOUR' | translate }} + + @for (h of hourOptions; track h) { + {{ formatHour(h) }} + } + + + + {{ 'PROFILES.ACTIVE_HOURS_MINUTE' | translate }} + + @for (m of minuteOptions; track m) { + {{ formatMinute(m) }} + } + + +
+
@if (repeatEnabled()) { - - {{ 'PROFILES.ACTIVE_HOURS_END_HOUR' | translate }} - - @for (h of hourOptions; track h) { - {{ formatHour(h) }} - } - - - - {{ 'PROFILES.ACTIVE_HOURS_END_MINUTE' | translate }} - - @for (m of minuteOptions; track m) { - {{ formatMinute(m) }} - } - - +
+ {{ 'PROFILES.ACTIVE_HOURS_UNTIL' | translate }} +
+ + {{ 'PROFILES.ACTIVE_HOURS_HOUR' | translate }} + + @for (h of hourOptions; track h) { + {{ formatHour(h) }} + } + + + + {{ 'PROFILES.ACTIVE_HOURS_MINUTE' | translate }} + + @for (m of minuteOptions; track m) { + {{ formatMinute(m) }} + } + + +
+
{{ 'PROFILES.ACTIVE_HOURS_REPEAT_EVERY' | translate }} diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.scss b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.scss index 612bedd3..ff173214 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.scss +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.scss @@ -82,18 +82,39 @@ mat-dialog-content { // Time picker .time-picker { display: flex; - align-items: flex-start; + align-items: flex-end; flex-wrap: wrap; gap: 12px; row-gap: 12px; } +// An hour and a minute are one answer, so they wrap together or not at all. +.time-group { + display: flex; + flex-direction: column; +} + +.time-pair { + display: flex; + gap: 12px; +} + +.group-caption { + margin: 0 0 2px 2px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--text-hint, rgba(0, 0, 0, 0.5)); +} + .time-field { width: 120px; } +// Wide enough for the longest of the eleven translations of "Repeat every". .step-field { - width: 140px; + width: 172px; } .repeat-toggle { @@ -268,9 +289,17 @@ mat-dialog-actions { min-width: unset; } - // Five controls wrapping freely turns into a ragged stack, so pair them up instead: - // start hour/minute, then end hour/minute, then the repeat interval and Add across both columns. + // One pair per row, each field taking half of it, then the repeat interval and Add on their own. + // The hour and its minute stay together on every width; what wraps is whole answers. .time-picker { + display: block; + } + + .time-group { + margin-bottom: 12px; + } + + .time-pair { display: grid; grid-template-columns: 1fr 1fr; } @@ -280,8 +309,7 @@ mat-dialog-actions { width: 100%; } - .step-field, .add-btn { - grid-column: 1 / -1; + width: 100%; } } diff --git a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.spec.ts b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.spec.ts index 2aee03a7..6adb3dac 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.spec.ts +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/app/shared/components/active-hours-editor-dialog/active-hours-editor-dialog.component.spec.ts @@ -1,4 +1,4 @@ -import { TestBed } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { provideTranslateService } from '@ngx-translate/core'; @@ -9,6 +9,7 @@ import { ActiveHourEntry } from '../../../core/models/active-hours.models'; describe('ActiveHoursEditorDialogComponent', () => { let component: ActiveHoursEditorDialogComponent; let dialogRef: { close: jest.Mock }; + let fixture: ComponentFixture; function setup(data: ActiveHoursEditorData) { dialogRef = { close: jest.fn() }; @@ -19,7 +20,7 @@ describe('ActiveHoursEditorDialogComponent', () => { imports: [ActiveHoursEditorDialogComponent, NoopAnimationsModule], }); - const fixture = TestBed.createComponent(ActiveHoursEditorDialogComponent); + fixture = TestBed.createComponent(ActiveHoursEditorDialogComponent); component = fixture.componentInstance; fixture.detectChanges(); } @@ -224,4 +225,58 @@ describe('ActiveHoursEditorDialogComponent', () => { expect(monday.rules[0].end).toBe(23 * 60); }); }); + + /** + * Turning on a repeat puts five controls in the time row, and `Until (hour)` and `Until (minute)` + * were too long for a field that has to stay narrow enough for two to sit side by side on a phone -- + * both labels rendered clipped. The qualifier moved out of the field and onto the pair it describes, + * so each field is labelled by the short word it asks for; screen readers still hear the whole + * thing, from the field's own accessible name. + */ + describe('time row labels', () => { + function fieldLabels(): string[] { + return Array.from(fixture.nativeElement.querySelectorAll('.time-picker mat-label')).map(el => + (el as HTMLElement).textContent!.trim(), + ); + } + + function captions(): string[] { + return Array.from(fixture.nativeElement.querySelectorAll('.time-picker .group-caption')).map(el => + (el as HTMLElement).textContent!.trim(), + ); + } + + it('names the end fields by their group rather than repeating the qualifier in each label', () => { + setup({ activeHours: [], profileName: 'Default' }); + component.repeatEnabled.set(true); + fixture.detectChanges(); + + expect(fieldLabels()).toEqual([ + 'PROFILES.ACTIVE_HOURS_HOUR', + 'PROFILES.ACTIVE_HOURS_MINUTE', + 'PROFILES.ACTIVE_HOURS_HOUR', + 'PROFILES.ACTIVE_HOURS_MINUTE', + 'PROFILES.ACTIVE_HOURS_REPEAT_EVERY', + ]); + expect(captions()).toEqual(['PROFILES.ACTIVE_HOURS_STARTS_AT', 'PROFILES.ACTIVE_HOURS_UNTIL']); + }); + + it('keeps the end fields tellable apart by their accessible names', () => { + setup({ activeHours: [], profileName: 'Default' }); + component.repeatEnabled.set(true); + fixture.detectChanges(); + + const labels = Array.from(fixture.nativeElement.querySelectorAll('.time-picker mat-select')).map(el => + (el as HTMLElement).getAttribute('aria-label'), + ); + expect(labels).toEqual([null, null, 'PROFILES.ACTIVE_HOURS_END_HOUR', 'PROFILES.ACTIVE_HOURS_END_MINUTE', null]); + }); + + it('shows no group captions when there is only one time to give', () => { + setup({ activeHours: [], profileName: 'Default' }); + + expect(fieldLabels()).toEqual(['PROFILES.ACTIVE_HOURS_HOUR', 'PROFILES.ACTIVE_HOURS_MINUTE']); + expect(captions()).toEqual([]); + }); + }); }); 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 65ca567f..396e79f1 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/da.json @@ -568,6 +568,7 @@ "MIN_STARDUST_HINT": "0 = enhver stjernestøvsopgave", "POKECOINS": "PokéMønter", "POKECOINS_AMOUNT": "{{amount}}+ PokéMønter", + "REWARD_TYPE": "Belønningstype", "TAB_POKECOINS": "PokéMønter", "MIN_POKECOINS": "Mindste antal PokéMønter", "MIN_POKECOINS_HINT": "0 = enhver PokéMønt-opgave", @@ -968,6 +969,8 @@ "ACTIVE_HOURS_CLEAR_ALL": "Ryd alle", "ACTIVE_HOURS_REPEAT": "Gentag indtil et senere tidspunkt", "ACTIVE_HOURS_REPEAT_HINT": "Profilen slås til igen ved hver gentagelse, frem til sluttidspunktet.", + "ACTIVE_HOURS_STARTS_AT": "Starter", + "ACTIVE_HOURS_UNTIL": "Indtil", "ACTIVE_HOURS_END_HOUR": "Indtil (time)", "ACTIVE_HOURS_END_MINUTE": "Indtil (minut)", "ACTIVE_HOURS_REPEAT_EVERY": "Gentag hver", 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 005f7b09..6b2edd96 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/de.json @@ -568,6 +568,7 @@ "MIN_STARDUST_HINT": "0 = jede Sternenstaub-Aufgabe", "POKECOINS": "PokéMünzen", "POKECOINS_AMOUNT": "{{amount}}+ PokéMünzen", + "REWARD_TYPE": "Belohnungstyp", "TAB_POKECOINS": "PokéMünzen", "MIN_POKECOINS": "Mindestens PokéMünzen", "MIN_POKECOINS_HINT": "0 = jede PokéMünzen-Aufgabe", @@ -968,6 +969,8 @@ "ACTIVE_HOURS_CLEAR_ALL": "Alle löschen", "ACTIVE_HOURS_REPEAT": "Bis zu einer späteren Zeit wiederholen", "ACTIVE_HOURS_REPEAT_HINT": "Das Profil wird bei jeder Wiederholung erneut aktiviert, bis zur Endzeit.", + "ACTIVE_HOURS_STARTS_AT": "Beginnt um", + "ACTIVE_HOURS_UNTIL": "Bis", "ACTIVE_HOURS_END_HOUR": "Bis (Stunde)", "ACTIVE_HOURS_END_MINUTE": "Bis (Minute)", "ACTIVE_HOURS_REPEAT_EVERY": "Wiederholen alle", 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 f436662e..35b7f367 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/en.json @@ -568,6 +568,7 @@ "MIN_STARDUST_HINT": "0 = any stardust quest", "POKECOINS": "PokéCoins", "POKECOINS_AMOUNT": "{{amount}}+ PokéCoins", + "REWARD_TYPE": "Reward type", "TAB_POKECOINS": "PokéCoins", "MIN_POKECOINS": "Minimum PokéCoins", "MIN_POKECOINS_HINT": "0 = any PokéCoin quest", @@ -968,6 +969,8 @@ "ACTIVE_HOURS_CLEAR_ALL": "Clear All", "ACTIVE_HOURS_REPEAT": "Repeat until a later time", "ACTIVE_HOURS_REPEAT_HINT": "The profile switches on again at each repeat, up to the end time.", + "ACTIVE_HOURS_STARTS_AT": "Starts at", + "ACTIVE_HOURS_UNTIL": "Until", "ACTIVE_HOURS_END_HOUR": "Until (hour)", "ACTIVE_HOURS_END_MINUTE": "Until (minute)", "ACTIVE_HOURS_REPEAT_EVERY": "Repeat every", 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 eb96c2e5..17e82b8f 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/es.json @@ -568,6 +568,7 @@ "MIN_STARDUST_HINT": "0 = cualquier tarea de polvo estelar", "POKECOINS": "PokéMonedas", "POKECOINS_AMOUNT": "{{amount}}+ PokéMonedas", + "REWARD_TYPE": "Tipo de recompensa", "TAB_POKECOINS": "PokéMonedas", "MIN_POKECOINS": "PokéMonedas mínimas", "MIN_POKECOINS_HINT": "0 = cualquier tarea de PokéMonedas", @@ -968,6 +969,8 @@ "ACTIVE_HOURS_CLEAR_ALL": "Borrar todo", "ACTIVE_HOURS_REPEAT": "Repetir hasta una hora posterior", "ACTIVE_HOURS_REPEAT_HINT": "El perfil se activa de nuevo en cada repetición, hasta la hora final.", + "ACTIVE_HOURS_STARTS_AT": "Empieza a", + "ACTIVE_HOURS_UNTIL": "Hasta", "ACTIVE_HOURS_END_HOUR": "Hasta (hora)", "ACTIVE_HOURS_END_MINUTE": "Hasta (minuto)", "ACTIVE_HOURS_REPEAT_EVERY": "Repetir cada", 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 caf88847..e1ce713c 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/fr.json @@ -568,6 +568,7 @@ "MIN_STARDUST_HINT": "0 = toute étude en poussière étoilée", "POKECOINS": "Poképièces", "POKECOINS_AMOUNT": "{{amount}}+ Poképièces", + "REWARD_TYPE": "Type de récompense", "TAB_POKECOINS": "Poképièces", "MIN_POKECOINS": "Poképièces minimum", "MIN_POKECOINS_HINT": "0 = toute étude en Poképièces", @@ -968,6 +969,8 @@ "ACTIVE_HOURS_CLEAR_ALL": "Tout effacer", "ACTIVE_HOURS_REPEAT": "Répéter jusqu'à une heure ultérieure", "ACTIVE_HOURS_REPEAT_HINT": "Le profil se réactive à chaque répétition, jusqu'à l'heure de fin.", + "ACTIVE_HOURS_STARTS_AT": "Commence à", + "ACTIVE_HOURS_UNTIL": "Jusqu’à", "ACTIVE_HOURS_END_HOUR": "Jusqu'à (heure)", "ACTIVE_HOURS_END_MINUTE": "Jusqu'à (minute)", "ACTIVE_HOURS_REPEAT_EVERY": "Répéter toutes les", 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 1cce1a9b..1c01a63e 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/it.json @@ -568,6 +568,7 @@ "MIN_STARDUST_HINT": "0 = qualsiasi incarico con polvere di stelle", "POKECOINS": "PokéMonete", "POKECOINS_AMOUNT": "{{amount}}+ PokéMonete", + "REWARD_TYPE": "Tipo di ricompensa", "TAB_POKECOINS": "PokéMonete", "MIN_POKECOINS": "PokéMonete minime", "MIN_POKECOINS_HINT": "0 = qualsiasi incarico con PokéMonete", @@ -968,6 +969,8 @@ "ACTIVE_HOURS_CLEAR_ALL": "Cancella Tutto", "ACTIVE_HOURS_REPEAT": "Ripeti fino a un orario successivo", "ACTIVE_HOURS_REPEAT_HINT": "Il profilo si riattiva a ogni ripetizione, fino all'orario di fine.", + "ACTIVE_HOURS_STARTS_AT": "Inizia alle", + "ACTIVE_HOURS_UNTIL": "Fino a", "ACTIVE_HOURS_END_HOUR": "Fino a (ora)", "ACTIVE_HOURS_END_MINUTE": "Fino a (minuto)", "ACTIVE_HOURS_REPEAT_EVERY": "Ripeti ogni", 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 e9cca7fa..263fb70d 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/nl.json @@ -568,6 +568,7 @@ "MIN_STARDUST_HINT": "0 = elke sterrenstof-opdracht", "POKECOINS": "PokéMunten", "POKECOINS_AMOUNT": "{{amount}}+ PokéMunten", + "REWARD_TYPE": "Type beloning", "TAB_POKECOINS": "PokéMunten", "MIN_POKECOINS": "Minimaal PokéMunten", "MIN_POKECOINS_HINT": "0 = elke PokéMunten-opdracht", @@ -968,6 +969,8 @@ "ACTIVE_HOURS_CLEAR_ALL": "Alles Wissen", "ACTIVE_HOURS_REPEAT": "Herhalen tot een later tijdstip", "ACTIVE_HOURS_REPEAT_HINT": "Het profiel gaat bij elke herhaling opnieuw aan, tot de eindtijd.", + "ACTIVE_HOURS_STARTS_AT": "Begint om", + "ACTIVE_HOURS_UNTIL": "Tot", "ACTIVE_HOURS_END_HOUR": "Tot (uur)", "ACTIVE_HOURS_END_MINUTE": "Tot (minuut)", "ACTIVE_HOURS_REPEAT_EVERY": "Herhaal elke", 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 ff8c9d17..867f76fe 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pl.json @@ -568,6 +568,7 @@ "MIN_STARDUST_HINT": "0 = każde zadanie z gwiezdnym pyłem", "POKECOINS": "PokéMonety", "POKECOINS_AMOUNT": "{{amount}}+ PokéMonet", + "REWARD_TYPE": "Typ nagrody", "TAB_POKECOINS": "PokéMonety", "MIN_POKECOINS": "Minimalna liczba PokéMonet", "MIN_POKECOINS_HINT": "0 = każde zadanie z PokéMonetami", @@ -968,6 +969,8 @@ "ACTIVE_HOURS_CLEAR_ALL": "Wyczyść wszystko", "ACTIVE_HOURS_REPEAT": "Powtarzaj do późniejszej godziny", "ACTIVE_HOURS_REPEAT_HINT": "Profil włącza się ponownie przy każdym powtórzeniu, aż do godziny końcowej.", + "ACTIVE_HOURS_STARTS_AT": "Start o", + "ACTIVE_HOURS_UNTIL": "Do", "ACTIVE_HOURS_END_HOUR": "Do (godzina)", "ACTIVE_HOURS_END_MINUTE": "Do (minuta)", "ACTIVE_HOURS_REPEAT_EVERY": "Powtarzaj co", 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 4ca1523b..f4b7b1f5 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 @@ -568,6 +568,7 @@ "MIN_STARDUST_HINT": "0 = qualquer tarefa de poeira estelar", "POKECOINS": "PokéMoedas", "POKECOINS_AMOUNT": "{{amount}}+ PokéMoedas", + "REWARD_TYPE": "Tipo de recompensa", "TAB_POKECOINS": "PokéMoedas", "MIN_POKECOINS": "PokéMoedas mínimas", "MIN_POKECOINS_HINT": "0 = qualquer tarefa de PokéMoedas", @@ -968,6 +969,8 @@ "ACTIVE_HOURS_CLEAR_ALL": "Limpar Tudo", "ACTIVE_HOURS_REPEAT": "Repetir até um horário posterior", "ACTIVE_HOURS_REPEAT_HINT": "O perfil liga novamente a cada repetição, até o horário final.", + "ACTIVE_HOURS_STARTS_AT": "Começa às", + "ACTIVE_HOURS_UNTIL": "Até", "ACTIVE_HOURS_END_HOUR": "Até (hora)", "ACTIVE_HOURS_END_MINUTE": "Até (minuto)", "ACTIVE_HOURS_REPEAT_EVERY": "Repetir a cada", 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 42e77cc3..7684514b 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/pt.json @@ -568,6 +568,7 @@ "MIN_STARDUST_HINT": "0 = qualquer tarefa de pó estelar", "POKECOINS": "PokéMoedas", "POKECOINS_AMOUNT": "{{amount}}+ PokéMoedas", + "REWARD_TYPE": "Tipo de recompensa", "TAB_POKECOINS": "PokéMoedas", "MIN_POKECOINS": "PokéMoedas mínimas", "MIN_POKECOINS_HINT": "0 = qualquer tarefa de PokéMoedas", @@ -968,6 +969,8 @@ "ACTIVE_HOURS_CLEAR_ALL": "Limpar Tudo", "ACTIVE_HOURS_REPEAT": "Repetir até uma hora posterior", "ACTIVE_HOURS_REPEAT_HINT": "O perfil volta a ligar-se em cada repetição, até à hora de fim.", + "ACTIVE_HOURS_STARTS_AT": "Começa às", + "ACTIVE_HOURS_UNTIL": "Até", "ACTIVE_HOURS_END_HOUR": "Até (hora)", "ACTIVE_HOURS_END_MINUTE": "Até (minuto)", "ACTIVE_HOURS_REPEAT_EVERY": "Repetir a cada", 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 4391f9bb..1411d453 100644 --- a/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json +++ b/Applications/Pgan.PoracleWebNet.App/ClientApp/src/assets/i18n/sv.json @@ -568,6 +568,7 @@ "MIN_STARDUST_HINT": "0 = alla stjärnstoftsuppdrag", "POKECOINS": "PokéMynt", "POKECOINS_AMOUNT": "{{amount}}+ PokéMynt", + "REWARD_TYPE": "Belöningstyp", "TAB_POKECOINS": "PokéMynt", "MIN_POKECOINS": "Minsta antal PokéMynt", "MIN_POKECOINS_HINT": "0 = alla PokéMynt-uppdrag", @@ -968,6 +969,8 @@ "ACTIVE_HOURS_CLEAR_ALL": "Rensa alla", "ACTIVE_HOURS_REPEAT": "Upprepa till en senare tid", "ACTIVE_HOURS_REPEAT_HINT": "Profilen slås på igen vid varje upprepning, fram till sluttiden.", + "ACTIVE_HOURS_STARTS_AT": "Börjar", + "ACTIVE_HOURS_UNTIL": "Till", "ACTIVE_HOURS_END_HOUR": "Till (timme)", "ACTIVE_HOURS_END_MINUTE": "Till (minut)", "ACTIVE_HOURS_REPEAT_EVERY": "Upprepa var", diff --git a/CHANGELOG.md b/CHANGELOG.md index b4ab55b6..bd4dae93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **The *Until* fields in the schedule editor say what they are.** Turning on a repeat adds an end time, and both its labels rendered cut off -- *Until (hou*, *Until (mir* -- because the field has to stay narrow enough for an hour and a minute to sit side by side on a phone. The qualifier has moved out of the fields and onto the pair it describes, so the row now reads *Starts at* over one hour-and-minute pair and *Until* over the other, and each field is labelled by the short word it asks for. Screen readers still hear the full wording. This is the editor quest summary schedules use as well. +- **The sixth quest reward type fits.** PokéCoins arrived as a sixth tab in a strip that had already been trimmed twice to hold five, and at dialog width its label was clipped to *Pok* -- selected or not -- with the strip offering a pagination arrow rather than scrolling it into view. The words are longer still in most of the other ten languages, so the next trim would have clipped Stardust as well. The reward is now chosen from a single *Reward type* list, which fits at any width in any language and matches how the Pokemon dialog handles its own crowded first tab. Nothing about the alarms changes: the same six rewards, in the same order, saving the same rules. +- **Alarm cards no longer read *Pokemon #1* on a cold load.** Open Pokemon, Raids, Nests or Max Battles as the first page of a session and every card was titled by its species number rather than its name; leaving the page and coming back fixed it, which is why it survived this long. The names arrive from Poracle a moment after the cards do, and the lists had no way of noticing: they asked for the data, painted once and never looked again. The name tables are now reactive, so the cards -- and the search and sort that read the same names -- redraw themselves the moment the names land. A Poracle that never answers leaves the number showing, exactly as it did. - **Six documentation screenshots carried the deployment's own branding, and two of them a real Discord avatar photo.** An audit of all seventy committed images found the site title "PoGO Alerts Network" in the toolbar of `areas`, `user-menu` (both the docs and in-app copies), `pokemon-add-dialog`, `fort-changes-add-dialog` and `scope-picker`, the operator's custom back-to-map nav link beside it, and beneath the grey silhouette placeholder on three of them the original avatar photo still showing around the rim, where an earlier pass had pasted a circle a few pixels too small. The three dialog shots were retaken against an anonymous session; `areas` and the two `user-menu` copies had the toolbar band replaced, which fixes all three leaks at once without discarding the configured pin, places and areas that made those images worth having. The other sixty-four images are clean. - **Deleting a saved place that alarms still point at says which alarms.** The refusal already carried the list, but each entry arrived as a fragment of raw JSON rather than a name, so there was nothing worth showing and the count was all anyone could use. It now reads as "raid 424". Deleting a place was also the last thing on this page still talking to Poracle's older API while everything around it had moved. - **Your alert language stops being forgotten when it is Portuguese (BR).** Poracle stores what it is given in lower case, so `pt-BR` comes back as `pt-br`, and the language picker was comparing the two exactly -- finding no match, discarding the answer and quietly falling back to the server's own language. It affected anyone whose alert language was set through the Poracle bot as well. The comparison ignores case now.