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
27 changes: 26 additions & 1 deletion src/api/swapi.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -51,3 +58,21 @@ export async function getFilms(): Promise<SwapiFilm[]> {
const payload = await fetchJson<SwapiFilmsPage>('/films/')
return payload.results
}

export async function getPeople(): Promise<SwapiPerson[]> {
const firstPage = await fetchJson<SwapiPeoplePage>('/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<SwapiPeoplePage>(`/people/?page=${index + 2}`),
),
)

return firstPage.results.concat(...remainingPages.map((page) => page.results))
}
6 changes: 6 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,11 @@ export interface SwapiFilm {
url: string
}

export interface SwapiPerson {
name: string
url: string
}

export type SwapiPlanetsPage = SwapiListResponse<SwapiPlanet>
export type SwapiFilmsPage = SwapiListResponse<SwapiFilm>
export type SwapiPeoplePage = SwapiListResponse<SwapiPerson>
48 changes: 44 additions & 4 deletions src/stores/__tests__/planets.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): SwapiPlanet {
const id = overrides.url?.match(/\/planets\/(\d+)/)?.[1] ?? '1'
Expand Down Expand Up @@ -34,26 +34,33 @@ 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<typeof import('@/api/swapi')>()
return {
...actual,
getAllPlanets: vi.fn<typeof actual.getAllPlanets>(),
getFilms: vi.fn<typeof actual.getFilms>(),
getPeople: vi.fn<typeof actual.getPeople>(),
getPlanet: vi.fn<typeof actual.getPlanet>(),
}
})

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(() => {
localStorage.clear()
setActivePinia(createPinia())
vi.resetAllMocks()
mockedGetFilms.mockResolvedValue([])
mockedGetPeople.mockResolvedValue([])
})

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<SwapiFilm[]>((resolve) => {
resolveFilms = resolve
}),
)
let resolvePeople!: (people: SwapiPerson[]) => void
mockedGetPeople.mockReturnValue(
new Promise<SwapiPerson[]>((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))

Expand All @@ -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'])
})
})

Expand Down
35 changes: 32 additions & 3 deletions src/stores/planets.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -11,6 +11,7 @@ export const PAGE_SIZE = 10

export const usePlanetsStore = defineStore('planets', () => {
const filmTitlesByUrl = ref<Record<string, string>>({})
const residentNamesByUrl = ref<Record<string, string>>({})
const allPlanets = ref<SwapiPlanet[]>([])
const planetsById = ref<Record<string, SwapiPlanet>>({})
const currentPage = ref(1)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -97,6 +104,27 @@ export const usePlanetsStore = defineStore('planets', () => {
await filmsRequest
}

let peopleRequest: Promise<void> | null = null

async function ensurePeople(): Promise<void> {
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<void> {
listError.value = null

Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -155,6 +183,7 @@ export const usePlanetsStore = defineStore('planets', () => {
isSearching,
planetsById,
filmTitlesFor,
residentNamesFor,
isFavourite,
toggleFavourite,
setPage,
Expand Down
23 changes: 22 additions & 1 deletion src/views/PlanetDetailView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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 [
Expand Down Expand Up @@ -117,6 +123,21 @@ watch(
</p>
</section>

<section class="mt-8" aria-labelledby="residents-heading">
<h2
id="residents-heading"
class="border-b border-star pb-2 text-sm font-normal uppercase tracking-wide text-star"
>
Residents
</h2>
<p
class="mt-4 text-xl font-light leading-snug text-star md:text-2xl"
:class="{ italic: !planet.residents.length }"
>
{{ residentLine }}
</p>
</section>

<dl class="mt-10 grid grid-cols-2 gap-x-6 gap-y-8 sm:gap-x-10 lg:grid-cols-3">
<div v-for="[label, value] in stats" :key="label">
<dt
Expand Down
Loading