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
9 changes: 9 additions & 0 deletions docs/AI decleration.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,12 @@ final implementation and documentation.
reviewed by the team.
- **`#57` - Rating system changes (10 November 2026):**
`Claude Opus 5.5` was consulted for rating system changes in order for logic to have fewer flaws, work with the current frontend implementations and generate tests for the new aggregate system.
- **`#58` - Display average rating and vote count (10 November 2026):**
We consulted `Claude Opus 5.5` on how to show the rating aggregate in the
frontend. It helped me understand how ratings stored in their own table are
combined with the Pokémon data by the GraphQL server, and i used this to
decide on a separate `RatedPokemon` type and on letting `ratePokemon` return
the updated Pokémon so the Apollo cache updates without a refetch. It also
helped write parts of the display component and related tests. I
chose how the rating should look on the cards, tested the changes in the
browser, and reviewed the code and documentation before committing.
58 changes: 58 additions & 0 deletions docs/Architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ The interface is divided into smaller components:
- `PokemonCard` displays a Pokémon in the result grid.
- `PokemonDetail` displays more information about a selected Pokémon.
- `PokemonRating` handles the rating controls.
- `AverageRating` shows the average rating and number of votes. It is used
both on the cards (short version) and in the detail view (longer version).

The search input is debounced before a new query is sent. The number of cards
per page is also calculated from the available grid space, so the layout can
Expand All @@ -35,6 +37,22 @@ Ratings are stored in the browser's `localStorage` as well as being sent to the
server. A browser-generated voter ID connects a user's local rating state to
the corresponding server records.

The user's own rating and the community average are two different things in
the interface. The stars in the detail view show what you rated yourself. The
average and vote count show what everyone has rated together. We kept them
apart on purpose, so it is clear which number you are looking at.

For screen readers, the short text on the card (for example `4.2 / 5 (17)`)
does not read very well out loud. We therefore hide that text from screen
readers and give them a full sentence instead: "Average rating 4.2 out of 5,
17 votes". Pokémon without any ratings show "No ratings" instead of a
misleading 0.

We added a separate TypeScript type, `RatedPokemon`, in `src/types/pokemon.ts`.
The normal `Pokemon` type describes the data we seed from PokeAPI, which does
not have ratings. `RatedPokemon` is a `Pokemon` plus the two rating fields the
API adds. This way the seed code does not have to pretend it has ratings.

## Backend

The backend runs on Node.js and uses Apollo Server to expose a GraphQL API. It
Expand All @@ -58,6 +76,39 @@ The production GraphQL schema and resolver logic are defined in
well. `server/schema.ts` contains the schema and resolver implementation used
by the API tests.

### Average rating and vote count

Each Pokémon in the API has two fields for community ratings:

- `averageRating`, the mean of all votes. It is `null` if nobody has rated the
Pokémon yet.
- `ratingCount`, the number of votes.

These values are not saved on the Pokémon. The ratings live in their own
`ratings` table, and the server calculates the average and count from that
table every time they are asked for. This works the same way as `types` and
`abilities`, which are also separate tables that the server joins onto the
Pokémon when it builds the response.

We chose to calculate the values instead of storing them, because a stored
average could get out of sync with the actual votes. With the calculation,
the numbers are always correct. If a voter changes their rating, the average
changes, but the count stays the same, because the composite key on
`(pokemon_id, voter_id)` means each voter only has one row per Pokémon.

`null` is used instead of 0 for unrated Pokémon. An average of 0 would look
like everyone gave the lowest rating, which is not true.

The `ratePokemon` mutation returns the updated Pokémon, not just the number
that was submitted. The frontend asks for `id`, `averageRating`, and
`ratingCount` in the response, which lets Apollo Client update the Pokémon it
already has in its cache (see the communication section below).

A limitation of the current solution is that the server runs one small SQL
query per Pokémon for the rating fields, the same as for types and abilities.
With at most 100 Pokémon per page this is fine, but batching these queries
would be a good improvement if the dataset grows.

## Database

SQLite is used through `better-sqlite3`. The database contains four main
Expand Down Expand Up @@ -102,6 +153,13 @@ and then sends the `ratePokemon` mutation. If the server request fails, the
browser still retains the local rating and displays an error so the user can
retry.

When the mutation succeeds, the server responds with the Pokémon's `id` and
the new `averageRating` and `ratingCount`. Apollo Client stores each Pokémon in
its cache under its type and `id`, so it recognises this as a Pokémon it has
already loaded and updates it. Both the card in the grid and the open detail
view then show the new average straight away, without sending a new list
query.

## Main data flow

```text
Expand Down
9 changes: 9 additions & 0 deletions docs/Sustainability.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ matching page of results instead of downloading the complete dataset and
filtering it in the browser. This reduces both network traffic and client-side
processing.

### Updating ratings without a new list request

When a user rates a Pokémon, the `ratePokemon` mutation sends back the updated
average and vote count for that one Pokémon. Apollo Client uses this response
to update the Pokémon it already has in its cache, so we do not need to fetch
the whole page of results again just to show the new average. The only
exception is when the list is sorted by rating, because then the order of the
list can change and the page has to be loaded again.

### Apollo client cache

The frontend uses Apollo Client's `InMemoryCache`. Responses may be reused by
Expand Down
23 changes: 23 additions & 0 deletions docs/Testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,29 @@ the identity and image data supplied by the API, including the padded
Pokédex ID and accessible image alternative text. It also checks that clicking
the card passes the complete Pokémon object to the parent.

The `PokemonCard` tests also cover the average rating on the card. We test
that the average is rounded to one decimal, that a single vote says "1 vote"
and not "1 votes", and that an unrated Pokémon shows "No ratings" instead of a
number. We also test what a screen reader gets: the short text is hidden from
assistive technology, and the full sentence ("Average rating 4.3 out of 5, 17
votes") is part of the card button's accessible name. We think it is
important to test this, because an accessibility mistake here would not be
visible when you look at the page.

The API tests have a section for the rating aggregate. They check that a
Pokémon with no ratings returns `averageRating: null` and `ratingCount: 0`,
that one and several votes give the right values, that the query is run for
the correct Pokémon ID, and that every Pokémon on a page gets its own values.

The API tests use a mocked database, so they test that the resolvers pass the
values through correctly, but not that the SQL itself calculates the right
average. To check the SQL, we ran the real server against an empty test
database and sent ratings to it. The results matched what we expected: one
vote of 4 gave an average of 4, a second vote of 3 gave 3.5 with 2 votes, and
when the first voter changed their rating to 1 the average became 2 while the
count stayed at 2. An invalid rating of 9 was rejected. Turning this into an
automatic test against a real in-memory database would be a good next step.

Run the component tests from the repository root:

```bash
Expand Down
10 changes: 6 additions & 4 deletions project2/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ const typeDefs = `#graphql
}
type Mutation {
ratePokemon(pokemonId: Int!, voterId: ID!, value: Int!): Int!
ratePokemon(pokemonId: Int!, voterId: ID!, value: Int!): Pokemon!
}
`;

Expand Down Expand Up @@ -158,8 +158,8 @@ const resolvers = {
throw new Error('Rating must be an integer between 1 and 5.');
}
const pokemon = db
.prepare('SELECT id FROM pokemons WHERE id = ?')
.get(args.pokemonId);
.prepare('SELECT * FROM pokemons WHERE id = ?')
.get(args.pokemonId) as Pokemon | undefined;
if (!pokemon) {
throw new Error('Pokemon not found.');
}
Expand All @@ -171,7 +171,9 @@ const resolvers = {
DO UPDATE SET rating = excluded.rating
`).run(args.pokemonId, args.voterId, args.value);

return args.value;
// Return the Pokémon so clients can update their cache: the field
// resolvers below recompute averageRating and ratingCount.
return pokemon;
},
},
Query: {
Expand Down
12 changes: 10 additions & 2 deletions project2/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const GET_POKEMONS = gql`
speed
hp
imageUrl
averageRating
ratingCount
}
total
hasNextPage
Expand All @@ -49,7 +51,13 @@ const GET_POKEMONS = gql`

const RATE_POKEMON = gql`
mutation RatePokemon($pokemonId: Int!, $voterId: ID!, $value: Int!) {
ratePokemon(pokemonId: $pokemonId, voterId: $voterId, value: $value)
ratePokemon(pokemonId: $pokemonId, voterId: $voterId, value: $value) {
# Apollo matches this on id and updates the cached Pokémon,
# so cards and the detail view show the new average without a refetch
id
averageRating
ratingCount
}
}
`;

Expand Down Expand Up @@ -107,7 +115,7 @@ function loadLocalRatings(): {

interface PokemonsPageData {
pokemons: {
items: import('./types/pokemon').Pokemon[];
items: import('./types/pokemon').RatedPokemon[];
total: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
Expand Down
43 changes: 43 additions & 0 deletions project2/src/components/AverageRating.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*********
Read-only display of the community rating for a Pokémon.
Used in both the card (compact) and the detail view (full text).
The visible text is a short "4.2 / 5 (17)" that reads poorly aloud,
so screen readers get a separate full sentence instead.
**********/

interface AverageRatingProps {
average: number | null;
count: number;
/** Short form for cards: "4.2 / 5 (17)". Full form for the detail view. */
compact?: boolean;
}

export function AverageRating({
average,
count,
compact = false,
}: AverageRatingProps) {
if (average === null || count === 0) {
return (
<span className="average-rating average-rating--empty">
{compact ? 'No ratings' : 'No ratings yet'}
</span>
);
}

const rounded = average.toFixed(1);
const votes = `${count} ${count === 1 ? 'vote' : 'votes'}`;

return (
<span className="average-rating">
<span aria-hidden="true">
{compact
? `${rounded} / 5 (${count})`
: `Average ${rounded} / 5 (${votes})`}
</span>
<span className="visually-hidden">
Average rating {rounded} out of 5, {votes}
</span>
</span>
);
}
12 changes: 9 additions & 3 deletions project2/src/components/PokemonCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
Can be extended to show more info, but not currently needed.
**********/

import type { Pokemon } from '../types/pokemon';
import type { RatedPokemon } from '../types/pokemon';
import { AverageRating } from './AverageRating';

interface PokemonCardProps {
pokemon: Pokemon;
onSelect: (pokemon: Pokemon) => void;
pokemon: RatedPokemon;
onSelect: (pokemon: RatedPokemon) => void;
}

/**
Expand All @@ -32,6 +33,11 @@ export function PokemonCard({ pokemon, onSelect }: PokemonCardProps) {
{pokemon.name}
</h2>
<small>#{String(pokemon.id).padStart(3, '0')}</small>
<AverageRating
average={pokemon.averageRating}
count={pokemon.ratingCount}
compact
/>
</button>
);
}
9 changes: 7 additions & 2 deletions project2/src/components/PokemonDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@
More info about it will come, including type abilities etc, this is more to show the idea.
**********/

import type { Pokemon } from '../types/pokemon';
import type { RatedPokemon } from '../types/pokemon';
import { AverageRating } from './AverageRating';
import { PokemonRating } from './PokemonRating';

interface PokemonDetailProps {
pokemon: Pokemon;
pokemon: RatedPokemon;
rating: number | null;
ratingDisabled: boolean;
ratingError: string | null;
Expand Down Expand Up @@ -71,6 +72,10 @@ export function PokemonDetail({
<span>Speed: {pokemon.speed}</span> <span></span>
<span>HP: {pokemon.hp}</span> <span></span>
</div>
<AverageRating
average={pokemon.averageRating}
count={pokemon.ratingCount}
/>
<PokemonRating
pokemonName={pokemon.name}
rating={rating}
Expand Down
26 changes: 26 additions & 0 deletions project2/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,32 @@ main {
font-size: 0.85rem;
}

/* Community average (AverageRating component), shown on cards and in the detail view */
.average-rating {
display: block;
margin-top: 0.25rem;
font-size: 0.85rem;
text-align: center;
}

/* Unrated: italic instead of a lighter colour, so text contrast stays the same */
.average-rating--empty {
font-style: italic;
}

/* Hidden visually but still read by screen readers */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

.pokemon-types {
display: flex;
flex-wrap: wrap;
Expand Down
Loading