Vercel Logo

Visited Tracking

Visited tracking follows the same storage and route pattern as favorites. The new work is a dashboard that derives regional coverage, hottest temperature, and wild-spring count from the saved visits.

In React, this dashboard might use several useMemo hooks with dependency arrays. Vue's computed tracks dependencies automatically and allows one computed value to depend on another. The dashboard can derive every statistic from the visited list and spring data without storing duplicate state.

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>

The visited page computes its statistics from the spring 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>

Every value in stats is derived. When the visited list refetches, visitedSprings and stats recompute, updating the dashboard without duplicate state.

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]);

The React version declares dependencies for both useMemo calls. Vue records the reactive values read by each computed callback instead.

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 values can depend on each other

visitedSprings depends on visited and allSprings, while stats depends on visitedSprings. Vue tracks that chain and recomputes the affected values when the source data changes.

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>

Was this helpful?

supported.