diff --git a/docs/AI decleration.md b/docs/AI decleration.md
index d49fdb3..0191137 100644
--- a/docs/AI decleration.md
+++ b/docs/AI decleration.md
@@ -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.
diff --git a/docs/Architecture.md b/docs/Architecture.md
index 5dc51b2..9f1e885 100644
--- a/docs/Architecture.md
+++ b/docs/Architecture.md
@@ -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
@@ -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
@@ -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
@@ -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
diff --git a/docs/Sustainability.md b/docs/Sustainability.md
index 38f63e7..d8e4fed 100644
--- a/docs/Sustainability.md
+++ b/docs/Sustainability.md
@@ -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
diff --git a/docs/Testing.md b/docs/Testing.md
index bca7820..03c0720 100644
--- a/docs/Testing.md
+++ b/docs/Testing.md
@@ -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
diff --git a/project2/server/index.ts b/project2/server/index.ts
index 991195b..76a2fed 100644
--- a/project2/server/index.ts
+++ b/project2/server/index.ts
@@ -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!
}
`;
@@ -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.');
}
@@ -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: {
diff --git a/project2/src/App.tsx b/project2/src/App.tsx
index ce65daf..219f54c 100644
--- a/project2/src/App.tsx
+++ b/project2/src/App.tsx
@@ -38,6 +38,8 @@ const GET_POKEMONS = gql`
speed
hp
imageUrl
+ averageRating
+ ratingCount
}
total
hasNextPage
@@ -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
+ }
}
`;
@@ -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;
diff --git a/project2/src/components/AverageRating.tsx b/project2/src/components/AverageRating.tsx
new file mode 100644
index 0000000..a464a34
--- /dev/null
+++ b/project2/src/components/AverageRating.tsx
@@ -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 (
+
+ {compact ? 'No ratings' : 'No ratings yet'}
+
+ );
+ }
+
+ const rounded = average.toFixed(1);
+ const votes = `${count} ${count === 1 ? 'vote' : 'votes'}`;
+
+ return (
+
+
+ {compact
+ ? `${rounded} / 5 (${count})`
+ : `Average ${rounded} / 5 (${votes})`}
+
+
+ Average rating {rounded} out of 5, {votes}
+
+
+ );
+}
diff --git a/project2/src/components/PokemonCard.tsx b/project2/src/components/PokemonCard.tsx
index c30c609..8ffeb31 100644
--- a/project2/src/components/PokemonCard.tsx
+++ b/project2/src/components/PokemonCard.tsx
@@ -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;
}
/**
@@ -32,6 +33,11 @@ export function PokemonCard({ pokemon, onSelect }: PokemonCardProps) {
{pokemon.name}
#{String(pokemon.id).padStart(3, '0')}
+
);
}
diff --git a/project2/src/components/PokemonDetail.tsx b/project2/src/components/PokemonDetail.tsx
index 412ae7c..c900dbe 100644
--- a/project2/src/components/PokemonDetail.tsx
+++ b/project2/src/components/PokemonDetail.tsx
@@ -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;
@@ -71,6 +72,10 @@ export function PokemonDetail({
Speed: {pokemon.speed}
HP: {pokemon.hp}
+
{
@@ -38,4 +40,52 @@ describe('PokemonCard', () => {
expect(onSelect).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith(pokemon);
});
+
+ it('shows the average with one decimal and the vote count', () => {
+ render();
+
+ expect(screen.getByText('4.3 / 5 (17)')).toBeInTheDocument();
+ });
+
+ it('gives screen readers a full sentence instead of the short form', () => {
+ render();
+
+ expect(screen.getByText('4.3 / 5 (17)')).toHaveAttribute(
+ 'aria-hidden',
+ 'true'
+ );
+ expect(
+ screen.getByText('Average rating 4.3 out of 5, 17 votes')
+ ).toBeInTheDocument();
+ expect(screen.getByRole('button')).toHaveAccessibleName(
+ expect.stringContaining('Average rating 4.3 out of 5, 17 votes')
+ );
+ });
+
+ it('uses the singular "vote" for a single rating', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByText('Average rating 5.0 out of 5, 1 vote')
+ ).toBeInTheDocument();
+ });
+
+ it('shows "No ratings" instead of a zero average when unrated', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('No ratings')).toBeVisible();
+ expect(
+ screen.queryByText('/ 5', { exact: false })
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/project2/src/types/pokemon.ts b/project2/src/types/pokemon.ts
index d1184e7..c210dbe 100644
--- a/project2/src/types/pokemon.ts
+++ b/project2/src/types/pokemon.ts
@@ -25,3 +25,15 @@ export interface Pokemon {
hp: number;
imageUrl: string;
}
+
+/**
+ * A Pokémon as returned by our GraphQL API.
+ * The rating fields are not stored on the Pokémon itself; the server
+ * aggregates them from the separate `ratings` table on each request.
+ * Seed data (PokeAPI / pokemons.json) uses the plain `Pokemon` type.
+ */
+export interface RatedPokemon extends Pokemon {
+ /** Mean of all votes, or null when nobody has rated yet */
+ averageRating: number | null;
+ ratingCount: number;
+}