Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@
"prettier": "^3.9.5",
"vite": "^7.1.0",
"vitest": "^3.2.4",
"vue-eslint-parser": "^10.4.1"
"vue-eslint-parser": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/user-event": "^14.6.7",
"@testing-library/vue": "^8.1.0"
},
"engines": {
"node": "^22.18.0 || >=24.12.0"
Expand Down
33 changes: 20 additions & 13 deletions client/src/components/GuideCard.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
<script setup lang="ts">
import { useRouter } from "vue-router";
import type { Guide } from "@utpost/shared";

const props = defineProps<{ guide: Guide }>();

const router = useRouter();

const goToGuideDetails = () => {
router.push({
name: "guide-details",
params: { id: props.guide.id },
});
};
defineProps<{ guide: Guide }>();
</script>

<template>
<article @click="goToGuideDetails">
<h2>{{ guide.title }}</h2>
<article class="card">
<h3>
<RouterLink :to="`/guider/${guide.slug}`">{{ guide.title }}</RouterLink>
</h3>
<p class="muted">
{{ guide.region }} · {{ guide.difficulty }} · {{ guide.length_km }} km
</p>
</article>
</template>

<style scoped>
.card {
border: 1px solid #ddd;
padding: 12px;
border-radius: 4px;
}
.muted {
color: #777;
font-size: 14px;
}
</style>
27 changes: 27 additions & 0 deletions client/src/lib/tours.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import type { TourLog } from "@utpost/shared";
import { elevationGain } from "./tours";

const log = (elevation_m: number | null, id = 0): TourLog => ({
id,
tour_id: 1,
recorded_at: "2026-09-01T08:00:00.000Z",
lat: 67.9,
lon: 18.5,
elevation_m,
heart_rate: null,
note: null,
});

describe("elevationGain", () => {
it("summerar bara stigningar, inte nedförsbackar", () => {
expect(elevationGain([log(100), log(150), log(120), log(180)])).toBe(110);
});
it("ger 0 för en tur utan mätpunkter", () => {
expect(elevationGain([])).toBe(0);
});
// Regressionstest – skuld ur docs/debt.md: en mätpunkt utan höjd räknas som havsnivå
it("hoppar över mätpunkter utan höjd i stället för att räkna dem som noll", () => {
expect(elevationGain([log(100), log(null), log(150)])).toBe(50);
});
});
19 changes: 19 additions & 0 deletions client/src/lib/tours.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { TourLog } from "@utpost/shared";

export const elevationGain = (logs: TourLog[]): number => {
let gain = 0;
let previousElevation: number | null = null;

for (const log of logs) {
const elevation = log.elevation_m;
if (elevation == null) continue;

if (previousElevation !== null && elevation > previousElevation) {
gain += elevation - previousElevation;
}

previousElevation = elevation;
}

return gain;
};
29 changes: 0 additions & 29 deletions client/src/views/GuidesView.test.js

This file was deleted.

89 changes: 89 additions & 0 deletions client/src/views/GuidesView.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/vue";
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom/vitest";
import type { Guide } from "@utpost/shared";
import GuidesView from "./GuidesView.vue";
import { get } from "../api.js";

vi.mock("../api", () => ({ get: vi.fn() }));
const mockedGet = vi.mocked(get);

// Kontraktet styr mockdatan: glömmer ni ett fält säger typecheck ifrån.
const guide = (overrides: Partial<Guide>): Guide => ({
id: 1,
slug: "kebnekaise",
title: "Kebnekaise",
region: "Lappland",
difficulty: "svår",
length_km: 18,
body_html: "<p>Sveriges tak</p>",
hero_image: null,
published: true,
author_id: 1,
updated_at: "2026-09-01T00:00:00.000Z",
...overrides,
});

const renderView = () =>
render(GuidesView, {
global: { stubs: { RouterLink: { template: "<a><slot /></a>" } } },
});

describe("GuidesView", () => {
beforeEach(() => {
mockedGet.mockResolvedValue([
guide({ id: 1, title: "Kebnekaise", region: "Lappland" }),
guide({
id: 2,
slug: "sodra-myrleden",
title: "Södra Myrleden",
region: "Småland",
}),
]);
});
//Test 1
it("visar guiderna från API:et", async () => {
renderView();
expect(await screen.findByText("Kebnekaise")).toBeInTheDocument();
expect(screen.getByText("2 av 2")).toBeInTheDocument();
});

//Test 2

it("filtrerar på landskap när användaren söker", async () => {
const user = userEvent.setup();
renderView();
await screen.findByText("Kebnekaise");

await user.type(screen.getByLabelText("Sök"), "små");

expect(screen.getByText("Södra Myrleden")).toBeInTheDocument();
expect(screen.queryByText("Kebnekaise")).not.toBeInTheDocument();
expect(screen.getByText("1 av 2")).toBeInTheDocument();
});

//Test 3
it("visar ett fel när API:et inte svarar", async () => {
mockedGet.mockRejectedValueOnce(new Error("API svarade 500"));
renderView();
expect(await screen.findByRole("alert")).toHaveTextContent(
"API svarade 500",
);
});

//Test 4
it("säger till när inget matchar", async () => {
const user = userEvent.setup();
renderView();
await screen.findByText("Kebnekaise");

await user.type(screen.getByLabelText("Sök"), "öland");

expect(
screen.getByText("Inga guider matchar sökningen."),
).toBeInTheDocument();
expect(screen.getByText("0 av 2")).toBeInTheDocument();
expect(screen.queryByText("Kebnekaise")).not.toBeInTheDocument();
});
});
101 changes: 63 additions & 38 deletions client/src/views/GuidesView.vue
Original file line number Diff line number Diff line change
@@ -1,58 +1,83 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { ref, computed, onMounted } from "vue";
import type { Guide } from "@utpost/shared";
import { get } from "../api";
import GuideCard from "../components/GuideCard.vue";

const guides = ref<Guide[]>([]);
const searchQuery = ref("");
const query = ref("");
const loading = ref(true);
const error = ref<string | null>(null);

const fetchGuides = async (): Promise<void> => {
const load = async () => {
loading.value = true;
error.value = null;
try {
guides.value = await get<Guide[]>("/guides");
} catch (error) {
console.error("Error fetching guides:", error);
} catch (err: unknown) {
error.value =
err instanceof Error ? err.message : "Kunde inte hämta guider";
} finally {
loading.value = false;
}
};

onMounted(() => {
void fetchGuides();
});

const filterGuides = computed(() => {
if (searchQuery.value.trim() === "") return guides.value;
onMounted(load);

return guides.value.filter((guide) =>
guide.title.toLowerCase().includes(searchQuery.value.toLowerCase()),
const visible = computed(() => {
const q = query.value.trim().toLowerCase();
if (!q) return guides.value;
return guides.value.filter(
(g) =>
g.title.toLowerCase().includes(q) || g.region.toLowerCase().includes(q),
);
});

const searchGuides = (): void => {
if (searchQuery.value.trim() === "") {
void fetchGuides();
} else {
guides.value = guides.value.filter((guide) =>
guide.title.toLowerCase().includes(searchQuery.value.toLowerCase()),
);
}
};
</script>

<template>
<div class="guides">
<h1>Guides</h1>
<p>
Welcome to the guides section! Here you can find various guides to help
you navigate through our application.
</p>
<input v-model="searchQuery" placeholder="Search guides..." />
<button @click="searchGuides">Search</button>

<ul>
<li v-for="guide in filterGuides" :key="guide.id">
<router-link :to="`/guides/${guide.id}`">{{ guide.title }}</router-link>
</li>
</ul>
<div>
<h1>Guider</h1>

<div class="searchrow">
<label for="guide-search">Sök</label>
<input
id="guide-search"
v-model="query"
type="search"
placeholder="Namn eller landskap"
/>
<span class="muted">{{ visible.length }} av {{ guides.length }}</span>
</div>

<p v-if="loading">Laddar guider…</p>
<p v-else-if="error" role="alert">Kunde inte hämta guider: {{ error }}</p>
<p v-else-if="visible.length === 0">Inga guider matchar sökningen.</p>

<div v-else class="grid">
<GuideCard v-for="guide in visible" :key="guide.id" :guide="guide" />
</div>
</div>
</template>

<style scoped></style>
<style scoped>
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.searchrow {
display: flex;
gap: 8px;
align-items: center;
margin: 16px 0;
}
.searchrow input {
flex: 1;
padding: 8px;
border: 1px solid #ccc;
}
.muted {
color: #777;
font-size: 14px;
}
</style>
9 changes: 5 additions & 4 deletions client/vitest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
globals: true,
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)
Loading
Loading