Vercel Logo

Visited Tracking

Favorites are aspirational. Visited is proof. The pattern is almost identical to favorites, so this lesson will move faster. Where it gets interesting is the stats dashboard: once you're tracking which springs you've visited, you can compute how many regions you've covered, your hottest soak, and how many wild springs you've checked off.

This is where computed really earns its keep. In React, a stats dashboard like this means multiple useMemo hooks with carefully managed dependency arrays. Miss one dependency and the stats go stale. In Vue, computed tracks dependencies automatically, and you can chain computed values together without thinking about it. We'll derive all the stats from the visited list and the springs data without storing any of it.

Outcome

Build visited tracking with server routes, a toggle button, and a stats dashboard.

Fast Track

  1. Create GET, POST, and DELETE routes for visited in server/api/user/visited/
  2. Add a "Mark as Visited" toggle to the detail page
  3. Build the visited page with stats and a spring list

Hands-on exercise 4.2

Build the visited feature, following the same pattern as favorites.

Requirements:

  1. Create server/api/user/visited/index.get.ts, [springId].post.ts, and [springId].delete.ts
  2. Add a "Mark as Visited" toggle button on the detail page next to the favorites button
  3. Update app/pages/visited.vue with the auth middleware, visited springs list, and stats
  4. Stats should show: total visited, number of regions, hottest spring temperature, and wild springs count

Implementation hints:

  • The API routes follow the exact same pattern as favorites. The storage utility already handles visited in the UserData interface
  • The stats dashboard uses computed to derive values from the visited springs. No separate API call needed
  • new Set(springs.map(s => s.location.region)).size gives you the region count in one line

The API routes mirror favorites. Here's the POST route as an example:

server/api/user/visited/[springId].post.ts
export default defineEventHandler(async (event) => {
  const session = await requireUserSession(event);
  const springId = getRouterParam(event, "springId");
 
  if (!springId) {
    throw createError({ statusCode: 400, statusMessage: "Missing spring ID" });
  }
 
  const data = await getUserData(session.user.id);
 
  if (!data.visited.some((v) => v.springId === springId)) {
    data.visited.push({ springId, visitedAt: new Date().toISOString() });
    await setUserData(session.user.id, data);
  }
 
  return { success: true };
});

On the detail page, add the visited toggle next to the favorites button. The script additions:

app/pages/springs/[id].vue — script additions
const { data: userVisited } = await useFetch("/api/user/visited", {
  default: () => [],
});
 
const isVisited = computed(() =>
  userVisited.value?.some(
    (v: { springId: string }) => v.springId === spring.value?.id
  )
);
 
async function toggleVisited() {
  if (!spring.value) return;
  await $fetch(`/api/user/visited/${spring.value.id}`, {
    method: isVisited.value ? "DELETE" : "POST",
  });
  userVisited.value = await $fetch("/api/user/visited");
}

And the button in the template, next to the favorites button:

<button @click="toggleVisited">
  {{ isVisited ? "Visited ✓" : "Mark as Visited" }}
</button>

Now the interesting part. The visited page computes stats from the springs data:

app/pages/visited.vue
<script setup lang="ts">
import type { Spring } from "~/types/spring";
 
definePageMeta({
  middleware: "auth",
});
 
const { data: visited } = await useFetch("/api/user/visited", {
  default: () => [],
});
 
const { data: allSprings } = await useFetch<Spring[]>("/api/springs", {
  default: () => [],
});
 
const visitedSprings = computed(() => {
  const visitedIds = new Set(
    visited.value?.map((v: { springId: string }) => v.springId) ?? []
  );
  return allSprings.value?.filter((s) => visitedIds.has(s.id)) ?? [];
});
 
const stats = computed(() => {
  const springs = visitedSprings.value;
  if (!springs.length) return null;
 
  const regions = new Set(springs.map((s) => s.location.region));
  const types = springs.reduce(
    (acc, s) => {
      acc[s.type] = (acc[s.type] || 0) + 1;
      return acc;
    },
    {} as Record<string, number>
  );
 
  return {
    total: springs.length,
    regions: regions.size,
    hottest: Math.max(...springs.map((s) => s.temperature.max)),
    types,
  };
});
</script>

Everything in stats is derived. When the user marks a new spring as visited and the favorites list refetches, visitedSprings recomputes, stats recomputes, and the dashboard updates. No state management library, no manual cache invalidation. The reactivity chain handles it.

Here's what the same logic looks like in React:

// React version — for comparison only
const visitedSprings = useMemo(() => {
  const ids = new Set(visited.map(v => v.springId));
  return allSprings.filter(s => ids.has(s.id));
}, [visited, allSprings]);
 
const stats = useMemo(() => {
  if (!visitedSprings.length) return null;
  const regions = new Set(visitedSprings.map(s => s.location.region));
  return {
    total: visitedSprings.length,
    regions: regions.size,
    hottest: Math.max(...visitedSprings.map(s => s.temperature.max)),
  };
}, [visitedSprings]);

Two useMemo hooks, two dependency arrays. Get one dependency wrong and the stats silently go stale. Vue's computed eliminates this entire class of bug because it tracks what you read, not what you declare.

The template renders the stats as a grid of cards:

app/pages/visited.vue — stats template
<div v-if="stats">
  <div>
    <p>{{ stats.total }}</p>
    <p>Springs Visited</p>
  </div>
  <div>
    <p>{{ stats.regions }}</p>
    <p>Regions</p>
  </div>
  <div>
    <p>{{ stats.hottest }}°F</p>
    <p>Hottest Visited</p>
  </div>
  <div>
    <p>
      {{ stats.types.wild || 0 }}
    </p>
    <p>Wild Springs</p>
  </div>
</div>
computed chains are free

visitedSprings depends on visited and allSprings. stats depends on visitedSprings. Vue tracks the entire chain automatically. When the raw data changes, only the affected computed values recompute. No dependency arrays to maintain.

Math.max with empty arrays

Math.max(...[]) returns -Infinity. The if (!springs.length) return null guard prevents this, but if you remove it, the stats card would show a confusing number. Always guard against empty arrays when using spread with Math functions.

Try It

  1. Log in and visit a few spring detail pages. Mark 3-4 springs as visited across different regions
  2. Navigate to /visited. You should see:
    • The stats dashboard with total count, regions, hottest temperature, and wild springs
    • SpringCard components for each visited spring
  3. Go back to a detail page and unmark a spring. Return to /visited and verify the stats update

Commit

git add -A && git commit -m "feat(visited): add visited tracking with stats dashboard"

Done-When

  • POST and DELETE routes for visited work correctly
  • The detail page shows both favorites and visited toggle buttons
  • The visited page shows a stats dashboard with total, regions, hottest, and wild counts
  • Stats update correctly when springs are added or removed

Solution

server/api/user/visited/index.get.ts
export default defineEventHandler(async (event) => {
  const session = await getUserSession(event);
  if (!session.user) {
    return [];
  }
 
  const data = await getUserData(session.user.id);
  return data.visited;
});
server/api/user/visited/[springId].post.ts
export default defineEventHandler(async (event) => {
  const session = await requireUserSession(event);
  const springId = getRouterParam(event, "springId");
 
  if (!springId) {
    throw createError({ statusCode: 400, statusMessage: "Missing spring ID" });
  }
 
  const data = await getUserData(session.user.id);
 
  if (!data.visited.some((v) => v.springId === springId)) {
    data.visited.push({ springId, visitedAt: new Date().toISOString() });
    await setUserData(session.user.id, data);
  }
 
  return { success: true };
});
server/api/user/visited/[springId].delete.ts
export default defineEventHandler(async (event) => {
  const session = await requireUserSession(event);
  const springId = getRouterParam(event, "springId");
 
  if (!springId) {
    throw createError({ statusCode: 400, statusMessage: "Missing spring ID" });
  }
 
  const data = await getUserData(session.user.id);
  data.visited = data.visited.filter((v) => v.springId !== springId);
  await setUserData(session.user.id, data);
 
  return { success: true };
});
app/pages/visited.vue
<script setup lang="ts">
import type { Spring } from "~/types/spring";
 
definePageMeta({
  middleware: "auth",
});
 
const { data: visited } = await useFetch("/api/user/visited", {
  default: () => [],
});
 
const { data: allSprings } = await useFetch<Spring[]>("/api/springs", {
  default: () => [],
});
 
const visitedSprings = computed(() => {
  const visitedIds = new Set(
    visited.value?.map((v: { springId: string }) => v.springId) ?? []
  );
  return allSprings.value?.filter((s) => visitedIds.has(s.id)) ?? [];
});
 
const stats = computed(() => {
  const springs = visitedSprings.value;
  if (!springs.length) return null;
 
  const regions = new Set(springs.map((s) => s.location.region));
  const types = springs.reduce(
    (acc, s) => {
      acc[s.type] = (acc[s.type] || 0) + 1;
      return acc;
    },
    {} as Record<string, number>
  );
 
  return {
    total: springs.length,
    regions: regions.size,
    hottest: Math.max(...springs.map((s) => s.temperature.max)),
    types,
  };
});
</script>
 
<template>
  <div>
    <div>
      <h1>
        Visited Springs
      </h1>
      <p>
        {{ visitedSprings.length }} spring{{
          visitedSprings.length === 1 ? "" : "s"
        }}
        visited
      </p>
    </div>
 
    <div v-if="stats">
      <div>
        <p>{{ stats.total }}</p>
        <p>Springs Visited</p>
      </div>
      <div>
        <p>{{ stats.regions }}</p>
        <p>Regions</p>
      </div>
      <div>
        <p>{{ stats.hottest }}°F</p>
        <p>Hottest Visited</p>
      </div>
      <div>
        <p>
          {{ stats.types.wild || 0 }}
        </p>
        <p>Wild Springs</p>
      </div>
    </div>
 
    <div v-if="visitedSprings.length">
      <SpringCard v-for="spring in visitedSprings" :key="spring.id" :spring="spring" />
    </div>
 
    <div v-else>
      <p>
        You haven't marked any springs as visited yet. Start exploring.
      </p>
      <NuxtLink to="/springs">
        Browse Hot Springs &rarr;
      </NuxtLink>
    </div>
  </div>
</template>