From ddba45ab3ea230521f4eab6599162dd7961ed0a2 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 3 Aug 2026 19:39:18 +0200 Subject: [PATCH] feat(shared): tell visited countries apart from planned ones The atlas counted every trip as a visit, so a country you had merely booked a flight to was stamped the same as one you came back from. tripVisitStatus buckets a trip by its dates: already started (or running) counts as visited, starting in the future is planned, and a trip with no dates at all is an idea that must not reach the visit stats. The comparison is UTC, matching the dashboard and journey lifecycles. Requested in discussion #1048. --- shared/src/atlas/atlas.schema.spec.ts | 135 +++++++++++++++++++++++++- shared/src/atlas/atlas.schema.ts | 44 +++++++++ 2 files changed, 177 insertions(+), 2 deletions(-) diff --git a/shared/src/atlas/atlas.schema.spec.ts b/shared/src/atlas/atlas.schema.spec.ts index 3f341e4a8..ad1ead254 100644 --- a/shared/src/atlas/atlas.schema.spec.ts +++ b/shared/src/atlas/atlas.schema.spec.ts @@ -1,6 +1,29 @@ -import { markRegionRequestSchema, createBucketItemRequestSchema, regionGeoSchema } from './atlas.schema'; +import { + markRegionRequestSchema, + createBucketItemRequestSchema, + regionGeoSchema, + visitStatusSchema, + todayUtc, + tripVisitStatus, + VISIT_STATUS_RANK, + strongerVisitStatus, +} from './atlas.schema'; -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach, vi } from 'vitest'; + +/** + * The date helpers below read the wall clock, so every test that exercises the + * default `today` pins it first — otherwise the suite flips its answers when it + * happens to run across midnight UTC. + */ +function freezeClock(iso: string): void { + vi.useFakeTimers(); + vi.setSystemTime(new Date(iso)); +} + +afterEach(() => { + vi.useRealTimers(); +}); describe('markRegionRequestSchema', () => { it('requires both name and country_code', () => { @@ -36,3 +59,111 @@ describe('regionGeoSchema', () => { expect(regionGeoSchema.safeParse({ type: 'Other', features: [] }).success).toBe(false); }); }); + +describe('visitStatusSchema', () => { + it('accepts the three buckets and nothing else', () => { + expect(visitStatusSchema.safeParse('visited').success).toBe(true); + expect(visitStatusSchema.safeParse('planned').success).toBe(true); + expect(visitStatusSchema.safeParse('idea').success).toBe(true); + expect(visitStatusSchema.safeParse('wishlist').success).toBe(false); + expect(visitStatusSchema.safeParse('').success).toBe(false); + }); +}); + +describe('tripVisitStatus', () => { + // Passed explicitly wherever the clock is not the thing under test. + const TODAY = '2026-08-03'; + + it('counts a trip that already ended as visited', () => { + expect(tripVisitStatus('2026-01-10', '2026-01-20', TODAY)).toBe('visited'); + }); + + it('counts a running trip as visited — you are there right now', () => { + expect(tripVisitStatus('2026-08-01', '2026-08-10', TODAY)).toBe('visited'); + }); + + it('counts a trip that has not started yet as planned', () => { + expect(tripVisitStatus('2026-09-01', '2026-09-10', TODAY)).toBe('planned'); + }); + + it('flips to visited on the start day itself', () => { + expect(tripVisitStatus(TODAY, '2026-08-09', TODAY)).toBe('visited'); + expect(tripVisitStatus(TODAY, TODAY, TODAY)).toBe('visited'); + // ...and the very next day is still only planned + expect(tripVisitStatus('2026-08-04', '2026-08-09', TODAY)).toBe('planned'); + }); + + it('works off start_date alone', () => { + expect(tripVisitStatus('2026-01-10', null, TODAY)).toBe('visited'); + expect(tripVisitStatus('2026-12-01', null, TODAY)).toBe('planned'); + }); + + it('falls back to end_date as the effective start', () => { + expect(tripVisitStatus(null, '2026-01-20', TODAY)).toBe('visited'); + expect(tripVisitStatus(null, '2026-12-20', TODAY)).toBe('planned'); + }); + + it('treats a trip without any date as an idea, not a plan', () => { + expect(tripVisitStatus(null, null, TODAY)).toBe('idea'); + }); + + it('treats null, undefined and empty strings alike', () => { + expect(tripVisitStatus(undefined, undefined, TODAY)).toBe('idea'); + expect(tripVisitStatus(null, undefined, TODAY)).toBe('idea'); + expect(tripVisitStatus(undefined, null, TODAY)).toBe('idea'); + expect(tripVisitStatus('', '', TODAY)).toBe('idea'); + expect(tripVisitStatus(undefined, '2026-01-20', TODAY)).toBe(tripVisitStatus(null, '2026-01-20', TODAY)); + expect(tripVisitStatus('', '2026-01-20', TODAY)).toBe('visited'); + }); + + it('defaults to the UTC clock, right up to the last second of the day', () => { + freezeClock('2026-08-03T23:59:59.999Z'); + expect(tripVisitStatus('2026-08-03', null)).toBe('visited'); + expect(tripVisitStatus('2026-08-04', null)).toBe('planned'); + }); +}); + +describe('strongerVisitStatus', () => { + it('ranks visited above planned above idea', () => { + expect(VISIT_STATUS_RANK.visited).toBeLessThan(VISIT_STATUS_RANK.planned); + expect(VISIT_STATUS_RANK.planned).toBeLessThan(VISIT_STATUS_RANK.idea); + }); + + it('picks the stronger status regardless of argument order', () => { + expect(strongerVisitStatus('visited', 'planned')).toBe('visited'); + expect(strongerVisitStatus('planned', 'visited')).toBe('visited'); + expect(strongerVisitStatus('visited', 'idea')).toBe('visited'); + expect(strongerVisitStatus('idea', 'visited')).toBe('visited'); + expect(strongerVisitStatus('planned', 'idea')).toBe('planned'); + expect(strongerVisitStatus('idea', 'planned')).toBe('planned'); + }); + + it('returns the shared status on a tie', () => { + expect(strongerVisitStatus('visited', 'visited')).toBe('visited'); + expect(strongerVisitStatus('planned', 'planned')).toBe('planned'); + expect(strongerVisitStatus('idea', 'idea')).toBe('idea'); + }); +}); + +describe('todayUtc', () => { + it('formats as YYYY-MM-DD', () => { + freezeClock('2026-08-03T12:00:00.000Z'); + expect(todayUtc()).toBe('2026-08-03'); + expect(todayUtc()).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it('zero-pads single-digit months and days', () => { + freezeClock('2026-01-05T08:30:00.000Z'); + expect(todayUtc()).toBe('2026-01-05'); + }); + + it('does not tip into the next day late in the evening UTC', () => { + freezeClock('2026-08-03T23:59:59.999Z'); + expect(todayUtc()).toBe('2026-08-03'); + }); + + it('rolls over at midnight UTC', () => { + freezeClock('2026-08-04T00:00:00.000Z'); + expect(todayUtc()).toBe('2026-08-04'); + }); +}); diff --git a/shared/src/atlas/atlas.schema.ts b/shared/src/atlas/atlas.schema.ts index c23f4a1fa..e702ac0b2 100644 --- a/shared/src/atlas/atlas.schema.ts +++ b/shared/src/atlas/atlas.schema.ts @@ -56,6 +56,50 @@ export const regionGeoSchema = z.object({ }); export type RegionGeo = z.infer; +/** + * How a country/region ended up on the atlas map. Before this, every trip counted as a + * visit, so a booked-but-not-taken trip painted the map as if you had already been there. + * Requested in discussion #1048 — note that's a discussion, not an issue: + * https://github.com/liketrek/TREK/discussions/1048 + * + * - visited: at least one source is a trip that has already started, or a manual mark + * - planned: every source is a trip starting in the future + * - idea: every source is a trip with no dates at all + */ +export const visitStatusSchema = z.enum(['visited', 'planned', 'idea']); +export type VisitStatus = z.infer; + +/** Today as 'YYYY-MM-DD' in UTC — the comparison base the rest of TREK uses. */ +export function todayUtc(): string { + return new Date().toISOString().slice(0, 10); +} + +/** + * Bucket a trip by its dates. A trip that has started but not ended still counts as + * visited — you are there right now. A trip with neither date is an idea, not a plan, + * and must stay out of the visit stats. + */ +export function tripVisitStatus( + startDate: string | null | undefined, + endDate: string | null | undefined, + today: string = todayUtc(), +): VisitStatus { + const effectiveStart = startDate || endDate || null; + if (!effectiveStart) return 'idea'; + return effectiveStart <= today ? 'visited' : 'planned'; +} + +/** visited beats planned beats idea — a country takes its strongest source. */ +export const VISIT_STATUS_RANK: Record = { + visited: 0, + planned: 1, + idea: 2, +}; + +export function strongerVisitStatus(a: VisitStatus, b: VisitStatus): VisitStatus { + return VISIT_STATUS_RANK[a] <= VISIT_STATUS_RANK[b] ? a : b; +} + /** * ISO 3166-1 alpha-2 country code → continent. Single source of truth for the * Atlas continent breakdown, used by the server (stats aggregation) and the