From 4cb759b6282a02fe8894bc5eb6dad4f7a21d6775 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Fri, 21 Aug 2026 18:06:37 +0200 Subject: [PATCH] feat(reputation): add the reusable Regional Reputation display component --- .../reputation-display.component.html | 25 +++++ .../reputation-display.component.scss | 52 ++++++++++ .../reputation-display.component.spec.ts | 95 +++++++++++++++++++ .../reputation-display.component.ts | 28 ++++++ 4 files changed, 200 insertions(+) create mode 100644 apps/web/src/app/shared/reputation-display/reputation-display.component.html create mode 100644 apps/web/src/app/shared/reputation-display/reputation-display.component.scss create mode 100644 apps/web/src/app/shared/reputation-display/reputation-display.component.spec.ts create mode 100644 apps/web/src/app/shared/reputation-display/reputation-display.component.ts diff --git a/apps/web/src/app/shared/reputation-display/reputation-display.component.html b/apps/web/src/app/shared/reputation-display/reputation-display.component.html new file mode 100644 index 0000000..0034d28 --- /dev/null +++ b/apps/web/src/app/shared/reputation-display/reputation-display.component.html @@ -0,0 +1,25 @@ +
+
+ {{ entry().factionName }} + {{ entry().rankLabel }} +
+ + @if (entry().nextThreshold !== null) { +
+ + {{ entry().reputation }} / {{ entry().nextThreshold }} +
+ } @else { +

{{ entry().reputation }} — Höchster Rang erreicht

+ } +
diff --git a/apps/web/src/app/shared/reputation-display/reputation-display.component.scss b/apps/web/src/app/shared/reputation-display/reputation-display.component.scss new file mode 100644 index 0000000..4a6499f --- /dev/null +++ b/apps/web/src/app/shared/reputation-display/reputation-display.component.scss @@ -0,0 +1,52 @@ +.reputation-display { + display: grid; + gap: var(--ar-space-2); + padding: var(--ar-space-3); + border: 1px solid var(--ar-border); + border-radius: var(--ar-radius-sm); + background: var(--ar-panel); +} + +.reputation-display__header { + display: flex; + justify-content: space-between; + font-family: Georgia, 'Times New Roman', serif; +} + +.reputation-display__rank { + color: var(--ar-gold); +} + +.reputation-display__progress { + display: flex; + align-items: center; + gap: var(--ar-space-2); +} + +.reputation-display__track { + position: relative; + flex: 1; + block-size: 0.5rem; + border-radius: var(--ar-radius-sm); + background: var(--ar-border); + overflow: hidden; +} + +.reputation-display__fill { + position: absolute; + inset-block: 0; + inset-inline-start: 0; + background: var(--ar-gold); +} + +.reputation-display__value { + font-variant-numeric: tabular-nums; + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); +} + +.reputation-display__max { + margin: 0; + color: var(--ar-gold); + font-size: var(--ar-font-sm); +} diff --git a/apps/web/src/app/shared/reputation-display/reputation-display.component.spec.ts b/apps/web/src/app/shared/reputation-display/reputation-display.component.spec.ts new file mode 100644 index 0000000..da84c60 --- /dev/null +++ b/apps/web/src/app/shared/reputation-display/reputation-display.component.spec.ts @@ -0,0 +1,95 @@ +import { TestBed } from '@angular/core/testing'; +import type { ReputationEntry } from '../../core/api/game-api.models'; +import { ReputationDisplayComponent } from './reputation-display.component'; + +async function setup(entry: ReputationEntry) { + await TestBed.configureTestingModule({ imports: [ReputationDisplayComponent] }).compileComponents(); + const fixture = TestBed.createComponent(ReputationDisplayComponent); + fixture.componentRef.setInput('entry', entry); + fixture.detectChanges(); + return fixture; +} + +describe('ReputationDisplayComponent', () => { + it('renders the faction name and rank', async () => { + const fixture = await setup({ + factionKey: 'border-guard', + factionName: 'Grenzwacht', + reputation: 320, + rank: 'KNOWN', + rankLabel: 'Bekannt', + nextThreshold: 500, + }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.textContent).toContain('Grenzwacht'); + expect(element.textContent).toContain('Bekannt'); + }); + + it('shows current reputation against the next threshold', async () => { + const fixture = await setup({ + factionKey: 'border-guard', + factionName: 'Grenzwacht', + reputation: 320, + rank: 'KNOWN', + rankLabel: 'Bekannt', + nextThreshold: 500, + }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.textContent).toContain('320'); + expect(element.textContent).toContain('500'); + }); + + it('renders a progress fraction between 0 and 100', async () => { + const fixture = await setup({ + factionKey: 'border-guard', + factionName: 'Grenzwacht', + reputation: 320, + rank: 'KNOWN', + rankLabel: 'Bekannt', + nextThreshold: 500, + }); + const element = fixture.nativeElement as HTMLElement; + + const fill = element.querySelector('[data-reputation-progress-fill]'); + const width = parseFloat(fill?.style.inlineSize ?? '0'); + expect(width).toBeGreaterThan(0); + expect(width).toBeLessThanOrEqual(100); + }); + + it('handles the top rank with no next threshold without crashing or showing a bogus progress bar', async () => { + const fixture = await setup({ + factionKey: 'border-guard', + factionName: 'Grenzwacht', + reputation: 1500, + rank: 'ESTEEMED', + rankLabel: 'Geachtet', + nextThreshold: null, + }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.textContent).toContain('Geachtet'); + expect(element.querySelector('[data-reputation-max]')).toBeTruthy(); + }); + + it('renders a sensible zero-reputation row for a faction never interacted with', async () => { + const fixture = await setup({ + factionKey: 'silent-order', + factionName: 'Der Stille Orden', + reputation: 0, + rank: 'STRANGER', + rankLabel: 'Fremder', + nextThreshold: 100, + }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.textContent).toContain('Der Stille Orden'); + expect(element.textContent).toContain('Fremder'); + expect(element.textContent).toContain('0'); + + const fill = element.querySelector('[data-reputation-progress-fill]'); + const width = parseFloat(fill?.style.inlineSize ?? '-1'); + expect(width).toBe(0); + }); +}); diff --git a/apps/web/src/app/shared/reputation-display/reputation-display.component.ts b/apps/web/src/app/shared/reputation-display/reputation-display.component.ts new file mode 100644 index 0000000..209eb99 --- /dev/null +++ b/apps/web/src/app/shared/reputation-display/reputation-display.component.ts @@ -0,0 +1,28 @@ +import { Component, computed, input } from '@angular/core'; +import type { ReputationEntry } from '../../core/api/game-api.models'; + +/** + * Reusable Regional Reputation presentation (spec §27). No dedicated screen + * hosts this yet -- it is built and tested standalone so a Character + * screen, merchant, or NPC dialog can embed it later. + */ +@Component({ + selector: 'app-reputation-display', + templateUrl: './reputation-display.component.html', + styleUrl: './reputation-display.component.scss', +}) +export class ReputationDisplayComponent { + readonly entry = input.required(); + + // Progress toward the next threshold, measured from 0 rather than from the + // current rank's own floor: the API DTO exposes `reputation` and + // `nextThreshold` only. A more precise bar would need a `currentThreshold` + // field that nothing else needs yet, so this stays coarse on purpose. + protected readonly progressPercent = computed(() => { + const entry = this.entry(); + if (entry.nextThreshold === null) { + return 100; + } + return Math.min(100, Math.max(0, (entry.reputation / entry.nextThreshold) * 100)); + }); +}