fix(mobile-nav): dock Journey by default, move Atlas one tap further

Feedback from Discord: on a phone in the field the two things you reach
for are the itinerary and the journal, but the default dock spends its
two free slots on Vacay and Atlas. Journey sits under More, and most
people never open the navbar customiser to fix that.

DEFAULT_DOCK_IDS becomes a priority list instead of a membership test:
the first MOBILE_NAV_MAX_BAR entries that are actually enabled take the
slots. Journey ships disabled, so an instance that never turned it on
keeps the exact Vacay/Atlas dock it has today. Listing all four known
global addons also fills the dock when one of them is switched off,
where the old membership test left the slot empty.

Accounts that saved their own bar/more split are untouched: a non-empty
config still wins over the default, so nobody's arrangement moves.
This commit is contained in:
Maurice
2026-09-07 23:15:53 +02:00
committed by Maurice
parent f1120be3c1
commit c04e740ff1
3 changed files with 48 additions and 15 deletions
+22 -3
View File
@@ -25,14 +25,33 @@ describe('buildNavItems', () => {
})
describe('splitMobileNav', () => {
it('falls back to Dashboard + first two in the bar when there is no config', () => {
it('docks the top two enabled defaults when there is no config', () => {
for (const cfg of [undefined, { bar: [], more: [] }]) {
const s = splitMobileNav(items, cfg)
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
expect(ids(s.more)).toEqual(['journey', 'collections'])
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'journey'])
// Atlas is still reachable, one tap further under More.
expect(ids(s.more)).toEqual(['atlas', 'collections'])
}
})
it('keeps the Vacay/Atlas dock on an instance where Journey is switched off', () => {
const s = splitMobileNav([item('dashboard'), item('vacay'), item('atlas')], undefined)
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
expect(ids(s.more)).toEqual([])
})
it('fills the free slot from the next default rather than leaving it empty', () => {
const s = splitMobileNav([item('dashboard'), item('atlas'), item('journey')], undefined)
expect(ids(s.bar)).toEqual(['dashboard', 'journey', 'atlas'])
expect(ids(s.more)).toEqual([])
})
it('leaves an account that picked its own dock alone', () => {
const s = splitMobileNav(items, { bar: ['vacay', 'atlas'], more: ['journey', 'collections'] })
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
expect(ids(s.more)).toEqual(['journey', 'collections'])
})
it('honours a custom split and order, Dashboard still first', () => {
const s = splitMobileNav(items, { bar: ['journey'], more: ['collections', 'vacay', 'atlas'] })
expect(ids(s.bar)).toEqual(['dashboard', 'journey'])
+24 -10
View File
@@ -35,8 +35,18 @@ const ADDON_ICONS: Record<string, LucideIcon> = {
/** The bar holds Dashboard + at most this many custom items; the rest go to More.
* Two keeps the dock at 3 destinations (Dashboard + 2) so the More slot still fits. */
export const MOBILE_NAV_MAX_BAR = 2
/** The built-in dock next to Dashboard for an un-customised account; everything else starts under More. */
export const DEFAULT_DOCK_IDS = ['vacay', 'atlas']
/**
* The built-in dock next to Dashboard for an un-customised account, in priority
* order: the first MOBILE_NAV_MAX_BAR of these that are actually enabled take the
* slots, everything else starts under More.
*
* Journey outranks Atlas because a phone in the field wants the journal it writes
* to every day, not the map of countries it already visited. Journey ships
* disabled though, so an instance that never turned it on keeps the Vacay/Atlas
* dock it has today. Listing all four known global addons rather than just the
* top two also keeps the dock filled when one of them is switched off.
*/
export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections']
export function buildNavItems(
globalAddons: { id: string; name: string; icon: string }[],
@@ -90,10 +100,10 @@ export interface MobileNavSplit {
/**
* Resolve the persisted `{ bar, more }` id split against the live nav items.
* Dashboard is always pinned first in the bar. An empty/absent config falls back
* to the built-in dock (Dashboard + Vacay/Atlas where enabled, everything else
* under More) so an un-customised user sees exactly today's layout. Stored ids
* that no longer resolve are dropped; newly available items are appended under
* More.
* to the built-in dock (Dashboard + the top DEFAULT_DOCK_IDS that are enabled,
* everything else under More). Stored ids that no longer resolve are dropped;
* newly available items are appended under More, so an account that once picked
* its own dock keeps it untouched.
*/
export function splitMobileNav(
items: NavItemDef[],
@@ -102,16 +112,20 @@ export function splitMobileNav(
const dashboard = items.find((i) => i.id === 'dashboard')
const head = dashboard ? [dashboard] : []
const rest = items.filter((i) => i.id !== 'dashboard')
const byId = new Map(rest.map((i) => [i.id, i]))
const pick = (ids: string[]) => ids.map((id) => byId.get(id)).filter((x): x is NavItemDef => !!x)
if (!cfg || (cfg.bar.length === 0 && cfg.more.length === 0)) {
const dock = pick(DEFAULT_DOCK_IDS).slice(0, MOBILE_NAV_MAX_BAR)
const docked = new Set(dock.map((i) => i.id))
return {
bar: [...head, ...rest.filter((i) => DEFAULT_DOCK_IDS.includes(i.id))],
more: rest.filter((i) => !DEFAULT_DOCK_IDS.includes(i.id)),
bar: [...head, ...dock],
// Store order, not DEFAULT_DOCK_IDS order, so More keeps reading like the
// addon list itself.
more: rest.filter((i) => !docked.has(i.id)),
}
}
const byId = new Map(rest.map((i) => [i.id, i]))
const pick = (ids: string[]) => ids.map((id) => byId.get(id)).filter((x): x is NavItemDef => !!x)
const known = new Set([...cfg.bar, ...cfg.more])
const barPicked = pick(cfg.bar)
@@ -38,8 +38,8 @@ describe('useMobileNavEditor', () => {
it('FE-COMP-NAVEDITOR-001: an empty value falls back to the built-in dock split', () => {
const { result } = setup({ bar: [], more: [] });
expect(ids(result.current.barItems)).toEqual(['vacay', 'atlas']);
expect(ids(result.current.moreItems)).toEqual(['journey', 'collections']);
expect(ids(result.current.barItems)).toEqual(['vacay', 'journey']);
expect(ids(result.current.moreItems)).toEqual(['atlas', 'collections']);
expect(result.current.hasMore).toBe(true);
expect(result.current.barFull).toBe(true);
});