fix(planner): road trip mode, the dawarich trail and the route overview follow the account to another browser

The three planner view switches lived in sessionStorage only, so a new tab,
another browser or another device opened every trip on the plain view again.
They are now kept per trip in one user setting, planner_trip_views, validated
against a contract in @trek/shared. The server keeps only switches that are on
and only trips the user can still open, so a deleted trip or one the user left
drops out on the next write. The tab copy in sessionStorage stays and answers
until the settings have loaded.

Refs #2639
This commit is contained in:
Maurice
2026-10-12 17:41:56 +02:00
parent adb88142ab
commit fbba8f96b7
18 changed files with 502 additions and 51 deletions
+41 -1
View File
@@ -1,4 +1,11 @@
import { settingUpsertRequestSchema, settingsBulkRequestSchema, MASKED_SETTING_VALUE } from './settings.schema';
import {
settingUpsertRequestSchema,
settingsBulkRequestSchema,
MASKED_SETTING_VALUE,
PLANNER_TRIP_VIEWS_MAX,
compactPlannerTripViews,
plannerTripViewsSchema,
} from './settings.schema';
import { describe, it, expect } from 'vitest';
@@ -23,3 +30,36 @@ describe('MASKED_SETTING_VALUE', () => {
expect(MASKED_SETTING_VALUE).toBe('••••••••');
});
});
describe('plannerTripViewsSchema (#2639)', () => {
it('takes the three switches per trip id', () => {
expect(
plannerTripViewsSchema.safeParse({ '12': { roadtrip: true, dawarich: false, overview: true } }).success,
).toBe(true);
expect(plannerTripViewsSchema.safeParse({}).success).toBe(true);
});
it('refuses a key that is not a trip id and a switch that is not a boolean', () => {
expect(plannerTripViewsSchema.safeParse({ abc: { roadtrip: true } }).success).toBe(false);
expect(plannerTripViewsSchema.safeParse({ '0': { roadtrip: true } }).success).toBe(false);
expect(plannerTripViewsSchema.safeParse({ '12': { roadtrip: 'yes' } }).success).toBe(false);
expect(plannerTripViewsSchema.safeParse(['12']).success).toBe(false);
});
it('refuses more trips than the row is meant to hold', () => {
const many = Object.fromEntries(
Array.from({ length: PLANNER_TRIP_VIEWS_MAX + 1 }, (_, i) => [String(i + 1), { roadtrip: true }]),
);
expect(plannerTripViewsSchema.safeParse(many).success).toBe(false);
});
});
describe('compactPlannerTripViews', () => {
it('keeps only the switches that are on and drops trips with none', () => {
expect(
compactPlannerTripViews({ '1': { roadtrip: true, dawarich: false }, '2': { overview: false }, '3': {} }),
).toEqual({
'1': { roadtrip: true },
});
});
});
+40
View File
@@ -66,3 +66,43 @@ export type SettingsBulkResponse = z.infer<typeof settingsBulkResponseSchema>;
/** GET/PUT /api/admin/default-user-settings: the stored defaults a new account starts with, by key. */
export const adminDefaultUserSettingsResponseSchema = z.record(z.string(), z.unknown());
export type AdminDefaultUserSettingsResponse = z.infer<typeof adminDefaultUserSettingsResponseSchema>;
/**
* The planner's view switches a traveller expects to find again on any device
* (#2639): road trip mode, the recorded Dawarich trail and the whole-trip route
* overview. One settings row per user, keyed by trip id, holding only the
* switches that are on; a trip without any is left out instead of stored as a
* row of falses. The server drops trips the user can no longer open on every
* write, so a deleted trip does not linger in the row.
*/
export const PLANNER_TRIP_VIEWS_KEY = 'planner_trip_views';
/** More trips than anyone keeps open at once; it bounds the row, not a person. */
export const PLANNER_TRIP_VIEWS_MAX = 500;
export const plannerTripViewSchema = z.object({
roadtrip: z.boolean().optional(),
dawarich: z.boolean().optional(),
overview: z.boolean().optional(),
});
export type PlannerTripView = z.infer<typeof plannerTripViewSchema>;
export type PlannerTripViewFlag = keyof PlannerTripView;
export const plannerTripViewsSchema = z
.record(z.string().regex(/^[1-9]\d{0,9}$/), plannerTripViewSchema)
.refine((views) => Object.keys(views).length <= PLANNER_TRIP_VIEWS_MAX, {
message: `At most ${PLANNER_TRIP_VIEWS_MAX} trips`,
});
export type PlannerTripViews = z.infer<typeof plannerTripViewsSchema>;
/** The views with every trip that has no switch on left out. */
export function compactPlannerTripViews(views: PlannerTripViews): PlannerTripViews {
const kept: PlannerTripViews = {};
for (const [tripId, view] of Object.entries(views)) {
const on: PlannerTripView = {};
if (view.roadtrip) on.roadtrip = true;
if (view.dawarich) on.dawarich = true;
if (view.overview) on.overview = true;
if (Object.keys(on).length > 0) kept[tripId] = on;
}
return kept;
}