fix(dashboard): keep standard salary amounts readable

This commit is contained in:
cesnimda
2026-10-21 23:49:54 +02:00
parent 9604589785
commit 2f6227fb4f
8 changed files with 78 additions and 2 deletions
+5 -1
View File
@@ -34,6 +34,10 @@ Extend the existing account preference owner (`ApplicationUser`) with nullable l
SQLite and MariaDB use provider-neutral EF mapping and conditional updates. Explicitly check the existing SQLite startup schema reconciliation/migration infrastructure and the complete migration chain. Legacy accounts gain no layout until first Save. Language, notifications, profile, security stamps and all job/document data remain unchanged.
### Adding or changing widgets later
Add a stable ID to both `src/dashboardLayout.ts` and the backend `DashboardLayout` allowlist, supply EN/NB titles and the existing report/data owner, then prove allowed widths, empty/error states and ownership. Do not recycle an ID for another meaning. Existing saved layouts append missing known IDs hidden; unsaved accounts receive catalogue defaults. Unknown stored IDs are ignored on read and rejected on write, so preserving metadata across an incompatible client requires a schema-version increment. Future versions are read-only in an older client and cannot be downgraded by its PUT. A genuine rename/removal needs an explicit versioned ID migration and regression proof; there is no speculative alias framework. Feature availability must remain enforced by the data endpoint, independently of layout metadata.
## Risk and dependency register
- Phase 7 is CONDITIONAL GO, not unconditional release acceptance. Read [final acceptance](../phase-7/p7-12/FINAL_ACCEPTANCE.md) and [handoff](../phase-7/p7-12/PHASE8_HANDOFF.md).
@@ -49,7 +53,7 @@ SQLite and MariaDB use provider-neutral EF mapping and conditional updates. Expl
- [x] P8-01: editing, visibility, draft/cancel, empty recovery, localisation; focused component tests and shared P8-01/02 checkpoint commit/push.
- [x] P8-02: existing drag helper, named move buttons, DOM ordering and discrete grid sizing; normalisation/focus tests and shared P8-01/02 checkpoint commit/push.
- [x] P8-03: additive account storage, validated endpoint, revision conflict, retry/reset/session isolation; backend/frontend integration proof and checkpoint commit/push.
- [ ] P8-04: actual browser desktop/tablet/mobile, EN/NB, themes, keyboard, failures and axe; inspect captures, retain human qualifications; commit/push.
- [x] P8-04: actual browser desktop/tablet/mobile, EN/NB, themes, keyboard, failures and axe; inspected captures and populated sizing regression; human qualifications retained; checkpoint commit/push.
- [ ] P8-05: full backend/frontend/browser, separate standard contracts/sidecar, types/lint/build/security; 57-group reconciliation, final decisions and handoff; commit/push and direct remote verification.
## Implementation and verification plan
+3
View File
@@ -10,8 +10,11 @@ Viewports320/390/768/1440px cover editor EN/Bokmål and light/dark; retained das
Forty-two initial Dashboard/editor PNGs were inspected through four contact sheets and representative full-size crops. Final desktop/light/English and320px/dark/Bokmål editor crops were separately inspected after the focus repair; final raw qualification contains60 distinct screenshots including Analytics. This does not claim an individual full-size inspection of every image. Exact metric/month/sample/source-link and persisted API assertions supply behavioral evidence beyond screenshots.
Two final-build captures from the uninterrupted whole-browser run are retained with this checkpoint: [English/light desktop editor](evidence/editor-en-light-1440.png) and [Bokmål/dark320px editor](evidence/editor-nb-dark-320.png). Both were inspected; they show the repaired edit-entry position and short visible size labels with the complete control names preserved in the accessibility tree.
## Reproduced issues and repairs
- A subsequent populated sizing review found salary amounts breaking into digit fragments at standard width despite passing overflow checks. The new diagnostic first failed its actual text-line assertion (one failed, one passed), then passed both complete standard/full cases after the standard personal widget's salary groups became a single column. Full-width and Analytics retain their existing three-column desktop layout. All eight resizable widgets were saved/read back at both sizes and measured at320/768/1024/1440px, with tables/charts contained and no root overflow. Eight captures were produced; the standard1024 capture was inspected and its [salary crop](evidence/salary-standard-1024.png) retained. The complete ordinary browser suite was then rerun against the rebuilt bundle; final results are recorded in acceptance.
- Edit entry focused the panel beneath the fixed app header. A browser geometry check first failed (`focus-red.json`); deliberate preventScroll focus, the existing `--app-shell-header-height` scroll margin and explicit scroll now pass at320/768/1440px. The final reviewed crops show the heading and Save/Cancel controls below the header.
- Pending-save route navigation offered a misleading discard promise. A deferred acknowledgement unit regression first failed, then passed; SPA navigation now waits with a localized status while saving. Browser unload warnings do not promise rollback of server writes.
- Axe's incomplete triage identified a labelled preview `div` without a valid role. A role-based test failed first (`preview-semantics-red.log`); the preview is now a named region. The new editor browser case explicitly rejects `aria-prohibited-attr` incompletes. Final scan totals are in final acceptance; the earlier ten scans had0 automated violations and68 incomplete nodes (61contrast,6preview semantics,1drawer focus), so those earlier results are not presented as the repaired totals.
@@ -0,0 +1,8 @@
import { defineConfig } from '../../../../../job-tracker-ui/node_modules/@playwright/test';
import base from '../../../../../job-tracker-ui/playwright.config';
import path from 'node:path';
const cwd = path.resolve(__dirname, '../../../../../job-tracker-ui');
const servers = Array.isArray(base.webServer) ? base.webServer : [base.webServer!];
export default defineConfig({ ...base, testDir: __dirname, testMatch: 'sizes.spec.ts',
webServer: servers.map(server => ({ ...server, cwd })) });
@@ -0,0 +1,61 @@
import { test, expect, type APIRequestContext } from '../../../../../job-tracker-ui/node_modules/@playwright/test';
import { WIDGETS } from '../../../../../job-tracker-ui/src/dashboardLayout';
const api = 'http://localhost:5302/api';
const password = 'E2ePassword123!';
async function login(request: APIRequestContext, email: string) {
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
const me = await (await request.get(`${api}/auth/me`)).json();
const csrf = (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string };
}
for (const size of ['standard', 'full'] as const) test(`All populated widgets at ${size} across responsive widths`, async ({ page, playwright }, info) => {
test.setTimeout(120000);
const admin = await playwright.request.newContext();
try {
const adminHeaders = await login(admin, 'e2e@example.test');
const email = `p8-sizes-${size}-${Date.now()}@example.test`;
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
const headers = await login(page.request, email);
const created = await page.request.post(`${api}/companies`, { headers, data: { name: 'Very long Norwegian company name for readable widget layouts', source: 'Native size qualification' } });
expect(created.status()).toBe(201);
const companyId = (await created.json()).id;
expect((await page.request.post(`${api}/jobapplications`, { headers, data: { companyId, jobTitle: 'Senior platform developer', status: 'Applied', dateApplied: new Date(Date.now() - 86400000).toISOString(), responseReceived: true, responseDate: new Date().toISOString(), tags: 'TypeScript, Norsk programvareutvikling', salaryMin: 600000, salaryMax: 850000, salaryCurrency: 'NOK', salaryPeriod: 'year' } })).status()).toBe(201);
await page.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
await page.goto('/dashboard');
await page.getByRole('button', { name: 'Customize dashboard', exact: true }).click();
const editor = page.getByRole('region', { name: 'Editing dashboard', exact: true });
for (const widget of WIDGETS) if (widget.sizes.length > 1) {
const select = editor.locator(`[data-layout-row="${widget.id}"] select`);
await select.selectOption(size); await expect(select).toHaveValue(size);
}
await editor.getByRole('button', { name: 'Save layout', exact: true }).click();
await expect(editor).toHaveCount(0);
const readback = await (await page.request.get(`${api}/auth/preferences/dashboard`)).json();
expect(readback.widgets.every((widget: { id: string; size: string }) => widget.size === (widget.id === 'cards' ? 'full' : size))).toBe(true);
for (const summary of await page.locator('[data-dashboard-widget] details > summary').all()) await summary.click();
for (const width of [320, 768, 1024, 1440]) {
await page.setViewportSize({ width, height: 900 });
await expect(page.locator('[data-dashboard-widget]')).toHaveCount(9);
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
const failures = await page.locator('[data-dashboard-widget]').evaluateAll(widgets => widgets.flatMap(widget => {
const box = widget.getBoundingClientRect();
return [...widget.querySelectorAll('table, svg, th, td')].filter(element => {
const rect = element.getBoundingClientRect();
return rect.width > 0 && (rect.right > box.right + 1 || rect.left < box.left - 1);
}).map(element => ({ widget: widget.getAttribute('data-dashboard-widget'), tag: element.tagName }));
}));
expect(failures).toEqual([]);
const amount = page.locator('[data-dashboard-widget="salary"]').getByText(new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'NOK', maximumFractionDigits: 0 }).format(725000), { exact: true });
await expect(amount).toBeVisible();
expect(await amount.evaluate(element => {
const range = document.createRange(); range.selectNodeContents(element);
return range.getClientRects().length;
})).toBe(1);
const screenshot = info.outputPath(`${size}-${width}.png`);
await page.screenshot({ path: screenshot, fullPage: true });
await info.attach(`${size}-${width}`, { path: screenshot, contentType: 'image/png' });
}
} finally { await admin.dispose(); }
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -273,7 +273,7 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
</Box>)}</Stack>
</PageSection> : null,
salary: summaryReady && (overview?.salaryInsights?.length ?? 0) > 0 ? <PageSection title={t("dashboardSalaryInsights")} description={t("dashboardSalaryDefinition")}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: reporting || activeLayout.widgets.find(widget => widget.id === "salary")?.size === "full" ? "repeat(3, minmax(0, 1fr))" : "1fr" }, gap: 2 }}>
{overview!.salaryInsights!.map(item => {
const money = new Intl.NumberFormat(locale, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
return <Box key={item.currency + item.period} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 1 }}>