refactor: restructure navigation into 5 grouped hubs, off the 22-item flat strip

Implements the researched IA fix: mobile nav UX consensus caps primary
destinations at 4-5 (uxpin.com, fintech/banking 2026 UX research), and
this app had grown to 22 tabs in one horizontally-scrolling row —
exactly the anti-pattern that research flags for choice paralysis and
slower task completion.

New structure — 5 bottom-nav hubs, grouped by what the user is actually
trying to do, not build order:
- Home: Coverage (unchanged, still the landing screen)
- Estate: Assets, Faraid, Insurance, Wassiyah, Hibah, Family Waqf,
  Nominate, Claims (H2)
- Giving: Zakat, Sadaqah, Khairat
- Family: Tree, Trigger, Mutawalli, Manage (was 'Family', renamed to
  avoid colliding with the hub's own label), Neighbourhood
- Daily: Prayer Times, Qibla, Quran, Locate

Each hub reveals its own sub-nav one tap in, instead of every tab
competing for space in a single row. Settings moved out of the tab
strip entirely into a header gear icon, matching the banking-app pattern
of keeping settings out of primary thumb-reach real estate. The bottom
nav is now fixed (thumb-zone), sub-nav keeps the old sticky-top position.

Cross-component navigation (nav.js requestedTab, used by the Family
Tree's 'give sadaqah in memory' link) now resolves a tab label to its
owning (hub, sub-tab) pair instead of a flat index — verified live.

This touches every E2E suite: a single click on a tab's old selector no
longer reaches it (hub, then sub-tab). Added a shared gotoTab(page, label)
helper to e2e-auth-helper.cjs encapsulating the two-step navigation, and
migrated all ~22 affected test files off direct nav-button selectors —
mechanical substitution followed by manual fixes for local clickTab
wrappers, template-literal selectors, and active-state assertions that
needed to target the new .hub-tab/.subnav structure specifically.

Full regression after migration: every suite passes (one isolated
Family Tree flake confirmed clean on rerun, unrelated to navigation).
This commit is contained in:
2026-08-14 14:55:09 +08:00
parent 78e025c27d
commit 33a821e61d
23 changed files with 250 additions and 136 deletions
+14 -13
View File
@@ -1,7 +1,7 @@
// Full E2E UAT against the live moslem04.falahos.my deployment.
// Simulates real human interaction: clicks, typed input, waits — not just DOM assertions.
const { chromium } = require('playwright');
const { signInFreshFamily } = require('./e2e-auth-helper.cjs');
const { signInFreshFamily, gotoTab } = require('./e2e-auth-helper.cjs');
const BASE = 'https://moslem04.falahos.my/';
const results = [];
@@ -25,15 +25,16 @@ async function main() {
const tabs = ['Faraid', 'Assets', 'Wassiyah', 'Hibah', 'Family Waqf', 'Claims (H2)', 'Settings'];
for (const label of tabs) {
const tabBtn = page.locator('nav button.tab', { hasText: label });
await tabBtn.click();
await gotoTab(page, label);
await page.waitForTimeout(500);
const isActive = await tabBtn.evaluate(el => el.classList.contains('active'));
const isActive = label === 'Settings'
? await page.locator('.settings-btn').evaluate(el => el.classList.contains('active'))
: await page.locator('.subnav .tab', { hasText: label }).evaluate(el => el.classList.contains('active'));
record(`Nav: click "${label}" tab activates it`, isActive);
}
// ── Faraid Calculator: textbook case (wife + daughter + father + mother) ──
await page.locator('nav button.tab', { hasText: 'Faraid' }).click();
await gotoTab(page, 'Faraid');
await page.waitForTimeout(500);
await page.locator('.field:has-text("Number of surviving wives") input').fill('1');
await page.locator('.field:has-text("Daughters") input').fill('1');
@@ -48,7 +49,7 @@ async function main() {
record('Faraid: wife+daughter+father+mother produces textbook shares', hasWife && hasDaughter && hasMother && hasFather, shareRows.join(' | '));
// ── Asset Registry: add an asset ──
await page.locator('nav button.tab', { hasText: 'Assets' }).click();
await gotoTab(page, 'Assets');
await page.waitForTimeout(500);
await page.locator('.field:has-text("Description") input').fill('Terrace house, Shah Alam');
await page.locator('.field:has-text("Estimated value") input').fill('600000');
@@ -70,7 +71,7 @@ async function main() {
record('Asset Registry: second asset accumulates total', total2.includes('750,000'), total2);
// ── Wassiyah Generator: 1/3 meter + heir-exclusion block ──
await page.locator('nav button.tab', { hasText: 'Wassiyah' }).click();
await gotoTab(page, 'Wassiyah');
await page.waitForTimeout(500);
const capText = await page.locator('.meter-row:has-text("One-third limit") strong').textContent();
record('Wassiyah: one-third meter reflects Asset Registry total (750,000/3=250,000)', capText.includes('250,000'), capText);
@@ -110,7 +111,7 @@ async function main() {
record('Wassiyah: acknowledging override enables export', exportEnabledAfterAck);
// ── Hibah Tracker: marad al-mawt guard, blocked-heir path ──
await page.locator('nav button.tab', { hasText: 'Hibah' }).click();
await gotoTab(page, 'Hibah');
await page.waitForTimeout(500);
await page.locator('.field:has-text("Recipient") input').fill('My Daughter');
await page.locator('.field:has-text("Relation to you") input').fill('daughter');
@@ -127,7 +128,7 @@ async function main() {
record('Hibah: flagged + heir beneficiary blocks confirm', guardErrorVisible && confirmDisabled, 'recipient=daughter, flagged=yes');
// ── Family Waqf Designator: open note + beneficiary flow ──
await page.locator('nav button.tab', { hasText: 'Family Waqf' }).click();
await gotoTab(page, 'Family Waqf');
await page.waitForTimeout(500);
const openNoteVisible = await page.locator('.open-note').isVisible();
record('Family Waqf: open fiqh-question note is visible (OPEN-01 transparency)', openNoteVisible);
@@ -145,7 +146,7 @@ async function main() {
await page.waitForTimeout(500);
// ── Horizon 2 Claims: pre-pilot banner + issue + transfer restriction ──
await page.locator('nav button.tab', { hasText: 'Claims (H2)' }).click();
await gotoTab(page, 'Claims (H2)');
await page.waitForTimeout(500);
const pilotBannerVisible = await page.locator('.pilot-banner').isVisible();
const bannerText = await page.locator('.pilot-banner').textContent();
@@ -171,7 +172,7 @@ async function main() {
record('Claims: transfer-within-pool updates status', statusAfterTransfer.trim() === 'transferred', statusAfterTransfer);
// ── Settings: export + delete-all guarded by confirm() ──
await page.locator('nav button.tab', { hasText: 'Settings' }).click();
await gotoTab(page, 'Settings');
await page.waitForTimeout(500);
const exportBtnVisible = await page.locator('button.btn-secondary', { hasText: 'Export local export' }).isVisible();
const deleteBtnVisible = await page.locator('button.btn-danger', { hasText: 'Delete local device data' }).isVisible();
@@ -188,13 +189,13 @@ async function main() {
page.once('dialog', async d => { record('Settings: delete-all is guarded by a confirm() dialog', d.type() === 'confirm'); await d.dismiss(); });
await page.locator('button.btn-danger', { hasText: 'Delete local device data' }).click();
await page.waitForTimeout(500);
await page.locator('nav button.tab', { hasText: 'Assets' }).click();
await gotoTab(page, 'Assets');
await page.waitForTimeout(500);
const dataStillThereAfterDismiss = await page.locator('.asset-row', { hasText: 'Terrace house' }).isVisible();
record('Settings: dismissing delete confirm leaves data intact', dataStillThereAfterDismiss);
// ── Bilingual: language switcher click actually changes visible UI text ──
await page.locator('nav button.tab', { hasText: 'Settings' }).click();
await gotoTab(page, 'Settings');
await page.waitForTimeout(500);
const taglineBefore = await page.locator('.header-tagline').textContent();
await page.locator('.lang-btn', { hasText: 'Bahasa Malaysia' }).click();