Skip to content

Tutorial: Build a "Top Albums" stat card

This tutorial walks you through adding a complete new feature to Unwrap. You'll touch every layer of the architecture, write a test, and end up with a working, customisable stat card.

If you've read the Overview and Architecture pages, this tutorial puts those concepts into practice.

What you'll learn

  • How to add a new dashboard category end-to-end
  • The three-layer pattern (Component → Hook → Service) in real use
  • How to define an API contract before the backend exists
  • How to write an MSW handler and a fixture
  • How to write an integration test using shared test infrastructure

Before you start

You need:

  • The dev server running (npm run dev)
  • A code editor with TypeScript support
  • About 30–45 minutes.

We'll build the feature in this order:

  1. Define the API contract (types)
  2. Add a service function
  3. Add a fixture and MSW handler so the service has something to return
  4. Add a TanStack Query hook
  5. Build the component
  6. Register the new category in the dashboard store
  7. Render it from the Dashboard page
  8. Write a test

Each step builds directly on the previous. If something doesn't work at the end of a step, fix it before moving on.

Step 1: Define the API contract

Every layer below this step depends on the response shape, so we define it first.

Open src/types/api.ts and add:

ts
export interface Album {
  rank: number;
  albumId: string;
  albumName: string;
  artistName: string;
  playCount: number;
  albumArtUrl: string;
}

export interface TopAlbumsResponse {
  pagination: Pagination;
  topAlbums: Album[];
}

Then extend the DashboardCategory union (in the same file) so the dashboard can recognise the new category:

ts
export type DashboardCategory =
  | 'top-tracks'
  | 'top-artists'
  | 'top-albums' // ← new
  | 'top-genres'
  | 'top-skips'
  | 'top-replays'
  | 'peak-hour'
  | 'most-active-day'
  | 'total-minutes';

NOTE

By defining the response shape before any code calls the API, every later layer is type-checked against the same contract. If you change the contract, TypeScript will tell you exactly which files break.

Step 2: Add a service function

Services do one job: fetch and return typed data. Open src/services/api.ts and add:

ts
/**
 * Fetches top albums for the given range
 * Mirrors the shape of fetchTopTracks/fetchTopArtists for consistency
 */
export async function fetchTopAlbums(
  timeRange?: TimeRange,
  limit = 10,
  offset = 0
): Promise<TopAlbumsResponse> {
  return apiFetch('/api/top-albums', buildParams({ timeRange, limit, offset }));
}

Three things to notice:

  • No useQuery, no React. Pure async function.
  • The default limit and offset match fetchTopTracks so callers can ignore them.
  • The endpoint path follows the existing convention /api/top-{thing}. Consistency makes the surface easy to remember.

If you save and run npm run dev, nothing visible has changed yet, but TypeScript should be happy. If it isn't, fix the type errors before continuing.

Step 3: Add a fixture and MSW handler

The backend doesn't have a /api/top-albums endpoint yet. MSW lets us mock it.

Open src/mocks/fixtures.ts and add fixture data. Use the same shape as the response type:

ts
export const topAlbumsFixture: TopAlbumsResponse = {
  pagination: {
    limit: 10,
    offset: 0,
    total: 4,
    hasMore: false,
    nextOffset: null,
    previousOffset: null,
  },
  topAlbums: [
    {
      rank: 1,
      albumId: 'a1',
      albumName: 'To Pimp a Butterfly',
      artistName: 'Kendrick Lamar',
      playCount: 142,
      albumArtUrl: '',
    },
    {
      rank: 2,
      albumId: 'a2',
      albumName: 'Currents',
      artistName: 'Tame Impala',
      playCount: 118,
      albumArtUrl: '',
    },
    {
      rank: 3,
      albumId: 'a3',
      albumName: 'In Rainbows',
      artistName: 'Radiohead',
      playCount: 97,
      albumArtUrl: '',
    },
    {
      rank: 4,
      albumId: 'a4',
      albumName: 'Channel Orange',
      artistName: 'Frank Ocean',
      playCount: 84,
      albumArtUrl: '',
    },
  ],
};

Then in src/mocks/handlers.ts, add a handler that returns it:

ts
import { topAlbumsFixture } from './fixtures';

// inside the exported handlers array:
http.get('/api/top-albums', () => HttpResponse.json(topAlbumsFixture)),

Refresh the dev server. Open your browser DevTools → Network tab. The request to /api/top-albums won't actually fire yet (no component calls it), but the handler is registered and waiting.

This is an honest contract

The fixture matches TopAlbumsResponse exactly because TypeScript enforces it. If you change the type, the fixture breaks at compile time. This is what makes mock-driven development sustainable as the mocks can't drift from the contract.

Step 4: Add a TanStack Query hook

Hooks turn services into reactive data. Open src/hooks/useUnwrapData.ts (or wherever your other category hooks live) and add:

ts
export function useTopAlbums(timeRange: TimeRange) {
  return useQuery({
    queryKey: ['top-albums', timeRange],
    queryFn: () => fetchTopAlbums(timeRange),
  });
}

Three points to consider:

  • The query key includes timeRange. Switching from "All Time" to "Past Week" creates a separate cache entry. Switch back, and the cached "All Time" data is returned instantly.
  • The hook returns the standard TanStack Query shape { data, isLoading, isError, ... }. The component will consume that, not the raw service result.
  • No transformation here. The hook is a thin wrapper. Transforming or formatting data is the component's job (or a util).

Step 5: Build the component

This is the visual layer. Create src/components/AlbumList/AlbumList.tsx:

tsx
import { useTopAlbums } from '@/hooks/useUnwrapData';
import type { TimeRange } from '@/types/api';
import Skeleton from '@/components/Skeleton/Skeleton';
import styles from './AlbumList.module.css';

interface Props {
  timeRange: TimeRange;
}

export default function AlbumList({ timeRange }: Props) {
  const { data, isLoading, isError } = useTopAlbums(timeRange);

  if (isLoading) {
    return (
      <div className={styles.list}>
        <Skeleton height="200px" />
      </div>
    );
  }

  if (isError || !data) {
    return <p className={styles.error}>Couldn't load top albums.</p>;
  }

  return (
    <section className={styles.list}>
      <h3 className={styles.heading}>Top Albums</h3>
      <ol className={styles.items}>
        {data.topAlbums.map((album) => (
          <li key={album.albumId} className={styles.item}>
            <span className={styles.rank}>{album.rank}</span>
            <div className={styles.meta}>
              <p className={styles.name}>{album.albumName}</p>
              <p className={styles.sub}>
                {album.artistName} · {album.playCount} plays
              </p>
            </div>
          </li>
        ))}
      </ol>
    </section>
  );
}

And the matching AlbumList.module.css:

css
.list {
  background-color: var(--color-surface);
  border-radius: var(--radius);
  padding: 16px;
}

.heading {
  font-size: 1rem;
  margin-bottom: 12px;
}

.items {
  list-style: none;
  padding: 0;
  margin: 0;
}

.item {
  display: flex;
  gap: 12px;
  padding: 8px 0;
}

.rank {
  font-weight: 700;
  color: var(--color-text-secondary);
  min-width: 24px;
}

.name {
  font-weight: 600;
}

.sub {
  font-size: 0.875rem;
  color: var(--color-text-secondary);
}

.error {
  color: var(--color-error);
}

Why this component is small

It does one thing: render top albums. It doesn't fetch (the hook does), doesn't cache (TanStack Query does), and doesn't decide whether it should be visible (the dashboard does). That's SRP in practice.

Step 6: Register the category

The dashboard reads category metadata from a single map in src/stores/dashboardStore.ts. Add the new entry:

ts
export const CATEGORY_LABELS: Record<DashboardCategory, string> = {
  'top-tracks': 'Top Tracks',
  'top-artists': 'Top Artists',
  'top-albums': 'Top Albums', // ← new
  'top-genres': 'Top Genres',
  'top-skips': 'Top Skips',
  'top-replays': 'Top Replays',
  'peak-hour': 'Peak Listening Hour',
  'most-active-day': 'Most Active Day',
  'total-minutes': 'Total Minutes Listened',
};

This is the only change needed in the store. ALL_CATEGORIES is derived from this object's keys, so the picker, persistence, and toggling logic all pick it up automatically. You can also add it to DEFAULT_CATEGORIES if you want it visible out of the box.

Step 7: Render it from the Dashboard

Open src/pages/Dashboard.tsx. Find the CategoryCard switch statement and add a new case:

tsx
import AlbumList from '@/components/AlbumList/AlbumList';

// inside CategoryCard:
case 'top-albums':
  return <AlbumList timeRange={timeRange} />;

Save. Open the dashboard in your browser. Click Customise, tick Top Albums, and the new card should appear.

🎉 You've added a fully working stat card across every architectural layer.

Step 8: Write a test

A feature without a test is half-finished. Create src/__tests__/components/AlbumList/AlbumList.test.tsx:

tsx
import { http, HttpResponse } from 'msw';
import { server } from '@/test/server';
import { renderWithProviders } from '@/test/renderWithProviders';
import { screen, waitFor } from '@testing-library/react';
import AlbumList from '@/components/AlbumList/AlbumList';

describe('AlbumList', () => {
  it('renders top albums after loading', async () => {
    renderWithProviders(<AlbumList timeRange="all" />);

    expect(await screen.findByText('To Pimp a Butterfly')).toBeInTheDocument();
    expect(screen.getByText(/Kendrick Lamar/)).toBeInTheDocument();
    expect(screen.getByText(/142 plays/)).toBeInTheDocument();
  });

  it('shows an error message when the request fails', async () => {
    server.use(
      http.get('/api/top-albums', () =>
        HttpResponse.json({ message: 'Server error' }, { status: 500 })
      )
    );

    renderWithProviders(<AlbumList timeRange="all" />);

    await waitFor(() => {
      expect(screen.getByText(/couldn't load top albums/i)).toBeInTheDocument();
    });
  });
});

Run the test:

bash
npm run test:run -- AlbumList

Both tests should pass. The first uses the default MSW handler from handlers.ts. The second overrides it for one test using server.use(), see the How to write a test guide for more on this pattern.

Tutorial checklist

You built a feature that:

  • Has a fully typed API contract (Album, TopAlbumsResponse)
  • Has a pure HTTP service (fetchTopAlbums)
  • Has a mock that lets the frontend run without a backend
  • Has a cached, retrying data hook (useTopAlbums)
  • Has a presentational component (AlbumList)
  • Is integrated into the dashboard's customisation system
  • Has tests covering both the success and error paths

Built for SE_07 — Technical Documentation