Your Visited-Countries Tracker Isn't a Map Problem. It's a Data Model Problem.

Your Visited-Countries Tracker Isn't a Map Problem. It's a Data Model Problem.

March 30, 2026

You're not building a map.

You're building a truth machine that happens to render as a map.

Most "visited countries map" content treats this like a library choice. Leaflet vs Mapbox. SVG vs Canvas. That's the easy part.

The hard part is that "I've visited 49 countries" is not a fact. It's a computed claim based on:

  • what you consider a place (sovereign state, territory, dependent area, disputed region)
  • what you consider visited (entry vs airport vs transit)
  • what you consider time (date ranges, time zones, unknown end dates)
  • what you consider identity context (multiple passports, residency periods)

If you don't screen those edge cases at the data-model layer, they'll interrupt you forever at the UI layer.

That's the same philosophy we build SmartLine on: screen the messy reality before it hits you. You don't want every call to reach your phone. And you don't want every weird travel exception to corrupt your dataset.

What is the core product decision your tracker must make?

Your tracker must decide whether it's counting sovereign states, travel areas (territories), or both as separate views.

That decision changes everything downstream: your place taxonomy, your aggregation, your rollups, and what your map can truthfully show.

Here's the trap: people say "visited countries," but they argue about:

  • Hong Kong
  • Puerto Rico
  • Greenland
  • Aruba
  • Kosovo / Palestine / Western Sahara
  • French Guiana vs "France"

These aren't map problems. They're taxonomy problems.

What's the difference between a country, a territory, and a dependent area in a tracker?

In a tracker, the only definition that matters is: what unit can a user claim to have visited, and what unit can you render and count consistently.

Practical definitions (for builders):

  • Country (sovereign state): a top-level place you can count as a single unit in a "sovereign view." Often aligns with ISO 3166-1, which lists 249 codes (commonly cited; depends on inclusion rules).
  • Territory / dependent area (travel-distinct area): a place users routinely experience as "separate travel" even if it's politically attached (e.g., Greenland, Hong Kong, Puerto Rico). In your schema this is just place.type = territory with a parent_place_id.
  • Disputed region: a place where you want neutrality. You store the claim separately from the geometry you render.

Your job isn't to pick a side. Your job is to build a model that lets you render different views without rewriting history every time Twitter finds your app.

What counts as "visited" (layover, left the airport, overnight)?

"Visited" must be a rule applied to events, not a boolean stored on a place.

You'll need at least one field that distinguishes:

  • transit_no_entry (you never entered)
  • airport_layover (you entered the airport country but didn't leave the airport zone; varies)
  • day_trip
  • overnight
  • residency

Then your product exposes rules like:

  • "Count as visited only if visit_type IN (day_trip, overnight, residency)."
  • Or: "Count airport layovers only if duration > X hours."

Don't hardcode this into the event. Keep events factual-ish. Keep visited-ness computed.

The minimal, implementation-ready data model (tables + fields)

This schema is "event-sourced-ish": store atomic travel facts (stay events), compute read-optimized aggregates (user_place_status) for map rendering and stats.

Table: users

You know this one. Keep it boring.

Fields (minimal):

  • id (uuid / bigint)
  • created_at

Table: place

place is your canonical catalog of things a user can claim to have visited.

Key principle: taxonomy lives here. Geometry does not.

Fields:

  • id (uuid / bigint)
  • name (text)
  • type (enum: country, territory, region, disputed, historical)
  • parent_place_id (nullable FK to place.id)
    Used for territories under sovereigns and for rollups.
  • iso3166_1_alpha2 (nullable text, e.g., FR)
    Only for things that truly map to ISO 3166-1.
  • iso3166_1_alpha3 (nullable text)
  • iso3166_1_numeric (nullable text)
  • iso3166_2 (nullable text)
    Optional if you support subdivisions.
  • iso3166_3 (nullable text)
    Optional if you care about historical codes.
  • valid_from (nullable date)
  • valid_to (nullable date)
    Only if you choose to model historical entities.
  • canonical_slug (text, unique)
    Stable internal identifier.
  • tags (jsonb or join table)
    For things like schengen, eu, caribbean, etc.

Constraints to add:

  • type must be one of your supported values.
  • if valid_to is set then valid_from <= valid_to.

Why this exists: because you can't compute "visited" if you can't define what a place is.

Table: place_geometry

This table connects your taxonomy to whatever map dataset you render.

Key principle: you need stable identifiers that join to GeoJSON/TopoJSON features without duct tape.

Fields:

  • id
  • place_id (FK to place.id)
  • geometry_source (text, e.g., natural_earth_admin0, osm_boundaries, mapbox)
  • source_feature_id (text)
    The exact feature identifier in that dataset.
  • source_feature_props (jsonb, optional)
    Cache fields like ADM0_A3, ISO_A2, NAME if helpful.
  • geometry_version (text)
    So a boundary update doesn't silently change your output.
  • bbox (optional)
    If you need quick viewport ops.
  • is_primary (bool)
    Allows multiple geometry matches but one "main."

Constraints/indexes:

  • unique (geometry_source, source_feature_id, geometry_version)
  • index (place_id)

Why this exists: because you should be able to change map providers without rewriting your entire database.

Table: trip

trip is a container for user-facing organization. It is not your source of truth.

Fields:

  • id
  • user_id (FK)
  • name (text)
  • start_at (nullable timestamptz)
  • end_at (nullable timestamptz)
  • notes (text)

Why this exists: because users like grouping ("South America 2024"), but you don't want to force all facts to fit a neat trip.

Table: stay (aka visit_event)

This is the atomic fact table. Every computation should be derivable from this.

Fields:

  • id
  • user_id (FK)
  • trip_id (nullable FK)
  • place_id (FK to place.id)
  • start_at (timestamptz)
  • end_at (nullable timestamptz)
    Null means "open-ended / currently here."
  • start_precision (enum: exact, day, month, year)
  • end_precision (enum: exact, day, month, year)
  • visit_type (enum: transit_no_entry, airport_layover, day_trip, overnight, residency, unknown)
  • source (enum/text: manual, import, gps, calendar)
    Optional, but helpful.
  • notes (text)

Constraints:

  • end_at IS NULL OR end_at >= start_at
  • start_at IS NOT NULL

Indexes:

  • index (user_id, place_id, start_at)
  • index (user_id, start_at)
  • If Postgres: consider a range index with tstzrange(start_at, coalesce(end_at, now())) via GiST for overlap queries.

Why this exists: because "I visited France 12 times" is a list of events, not a boolean.

Read model table: user_place_status

This is your materialized cache for fast map coloring and stats.

Fields:

  • user_id
  • place_id
  • visited (bool)
  • first_visited_at (nullable timestamptz)
  • last_visited_at (nullable timestamptz)
  • visit_count (int)
  • total_duration_seconds (bigint)
    Optional: duration sums are tricky but useful.
  • computed_at (timestamptz)

Uniqueness:

  • unique (user_id, place_id)

Why this exists: because you should not run heavy range/aggregation queries every time someone loads a map.

How do I store multiple trips to the same place and show first/last visited?

You store every stay as a row, then compute first_visited_at, last_visited_at, and visit_count from those rows.

That's it. If you store visited = true directly on place, you've already lost.

Rule example (typical):

  • eligible visits are visit_type IN ('day_trip','overnight','residency') (configurable)
  • first_visited_at = min(start_at) over eligible visits
  • last_visited_at = max(coalesce(end_at, start_at)) over eligible visits
  • visit_count = count(*) over eligible visits

Then visited = visit_count > 0.

How do I model date ranges properly (time zones, unknown end dates, month-only dates)?

You model time as a range with precision, not as two naive dates.

Implementation guidance:

  • Use timestamptz for start_at/end_at. Store the actual instant.
  • Add start_precision/end_precision for partial inputs (month-only, year-only).
  • Allow end_at to be null for open-ended stays.

Pragmatic approach that ships:

  • treat partial dates as an interval you can compute against (e.g., month-only becomes [2026-03-01, 2026-03-31]) in your read model.
  • keep the raw precision so you can display honesty ("March 2026") instead of pretending you know the 12th.

Can I support both "visited sovereign states" and "visited territories" views?

Yes—if you model parent_place_id and compute status in two modes.

Mode A: territory-inclusive

  • Count visits directly against the visited place_id.

Mode B: sovereign rollup

  • If user visited a territory, roll it up to its parent sovereign (depending on your product rule).

This is why place.type and place.parent_place_id exist. It lets you ship a UI toggle like "Count territories separately" (on/off) without rewriting events.

How do I handle disputed regions without making political claims?

You decouple taxonomy (what you count) from geometry (what you draw).

Practical model:

  • place record for the disputed region (type='disputed')
  • place_geometry can point to a neutral boundary set or a specific dataset's feature
  • If you need to support multiple representations, allow multiple place_geometry rows with different geometry_source values

Your app's UI can say what it's doing: "Boundaries from Natural Earth (admin-0)." You're not declaring sovereignty. You're declaring your rendering source.

How do I avoid map coloring bugs when boundaries change?

You version your geometry mapping.

If you store only "place_id -> GeoJSON feature id" with no version, a dataset update will silently change what you color.

Add place_geometry.geometry_version and treat geometry updates as migrations:

  • add new rows for the new version
  • switch is_primary when you're ready

Your historical aggregates remain correct relative to the version you rendered.

How do I compute stats fast without expensive queries?

You separate the write model (events) from the read model (status).

Typical flow:

  1. Insert/update a stay
  2. Recompute user_place_status for that user_id (and affected rollups)
  3. Map rendering reads only user_place_status + place_geometry

This makes your map endpoint one indexed lookup for all visited places for a user plus a join to geometry feature ids. No range scans. No group-bys on every request.

Example: edge cases this schema handles without hacks

"I visited France 12 times."

Store 12 stay rows. user_place_status.visit_count = 12.

Overlapping stays (side trips, cruises, cross-border commuting)

You can store overlapping ranges because they're facts. Your computed views can merge/normalize if you want "unique days," or just keep counts.

Open-ended stay ("currently here")

end_at = NULL. Your UI can show "Present." Your stats logic can treat it as now() for calculations.

Airport transit vs entry

visit_type drives whether it counts for "visited." Your model doesn't pretend a layover is the same as an overnight.

Territories counted separately vs under parent

place.parent_place_id supports rollups and toggles.

Builder note: this is the same systems mindset SmartLine is built on

Founders who move across borders and time zones don't have time for systems that rot.

A visited-countries tracker rots when your schema can't absorb reality. Your phone rots the same way: the minute you can't tell a high-signal call from noise, you stop answering and you lose optionality.

SmartLine fixes that on the communication side. Your AI assistant screens every inbound call and SMS, then sends you clean summaries so you decide what deserves your attention. No more guessing whether that unknown number is a sales pitch or your next co-founder.

Your tracker should do the same thing: screen messy travel reality into clean, queryable events—so your map becomes a calm projection, not a bug farm.