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.
This commit is contained in:
Maurice
2026-08-03 19:39:18 +02:00
committed by Maurice
parent c42aea4171
commit ddba45ab3e
2 changed files with 177 additions and 2 deletions
+133 -2
View File
@@ -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');
});
});
+44
View File
@@ -56,6 +56,50 @@ export const regionGeoSchema = z.object({
});
export type RegionGeo = z.infer<typeof regionGeoSchema>;
/**
* 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<typeof visitStatusSchema>;
/** 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<VisitStatus, number> = {
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