diff --git a/src/api/swapi.ts b/src/api/swapi.ts index 1ad445e..8342e34 100644 --- a/src/api/swapi.ts +++ b/src/api/swapi.ts @@ -1,4 +1,11 @@ -import type { SwapiFilm, SwapiFilmsPage, SwapiPlanet, SwapiPlanetsPage } from './types' +import type { + SwapiFilm, + SwapiFilmsPage, + SwapiPeoplePage, + SwapiPerson, + SwapiPlanet, + SwapiPlanetsPage, +} from './types' const SWAPI_BASE_URL = 'https://swapi.dev/api' @@ -51,3 +58,21 @@ export async function getFilms(): Promise { const payload = await fetchJson('/films/') return payload.results } + +export async function getPeople(): Promise { + const firstPage = await fetchJson('/people/') + const pageSize = firstPage.results.length || 10 + const totalPages = Math.ceil(firstPage.count / pageSize) + + if (totalPages <= 1) { + return firstPage.results + } + + const remainingPages = await Promise.all( + Array.from({ length: totalPages - 1 }, (_, index) => + fetchJson(`/people/?page=${index + 2}`), + ), + ) + + return firstPage.results.concat(...remainingPages.map((page) => page.results)) +} diff --git a/src/api/types.ts b/src/api/types.ts index 7cde8de..e8d567e 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -27,5 +27,11 @@ export interface SwapiFilm { url: string } +export interface SwapiPerson { + name: string + url: string +} + export type SwapiPlanetsPage = SwapiListResponse export type SwapiFilmsPage = SwapiListResponse +export type SwapiPeoplePage = SwapiListResponse diff --git a/src/stores/__tests__/planets.spec.ts b/src/stores/__tests__/planets.spec.ts index b87fa7e..f12fe3e 100644 --- a/src/stores/__tests__/planets.spec.ts +++ b/src/stores/__tests__/planets.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { createPinia, setActivePinia } from 'pinia' import { usePlanetsStore, PAGE_SIZE } from '../planets' -import type { SwapiPlanet, SwapiFilm } from '@/api/types' +import type { SwapiPlanet, SwapiFilm, SwapiPerson } from '@/api/types' function makePlanet(overrides: Partial = {}): SwapiPlanet { const id = overrides.url?.match(/\/planets\/(\d+)/)?.[1] ?? '1' @@ -34,19 +34,25 @@ function makeFilm(index: number): SwapiFilm { return { title: `Film ${index}`, url: `https://swapi.dev/api/films/${index}/` } } +function makePerson(index: number): SwapiPerson { + return { name: `Person ${index}`, url: `https://swapi.dev/api/people/${index}/` } +} + vi.mock('@/api/swapi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, getAllPlanets: vi.fn(), getFilms: vi.fn(), + getPeople: vi.fn(), getPlanet: vi.fn(), } }) -import { getAllPlanets, getFilms, getPlanet } from '@/api/swapi' +import { getAllPlanets, getFilms, getPeople, getPlanet } from '@/api/swapi' const mockedGetAllPlanets = vi.mocked(getAllPlanets) const mockedGetFilms = vi.mocked(getFilms) +const mockedGetPeople = vi.mocked(getPeople) const mockedGetPlanet = vi.mocked(getPlanet) beforeEach(() => { @@ -54,6 +60,7 @@ beforeEach(() => { setActivePinia(createPinia()) vi.resetAllMocks() mockedGetFilms.mockResolvedValue([]) + mockedGetPeople.mockResolvedValue([]) }) // --------------------------------------------------------------------------- @@ -168,6 +175,24 @@ describe('filmTitlesFor', () => { }) }) +// --------------------------------------------------------------------------- +// residentNamesFor +// --------------------------------------------------------------------------- +describe('residentNamesFor', () => { + it('maps resident URLs to names and filters out missing ones', async () => { + const person1 = makePerson(1) + mockedGetPeople.mockResolvedValue([person1]) + mockedGetAllPlanets.mockResolvedValue([]) + const store = usePlanetsStore() + await store.loadCatalogue() + + const planet = makePlanet({ + residents: [person1.url, 'https://swapi.dev/api/people/99/'], + }) + expect(store.residentNamesFor(planet)).toEqual(['Person 1']) + }) +}) + // --------------------------------------------------------------------------- // loadCatalogue // --------------------------------------------------------------------------- @@ -247,16 +272,27 @@ describe('loadPlanet', () => { expect(store.detailError).toBe('Not found') }) - it('dedupes concurrent film fetches when loading planet and catalogue together', async () => { + it('dedupes concurrent film and people fetches when loading planet and catalogue together', async () => { let resolveFilms!: (films: SwapiFilm[]) => void mockedGetFilms.mockReturnValue( new Promise((resolve) => { resolveFilms = resolve }), ) + let resolvePeople!: (people: SwapiPerson[]) => void + mockedGetPeople.mockReturnValue( + new Promise((resolve) => { + resolvePeople = resolve + }), + ) const film = makeFilm(1) + const person = makePerson(1) mockedGetPlanet.mockResolvedValue( - makePlanet({ url: 'https://swapi.dev/api/planets/5/', films: [film.url] }), + makePlanet({ + url: 'https://swapi.dev/api/planets/5/', + films: [film.url], + residents: [person.url], + }), ) mockedGetAllPlanets.mockResolvedValue(makePlanets(2)) @@ -265,11 +301,15 @@ describe('loadPlanet', () => { const cataloguePromise = store.loadCatalogue() expect(mockedGetFilms).toHaveBeenCalledOnce() + expect(mockedGetPeople).toHaveBeenCalledOnce() resolveFilms([film]) + resolvePeople([person]) await Promise.all([planetPromise, cataloguePromise]) expect(mockedGetFilms).toHaveBeenCalledOnce() + expect(mockedGetPeople).toHaveBeenCalledOnce() expect(store.filmTitlesFor(store.planetsById['5']!)).toEqual(['Film 1']) + expect(store.residentNamesFor(store.planetsById['5']!)).toEqual(['Person 1']) }) }) diff --git a/src/stores/planets.ts b/src/stores/planets.ts index 6359f48..27201ea 100644 --- a/src/stores/planets.ts +++ b/src/stores/planets.ts @@ -1,7 +1,7 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' -import { getAllPlanets, getFilms, getPlanet, planetIdFromUrl } from '@/api/swapi' +import { getAllPlanets, getFilms, getPeople, getPlanet, planetIdFromUrl } from '@/api/swapi' import type { SwapiPlanet } from '@/api/types' import { pageCountFor, pageSlice } from '@/utils/pagination' import { readStringList, writeStringList } from '@/utils/storage' @@ -11,6 +11,7 @@ export const PAGE_SIZE = 10 export const usePlanetsStore = defineStore('planets', () => { const filmTitlesByUrl = ref>({}) + const residentNamesByUrl = ref>({}) const allPlanets = ref([]) const planetsById = ref>({}) const currentPage = ref(1) @@ -59,6 +60,12 @@ export const usePlanetsStore = defineStore('planets', () => { .filter((title): title is string => Boolean(title)) } + function residentNamesFor(planet: SwapiPlanet): string[] { + return planet.residents + .map((url) => residentNamesByUrl.value[url]) + .filter((name): name is string => Boolean(name)) + } + function toggleFavourite(id: string) { const next = isFavourite(id) ? favouriteIds.value.filter((favouriteId) => favouriteId !== id) @@ -97,6 +104,27 @@ export const usePlanetsStore = defineStore('planets', () => { await filmsRequest } + let peopleRequest: Promise | null = null + + async function ensurePeople(): Promise { + if (Object.keys(residentNamesByUrl.value).length > 0) return + + if (!peopleRequest) { + peopleRequest = getPeople() + .then((people) => { + residentNamesByUrl.value = Object.fromEntries( + people.map((person) => [person.url, person.name]), + ) + }) + .catch((error) => { + peopleRequest = null + throw error + }) + } + + await peopleRequest + } + async function loadCatalogue({ force = false } = {}): Promise { listError.value = null @@ -106,7 +134,7 @@ export const usePlanetsStore = defineStore('planets', () => { listLoading.value = true try { - await ensureFilms() + await Promise.all([ensureFilms(), ensurePeople()]) const planets = await getAllPlanets() allPlanets.value = planets planets.forEach(cachePlanet) @@ -127,7 +155,7 @@ export const usePlanetsStore = defineStore('planets', () => { detailLoading.value = true try { - await ensureFilms() + await Promise.all([ensureFilms(), ensurePeople()]) const planet = await getPlanet(id) cachePlanet(planet) return planet @@ -155,6 +183,7 @@ export const usePlanetsStore = defineStore('planets', () => { isSearching, planetsById, filmTitlesFor, + residentNamesFor, isFavourite, toggleFavourite, setPage, diff --git a/src/views/PlanetDetailView.vue b/src/views/PlanetDetailView.vue index 7505645..c3c1779 100644 --- a/src/views/PlanetDetailView.vue +++ b/src/views/PlanetDetailView.vue @@ -13,7 +13,7 @@ import { planetSurfaceStyle } from '@/utils/planetSurface' const route = useRoute() const store = usePlanetsStore() const { planetsById, detailLoading, detailError, filteredPlanets, allPlanets } = storeToRefs(store) -const { loadPlanet, loadCatalogue, filmTitlesFor } = store +const { loadPlanet, loadCatalogue, filmTitlesFor, residentNamesFor } = store const planetId = computed(() => String(route.params.id)) const planet = computed(() => planetsById.value[planetId.value]) @@ -31,6 +31,12 @@ const filmLine = computed(() => { return titles.length ? titles.join(', ') : 'No films listed' }) +const residentLine = computed(() => { + if (!planet.value) return '' + const names = residentNamesFor(planet.value) + return names.length ? names.join(', ') : 'No known residents' +}) + const stats = computed(() => { if (!planet.value) return [] return [ @@ -117,6 +123,21 @@ watch(

+
+

+ Residents +

+

+ {{ residentLine }} +

+
+