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
- Create GET, POST, and DELETE routes for visited in
server/api/user/visited/ - Add a "Mark as Visited" toggle to the detail page
- 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:
- Create
server/api/user/visited/index.get.ts,[springId].post.ts, and[springId].delete.ts - Add a "Mark as Visited" toggle button on the detail page next to the favorites button
- Update
app/pages/visited.vuewith the auth middleware, visited springs list, and stats - 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
visitedin theUserDatainterface - The stats dashboard uses
computedto derive values from the visited springs. No separate API call needed new Set(springs.map(s => s.location.region)).sizegives you the region count in one line
The API routes mirror favorites. Here's the POST route as an example:
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:
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:
<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:
<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>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(...[]) 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
- Log in and visit a few spring detail pages. Mark 3-4 springs as visited across different regions
- Navigate to
/visited. You should see:- The stats dashboard with total count, regions, hottest temperature, and wild springs
- SpringCard components for each visited spring
- Go back to a detail page and unmark a spring. Return to
/visitedand 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
export default defineEventHandler(async (event) => {
const session = await getUserSession(event);
if (!session.user) {
return [];
}
const data = await getUserData(session.user.id);
return data.visited;
});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 };
});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 };
});<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 →
</NuxtLink>
</div>
</div>
</template>Was this helpful?