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