mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user