mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
feat(map): add per-trip connections-visibility resolution util
Pure storage/resolution logic for a trip's booking-route visibility preference, keyed trek:visible-connections:<tripId>. Two modes: 'only' (nothing shown except the listed ids — today's existing behavior; a legacy bare-array localStorage value parses as this mode for backward compatibility) and 'all-except' (everything routable shown except the listed ids). A trip with no stored preference falls back to the account-wide default (all or nothing) without writing anything, so flipping the account setting later never silently overrides a trip with an explicit per-trip choice already recorded. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
parseStoredConnections, resolveEffectiveConnections, resolveVisibleConnectionIds,
|
||||
toggleConnectionId, toggleAllConnections,
|
||||
} from './connectionsVisibility'
|
||||
|
||||
describe('parseStoredConnections', () => {
|
||||
it('returns null when nothing was ever stored', () => {
|
||||
expect(parseStoredConnections(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('reads a legacy bare-array value as "only" mode', () => {
|
||||
expect(parseStoredConnections('[1,2,3]')).toEqual({ mode: 'only', ids: [1, 2, 3] })
|
||||
})
|
||||
|
||||
it('reads an empty legacy array as "only" mode with no ids', () => {
|
||||
expect(parseStoredConnections('[]')).toEqual({ mode: 'only', ids: [] })
|
||||
})
|
||||
|
||||
it('reads the tagged "only" object format', () => {
|
||||
expect(parseStoredConnections('{"mode":"only","ids":[5]}')).toEqual({ mode: 'only', ids: [5] })
|
||||
})
|
||||
|
||||
it('reads the tagged "all-except" object format', () => {
|
||||
expect(parseStoredConnections('{"mode":"all-except","ids":[7,8]}')).toEqual({ mode: 'all-except', ids: [7, 8] })
|
||||
})
|
||||
|
||||
it('returns null for invalid JSON', () => {
|
||||
expect(parseStoredConnections('not json')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a well-formed but unrecognized shape', () => {
|
||||
expect(parseStoredConnections('{"foo":"bar"}')).toBeNull()
|
||||
expect(parseStoredConnections('{"mode":"bogus","ids":[1]}')).toBeNull()
|
||||
expect(parseStoredConnections('null')).toBeNull()
|
||||
expect(parseStoredConnections('42')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveEffectiveConnections', () => {
|
||||
it('returns the stored preference unchanged when present, regardless of the account default', () => {
|
||||
const stored = { mode: 'only' as const, ids: [1] }
|
||||
expect(resolveEffectiveConnections(stored, true)).toEqual(stored)
|
||||
expect(resolveEffectiveConnections(stored, false)).toEqual(stored)
|
||||
})
|
||||
|
||||
it('falls back to "all-except []" when nothing is stored and the account default is on', () => {
|
||||
expect(resolveEffectiveConnections(null, true)).toEqual({ mode: 'all-except', ids: [] })
|
||||
})
|
||||
|
||||
it('falls back to "only []" when nothing is stored and the account default is off', () => {
|
||||
expect(resolveEffectiveConnections(null, false)).toEqual({ mode: 'only', ids: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveVisibleConnectionIds', () => {
|
||||
it('"only" mode shows exactly the stored ids, independent of what is routable', () => {
|
||||
expect(resolveVisibleConnectionIds({ mode: 'only', ids: [2, 4] }, [1, 2, 3, 4, 5])).toEqual([2, 4])
|
||||
})
|
||||
|
||||
it('"all-except" mode shows every routable id except the stored ones', () => {
|
||||
expect(resolveVisibleConnectionIds({ mode: 'all-except', ids: [3] }, [1, 2, 3, 4])).toEqual([1, 2, 4])
|
||||
})
|
||||
|
||||
it('"all-except []" shows every routable id', () => {
|
||||
expect(resolveVisibleConnectionIds({ mode: 'all-except', ids: [] }, [1, 2, 3])).toEqual([1, 2, 3])
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleConnectionId', () => {
|
||||
it('materializes the account default before toggling when nothing is stored yet (default on)', () => {
|
||||
// Default on -> effective mode is 'all-except []'; turning id 5 off adds it to the exceptions.
|
||||
expect(toggleConnectionId(null, true, 5)).toEqual({ mode: 'all-except', ids: [5] })
|
||||
})
|
||||
|
||||
it('materializes the account default before toggling when nothing is stored yet (default off)', () => {
|
||||
// Default off -> effective mode is 'only []'; turning id 5 on adds it to the inclusion list.
|
||||
expect(toggleConnectionId(null, false, 5)).toEqual({ mode: 'only', ids: [5] })
|
||||
})
|
||||
|
||||
it('adds an id in "only" mode', () => {
|
||||
expect(toggleConnectionId({ mode: 'only', ids: [1] }, false, 2)).toEqual({ mode: 'only', ids: [1, 2] })
|
||||
})
|
||||
|
||||
it('removes an id already present in "only" mode', () => {
|
||||
expect(toggleConnectionId({ mode: 'only', ids: [1, 2] }, false, 2)).toEqual({ mode: 'only', ids: [1] })
|
||||
})
|
||||
|
||||
it('adds an id to the exception list in "all-except" mode (hides it)', () => {
|
||||
expect(toggleConnectionId({ mode: 'all-except', ids: [] }, true, 9)).toEqual({ mode: 'all-except', ids: [9] })
|
||||
})
|
||||
|
||||
it('removes an id from the exception list in "all-except" mode (re-shows it)', () => {
|
||||
expect(toggleConnectionId({ mode: 'all-except', ids: [9] }, true, 9)).toEqual({ mode: 'all-except', ids: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('toggleAllConnections', () => {
|
||||
it('flips "only" to "all-except []"', () => {
|
||||
expect(toggleAllConnections({ mode: 'only', ids: [1, 2] }, false)).toEqual({ mode: 'all-except', ids: [] })
|
||||
})
|
||||
|
||||
it('flips "all-except" to "only []"', () => {
|
||||
expect(toggleAllConnections({ mode: 'all-except', ids: [3] }, false)).toEqual({ mode: 'only', ids: [] })
|
||||
})
|
||||
|
||||
it('materializes the account default first when nothing is stored yet, then flips it', () => {
|
||||
// Default on -> effective is 'all-except []' -> flips to 'only []'.
|
||||
expect(toggleAllConnections(null, true)).toEqual({ mode: 'only', ids: [] })
|
||||
// Default off -> effective is 'only []' -> flips to 'all-except []'.
|
||||
expect(toggleAllConnections(null, false)).toEqual({ mode: 'all-except', ids: [] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
// Per-trip booking-route visibility, persisted in localStorage under
|
||||
// `trek:visible-connections:<tripId>` (see useTripPlanner.ts). Two modes:
|
||||
// 'only' — nothing shown except `ids`
|
||||
// 'all-except' — everything routable shown except `ids`
|
||||
// The account-wide "always show booking routes" setting only decides which
|
||||
// mode a trip starts in the first time it's ever touched (see
|
||||
// resolveEffectiveConnections) — once a trip has a stored preference, that
|
||||
// preference always wins, regardless of the account setting's current value.
|
||||
|
||||
export type ConnectionsMode = 'only' | 'all-except'
|
||||
|
||||
export interface StoredConnections {
|
||||
mode: ConnectionsMode
|
||||
ids: number[]
|
||||
}
|
||||
|
||||
function isNumberArray(v: unknown): v is number[] {
|
||||
return Array.isArray(v) && v.every(n => typeof n === 'number')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the raw localStorage value for a trip's route-visibility
|
||||
* preference. Returns null when nothing has ever been stored (the caller
|
||||
* should fall back to the account-wide default in that case — see
|
||||
* resolveEffectiveConnections). Accepts the legacy bare-array format (from
|
||||
* before the 'all-except' mode existed) as an 'only' mode for backward
|
||||
* compatibility with values already sitting in users' browsers.
|
||||
*/
|
||||
export function parseStoredConnections(raw: string | null): StoredConnections | null {
|
||||
if (raw === null) return null
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (isNumberArray(parsed)) return { mode: 'only', ids: parsed }
|
||||
if (
|
||||
parsed && typeof parsed === 'object' && !Array.isArray(parsed) &&
|
||||
((parsed as { mode?: unknown }).mode === 'only' || (parsed as { mode?: unknown }).mode === 'all-except') &&
|
||||
isNumberArray((parsed as { ids?: unknown }).ids)
|
||||
) {
|
||||
const obj = parsed as { mode: ConnectionsMode; ids: number[] }
|
||||
return { mode: obj.mode, ids: obj.ids }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The mode/ids actually in effect for a trip: the stored preference if the
|
||||
* trip has one, otherwise the account-wide default (all routes shown, or
|
||||
* none) as an ephemeral, unwritten fallback.
|
||||
*/
|
||||
export function resolveEffectiveConnections(stored: StoredConnections | null, alwaysShowRoutesDefault: boolean): StoredConnections {
|
||||
if (stored) return stored
|
||||
return alwaysShowRoutesDefault ? { mode: 'all-except', ids: [] } : { mode: 'only', ids: [] }
|
||||
}
|
||||
|
||||
/** The concrete set of reservation ids visible right now. */
|
||||
export function resolveVisibleConnectionIds(effective: StoredConnections, routableIds: number[]): number[] {
|
||||
if (effective.mode === 'only') return effective.ids
|
||||
const excluded = new Set(effective.ids)
|
||||
return routableIds.filter(id => !excluded.has(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies one reservation's toggle to the current stored preference,
|
||||
* materializing the account-default fallback first if the trip has no
|
||||
* stored preference yet. Toggling just flips the id's membership in `ids` —
|
||||
* under 'only' that turns it on/off directly; under 'all-except' it
|
||||
* removes/re-adds it from the exception list.
|
||||
*/
|
||||
export function toggleConnectionId(stored: StoredConnections | null, alwaysShowRoutesDefault: boolean, id: number): StoredConnections {
|
||||
const base = resolveEffectiveConnections(stored, alwaysShowRoutesDefault)
|
||||
const ids = base.ids.includes(id) ? base.ids.filter(x => x !== id) : [...base.ids, id]
|
||||
return { mode: base.mode, ids }
|
||||
}
|
||||
|
||||
/**
|
||||
* Flips the whole trip between "show everything" and "show nothing" (the
|
||||
* toolbar's bulk show-all/hide-all button), materializing the account
|
||||
* default first if needed. Always starts the new mode with an empty
|
||||
* ids list — an explicit bulk action discards whatever per-leg overrides
|
||||
* applied to the mode it's leaving.
|
||||
*/
|
||||
export function toggleAllConnections(stored: StoredConnections | null, alwaysShowRoutesDefault: boolean): StoredConnections {
|
||||
const base = resolveEffectiveConnections(stored, alwaysShowRoutesDefault)
|
||||
return base.mode === 'all-except' ? { mode: 'only', ids: [] } : { mode: 'all-except', ids: [] }
|
||||
}
|
||||
Reference in New Issue
Block a user