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 }}
+
+
+