From 164e08c1d313157699e4f0bcdcfb2efd9a7ce86f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Or=C5=82=C3=B3w?= Date: Wed, 7 Oct 2026 03:34:07 +0200 Subject: [PATCH] docs(mypolitics): add universal orientation spec and tasks Add the spec of the one orientation definition the app uses and how it is read from the survey API, and the two front-end tasks cut from it: the definition with its API reading, and its adoption across the result modules in place of "party" and "candidate". Co-Authored-By: Claude Opus 5.5 --- mypolitics/spec/quiz/README.md | 2 + mypolitics/spec/quiz/universal-orientation.md | 276 ++++++++++++ .../modules/cycle-4/universal-orientation.md | 407 ++++++++++++++++++ .../cycle-5/universal-orientation-adoption.md | 249 +++++++++++ 4 files changed, 934 insertions(+) create mode 100644 mypolitics/spec/quiz/universal-orientation.md create mode 100644 mypolitics/tasks/epics/modules/cycle-4/universal-orientation.md create mode 100644 mypolitics/tasks/epics/modules/cycle-5/universal-orientation-adoption.md diff --git a/mypolitics/spec/quiz/README.md b/mypolitics/spec/quiz/README.md index 64892e8..df23ff6 100644 --- a/mypolitics/spec/quiz/README.md +++ b/mypolitics/spec/quiz/README.md @@ -15,6 +15,7 @@ | [Single axis chart](./single-axis-chart.md) | One orientation, one bar | | [Traits](./traits.md) | The traits a taker earned, as a set of pills | | [Universal axis](./universal-axis.md) | The bar every result module is built from | +| [Universal orientation](./universal-orientation.md) | The one definition of an orientation, and how it is read from the API | ## Build order The result modules lean on each other, so they are built in steps. Everything inside one step can be built at the same time; a step needs the ones above it only where the last column says so. @@ -23,6 +24,7 @@ The result modules lean on each other, so they are built in steps. Everything in |---|---|---| | 0 | [Universal axis](./universal-axis.md) | Nothing | | 0 | [Module wrapper](./module-wrapper.md) | Nothing | +| 0 | [Universal orientation](./universal-orientation.md) | Nothing | | 1 | [Single axis chart](./single-axis-chart.md) | Universal axis, module wrapper | | 1 | [Double axis chart](./double-axis-chart.md) | Universal axis, module wrapper | | 1 | [Traits](./traits.md) | Module wrapper | diff --git a/mypolitics/spec/quiz/universal-orientation.md b/mypolitics/spec/quiz/universal-orientation.md new file mode 100644 index 0000000..600ff40 --- /dev/null +++ b/mypolitics/spec/quiz/universal-orientation.md @@ -0,0 +1,276 @@ +# Universal orientation + +> Technical specification of the one definition of an orientation the app uses, and how it is read from the API. + +Docs: [Result modules](../../docs/modules/quiz/results/modules/README.md), for the rule that every entity with points is an orientation, and the [glossary](../../docs/concept/glossary.md). + +## Kind +Front-end + +## Scope +The single definition of an orientation: what one is, which properties it carries, what each is called, and how the orientation the API sends is read into it. Everything in the app that names, scores or compares something uses this definition - the result modules, the header, the checkpoint cards, comparison and the generated result image. + +It replaces three things: + +- **Each module described an orientation in its own words**, and none of those descriptions matches what the API sends. +- **The API packs the same property differently from quiz to quiz** - a candidate's name one way, an identity's another - and each old result screen read only its own. +- **The other person in a comparison was called a party**, while in the API a party is a type of orientation. + +Packing a property into a text field is not a valid way to send it. This spec reads packed text because old quiz versions hold it and their results are still opened, and for no other reason. It names the properties the API has no field for, so that they are asked of the back-end as fields. + +Not covered here: + +- **How points become a value** - scoring. This spec only says how a result finds its orientation. +- **How an axis is defined** - quiz configuration. An axis refers to orientations by identifier, and that is all this spec needs from it. +- **Which module shows which orientation** - quiz configuration, as the [result modules](../../docs/modules/quiz/results/modules/README.md) doc says. +- **Whether an author's name, slogan, website or description is acceptable** - moderation. +- **What a friend's name and avatar are, and where they come from** - [comparison](../../docs/modules/quiz/results/comparison/README.md). This spec only says what shape they arrive in. + +## Data + +### What an orientation carries +| Property | Data | Meaning | Rules | +|---|---|---|---| +| Identifier | Text | What results, axes, answers and links refer to it by | Required. Unique within a quiz | +| Type | Ideology, party, identity, compass, person or other | What sort of thing it is | Required. Other when unknown | +| Name | Text | The display name | Optional. May come in two forms | +| Image | Web address | The icon, logo, portrait or avatar | Optional. May come in two forms | +| Colour | A colour | The colour it is drawn in | Optional | +| Description | Text | The text shown by default | Optional | +| Full description | Text | The longer text, opened on request | Optional | +| Slogan | Text | One author-written line | Optional | +| Website | Web address | Where the author sends a taker to read more - a candidate's programme | Optional | +| Official | Yes or no | Whether a moderator marked its answers as given by the party or candidate itself | No unless said. Only in an official quiz | +| Hidden | Yes or no | Whether the author took it out of what takers see | No unless said | +| Explanation | Text | The author's note on how to read this orientation | Optional | +| Linked orientations | A list of identifiers | Other orientations of the same quiz the author tied to this one - today, the parties closest to an identity | May be empty | + +Every text, both addresses and the colour are author-supplied and untrusted. The official mark is not the author's to give: a moderator grants it, in the quizzes myPolitics writes itself. + +An orientation belongs to one quiz. It is always read as part of its quiz and carries no reference back to it. + +### Types +| Type | From the API | What it is | +|---|---|---| +| Ideology | `IDEOLOGY` | A position on one issue - usually one pole of an axis | +| Party | `PARTY` | Something a taker can vote for: a political party or a candidate | +| Identity | `IDENTITY` | A named character a taker can come out as | +| Compass | `COMPASS` | A position on a compass. The API has the type; no quiz in use sends one | +| Person | Never | Someone the taker compares with. Built by the app, never sent by a quiz | +| Other | Anything else, or nothing | A type the app does not know | + +A trait, an archetype and a candidate are not types. They are what an orientation is used as, once an author points the traits module, the archetype module or a ranking at it. The API has no type for any of them: the presidential quiz sends its candidates as parties, and an orientation created without a type arrives as a party too. So the type is a label for choosing orientations and nothing more. + +### Words +| Word | Means | Never means | +|---|---|---| +| Orientation | Anything a result can name, score or compare against, in the shape above | Only an ideology | +| Party | An orientation of type party: a political party or a candidate | The other person in a comparison | +| The other side | The orientation a taker is compared with: a person, or an orientation the quiz scores | A second kind of data. It is an orientation | +| Entry | An orientation together with its value, a number from 0 to 100 that may be absent | - | +| Form | One of two variants of a name or an image: masculine and feminine | A translation | +| Packed text | A text field of the API whose content is a JSON object and not plain words. A legacy format | A valid way to send a property | + +### Reading the API +Orientations arrive inside a quiz, under `orientations`. + +| Property | From | Reading | +|---|---|---| +| Identifier | `id` | As sent | +| Type | `type` | By the table above | +| Name | `generalName` | The text is the name | +| Image | `logoUrl` | The address is the image | +| Colour | `color` | As sent | +| Description | `description` | The text is the description | +| Explanation | `explanation` | As sent | +| Linked orientations | `linkedOrientations` | As sent | +| - | `surveyId` | Not carried | + +This is the whole of the valid reading: one field, one property, plain words in it. + +Every text arrives in one language - the one the quiz was asked for, or the quiz's default. The API keeps the translations and sends one, so an orientation never holds two languages at once. + +### Properties without a field +The API has no field for these. Each is asked of the back-end as a field of its own. + +| Property | Field today | Carried today by | +|---|---|---| +| The two forms of a name | None | Packed text in `generalName` | +| The two forms of an image | None | Packed text in `logoUrl` | +| Full description | None | Packed text in `description` | +| Slogan | None | Packed text in `generalName` | +| Website | None | Packed text in `generalName` | +| Official | None | Packed text in `generalName` | +| Hidden | None | Packed text in `generalName` | + +Until a field exists, the property is read from packed text and from nowhere else. Once it exists, the field is what the app reads, and packed text is only the fallback for a quiz version that has no value in the field. + +### Packed text, read for old quiz versions +Packed text is not valid. No new quiz, no new version of a quiz and no new property uses it, and nothing in the app produces it. The keys below are the ones old quiz versions already hold, and the list is closed. + +| Field | Key | Read as | +|---|---|---| +| `generalName` | `name` | Name | +| `generalName` | `m`, `f` | The masculine and the feminine form of the name | +| `generalName` | `slogan` | Slogan | +| `generalName` | `websiteUrl` | Website | +| `generalName` | `isOfficial` | Official | +| `generalName` | `isHidden` | Hidden | +| `logoUrl` | `m`, `f` | The masculine and the feminine form of the image | +| `description` | `short` or `shortDescription` | Description | +| `description` | `long` or `longDescription` | Full description | + +Packed text is recognised by its content, never by the type or the quiz. The identity quiz packs the names of its identities and the presidential quiz packs the names of its candidates, with different keys; one reading takes both, and an orientation that mixes them is read the same way. + +Packing sits inside each translation, so an old orientation can have two forms in one language and one name in another. + +The reading follows what the API sends, where that differs from what its documentation declares: + +- The documentation declares `logoUrl` and `color` as text that is always present. Quizzes in use send both as missing and as empty text. +- `surveyId` is sent on every orientation and is not in the documentation. + +### The other side as a person +The API has no orientation for a person. The app builds one from the friend's result. + +| Property | Value | +|---|---| +| Identifier | The identifier of the friend's result | +| Type | Person | +| Name | The friend's display name, when comparison knows one | +| Image | The friend's avatar, when comparison knows one | +| Colour | The friend's profile colour, when comparison knows one | +| Everything else | Absent, not official, not hidden | + +A person has one name and one image, never two forms. + +## Interface +| Direction | Name | Shape | Notes | +|---|---|---|---| +| In | Quiz orientations | The list the API sends inside a quiz, in one language | Read once per quiz and language | +| In | Whether the quiz is official or a community one | One of the two | Decides whether an official mark counts | +| In | Whose result it is, and their declared gender | Female, male, other, or not declared | Chooses the form. Not declared covers a taker who declined and one who has not reached the question yet | +| In | A friend's result | An identifier, and a name, avatar and colour when known | Becomes the other side | +| Out | Orientations of the quiz | A list in the quiz's order, each findable by identifier | For anything that selects or links | +| Out | An orientation for display | The same orientation with exactly one name and one image | For anything that draws | + +## Behaviour + +### Choosing a form +A name and an image are chosen separately, by the same cases. + +| Case | Behaviour | +|---|---| +| One form only, or no forms at all | That name or image, for everyone | +| Two forms, the person declared female | The feminine form | +| Two forms, the person declared male | The masculine form | +| Two forms, the person declared other, declined, or was not asked yet | The masculine form | +| The declaration arrives or changes later | Every orientation on screen switches form. Nothing else about the result changes | +| The other side is shown | The form follows the taker whose result the screen belongs to, never the friend | + +The form follows the person the result belongs to. Nothing that draws an orientation ever sees two forms or chooses between them. + +### Finding an orientation +| Case | Behaviour | +|---|---| +| A result, an axis, an answer or a link names an identifier the quiz has | It resolves to that orientation | +| It names an identifier the quiz does not have | That reference is dropped. Everything else is unaffected | +| The quiz has an orientation no result names | The orientation stands, without a value | +| The result is not calculated yet | Every orientation stands, without a value | +| The other side is a person | Never looked up among the quiz's orientations, whatever its identifier | + +### Type +| Case | Behaviour | +|---|---| +| A screen needs all orientations of one sort - the parties for a ranking, every orientation for the comparison picker | It selects by type | +| An author's configuration points at orientations one by one | The configuration wins. The type is not checked against it | +| Anything draws an orientation | The type changes nothing. An ideology, a party and a person with the same name, image and colour draw the same | + +### Hidden +| Case | Behaviour | +|---|---| +| A screen selects orientations by type | Hidden ones are left out | +| A ranking is drawn, or a leader is chosen | Hidden ones take no part | +| The comparison picker lists orientations | Hidden ones are not offered | +| An author's configuration points at a hidden orientation | It is not drawn. Hidden wins | +| A result, an answer or a link names a hidden orientation | It resolves, and says it is hidden. Scoring is untouched | + +### Language +| Case | Behaviour | +|---|---| +| The quiz is asked for in a language it supports | Every text of every orientation is in that language | +| The app's language changes | The quiz is read again. Identifiers, types, colours and links do not change; texts, forms and images may | +| The quiz is asked for in a language it does not support | The API refuses the quiz, so there is nothing to read. What to show instead is the screen's decision | + +## Rules and constraints +- **One definition.** No module, screen or card describes an orientation of its own. A module that needs less than the whole takes what it needs from this one. +- **One reading.** The API's orientation becomes this one in a single place. Nothing past it sees an API field name or packed text. +- **Packed text is legacy.** It is read and never written. Reading it keeps old quiz versions and the results shared from them working; it is not a format to build on. +- **A new property needs a new field.** When the app needs something the API does not send, the back-end is asked for a field. A new key inside a text is never the answer, and the list of keys read here does not grow. +- **The other side is an orientation.** A comparison names it as an orientation, in the same words a module uses for any other. +- **Party is a type.** It covers parties and candidates. The word is not used for the other person in a comparison. +- **The quiz's order is kept.** Orientations stay in the order the API sent them. Modules lean on it for ties and for a stable drawing. +- **Text is plain text.** Names, slogans, descriptions and explanations are shown as written and never interpreted. Paragraph breaks in the two descriptions are kept. +- **Official is a moderator's claim.** myPolitics grants it, in the quizzes it writes itself. A community author cannot, and the app shows the mark without ever working it out. +- **Orientations are never merged.** The same party in two quizzes, or in two versions of one quiz, is two orientations. The app does not match by name. +- **Not supported: more than two forms.** A name or an image has a masculine and a feminine form and no third. + +## Invalid and edge input +| Input | Behaviour | +|---|---| +| The list of orientations is missing, empty or not a list | The quiz has no orientations | +| An orientation without an identifier | Dropped | +| Two orientations with the same identifier | The first is kept | +| Type missing, or one the app does not know | Other | +| The API sends the type person | Other. A person is only ever built by the app | +| Text with space or line breaks around it | Trimmed | +| Text that is missing, empty or only space | Absent | +| Text that is valid JSON but not an object - a name like "2050" | Plain text, as written | +| Packed text with raw line breaks inside its values | Read as if they were written correctly. One version of the identity quiz stores every description this way | +| Text that opens like packed text and still does not parse | Absent. Broken machine text is never shown to a taker | +| Packed text with keys this spec does not name | Those keys are ignored. A new key is a reason to ask for a field, not to extend the list | +| A property sent both in a field of its own and in packed text | The field | +| An official mark in a community quiz | Not official. The text it sits in is the author's, and the mark is not theirs to give | +| A packed value of the wrong sort - a number for a name, text for official | Absent, or no for a yes-or-no | +| Packed name with one form | That form, for everyone | +| Packed name with forms and a `name` | The forms. The `name` is ignored | +| Packed name with no form and no `name` | No name. The rest of it is still read | +| Packed description with both `short` and `shortDescription`, or both `long` and `longDescription` | The shorter key | +| Packed description with only a full description | A full description and no description | +| Image or website that is not a web address | Absent | +| Colour missing, or not a colour | Absent | +| Colour that is white | Absent. Quizzes in use store white for "not set", and a white fill cannot be seen on the track | +| Linked orientations missing or not a list | Empty | +| A link to an identifier the quiz does not have, or to itself | Dropped | +| The same link twice | Kept once | +| Every orientation of a type is hidden | A selection by that type is empty. Each module has its own empty state | +| A person without a name or an avatar | A person with those absent. Each module has its own placeholder | + +An orientation with no name, no image and no colour is still an orientation. What to draw for it is each module's rule: the chart modules fall back to a neutral colour, and the traits module leaves a nameless trait out. + +Nothing here throws, and one bad orientation never costs the quiz its others. + +## Privacy and data handling +- **The declared gender is used to choose a form and for nothing else here.** It is read where the result is drawn and is not stored with an orientation. +- **A gendered name discloses the declaration.** A result shared as an image carries the form, so whoever sees "Zielona postępowczyni" learns what its owner declared. +- **A person is personal data.** The friend's name and avatar are held for the comparison on screen. They are never stored with the quiz's orientations or sent along with them. +- **A website is someone else's page.** The app never loads it. It is only ever a link the taker chooses to follow. + +## Non-functional +- **Read once.** A quiz is read into orientations once per language, not on every draw. The identity quiz has 58 orientations; a few hundred must stay unnoticeable. +- **The same everywhere.** The app, the checkpoint cards and the generated result image use the same definition and choose the same form for the same person. + +## Dependencies +**Build order: step 0.** Needs nothing in this folder. + +Relies on: + +- [Result modules doc](../../docs/modules/quiz/results/modules/README.md) - the rule this implements. +- The [survey API](https://api.mypolitics.pl/api) - the orientation it sends inside a quiz, and the identifiers a result and an answer carry. The reading was checked against every public quiz it serves. This spec asks it for the fields listed under "Properties without a field"; until they exist nothing here is blocked, since the legacy reading covers every quiz in use. + +Relied on by every spec in this folder that takes an orientation. Where their wording differs, this spec decides: + +- [Universal axis](./universal-axis.md) and every chart module - "the other party" is the other side, and the orientation they describe is this one. +- [Header](./header.md) - the slogan it shows is the orientation's own, and so is the address of its link. +- [Horizontal bar chart](./horizontal-bar-chart.md) - an "official" badge is drawn from the orientation's own mark, and a hidden orientation is not ranked. +- [Archetype](./archetype.md) - its short and full description are the orientation's description and full description. +- [Traits](./traits.md) - a trait is an orientation used as a trait, not a type. diff --git a/mypolitics/tasks/epics/modules/cycle-4/universal-orientation.md b/mypolitics/tasks/epics/modules/cycle-4/universal-orientation.md new file mode 100644 index 0000000..aecb8ae --- /dev/null +++ b/mypolitics/tasks/epics/modules/cycle-4/universal-orientation.md @@ -0,0 +1,407 @@ +# Story + +As a user reading a quiz result, I want every party, candidate, identity, ideology and friend to arrive in the app as the same thing - an orientation - whatever quiz it comes from, so that every module can draw every quiz and nothing on my result depends on which quiz I took. + +# Module properties + +**Module:** the universal orientation - its types, the Zod schema of what the API sends, and the functions that read one into the other +**Location:** `src/types/orientation.ts`, `src/services/api/schemas/`, `src/services/api/utils/orientation/`, `src/utils/orientation/` +**Shared:** yes - every domain that names, scores or compares something uses it + +There is **no component in this task**. It is pure TypeScript: nothing renders, nothing fetches, nothing holds state. + +```ts +// src/types/orientation.ts +export type OrientationType = + | "ideology" + | "party" + | "identity" + | "compass" + | "person" // built by the app, never sent by a quiz + | "other"; // a type the app does not know + +export interface OrientationForms { + masculine?: string; + feminine?: string; +} + +interface OrientationBase { + id: string; + type: OrientationType; + color?: string; + description?: string; // the text shown by default + fullDescription?: string; // the longer text, opened on request + slogan?: string; + websiteUrl?: string; + isOfficial?: boolean; // absent = no. A moderator's mark, never the author's + isHidden?: boolean; // absent = no + explanation?: string; + linkedOrientationIds?: string[]; +} + +// As read from a quiz: a name and an image may still hold two forms. +export interface QuizOrientation extends OrientationBase { + name?: string; + nameForms?: OrientationForms; + imageUrl?: string; + imageUrlForms?: OrientationForms; +} + +// What anything that draws receives: exactly one name and one image. +export interface Orientation extends OrientationBase { + name?: string; + imageUrl?: string; +} + +export type DeclaredGender = "female" | "male" | "other" | "prefer_not_to_share"; +``` + +```ts +// src/services/api/schemas/orientation.ts +// Lenient on purpose: only `id` is required. Every other field is optional and nullable, +// because the API documents `logoUrl` and `color` as always present and sends both as null and "". +export const orientationResponseSchema = z.object({ ... }); +export type OrientationResponse = z.infer; + +// src/services/api/utils/orientation/ +export const readQuizOrientations = ( + response: unknown, // the `orientations` of a quiz, as sent + options: { isOfficialQuiz: boolean }, +): QuizOrientation[] => ... + +export const parsePackedText = (text?: string | null): PackedText => ... +// PackedText = { kind: "absent" } | { kind: "plain"; text: string } | { kind: "packed"; value: Record } + +// src/utils/orientation/ +export const toDisplayOrientation = (orientation: QuizOrientation, gender?: DeclaredGender): Orientation => ... +export const createPersonOrientation = (friend: PersonInput): Orientation => ... +// PersonInput = { resultId: string; name?: string; imageUrl?: string; color?: string } +export const findOrientation = (orientations: T[], id: string): T | undefined => ... +export const selectOrientations = (orientations: T[], type?: OrientationType): T[] => ... +``` + +`readQuizOrientations` is the **only** place in the app that knows an API field name or packed text. Nothing past it reads `generalName`, `logoUrl` or a JSON string. + +# Behaviour + +The [spec](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/universal-orientation.md) is the source of truth for every case below. The [survey API docs](https://api.mypolitics.pl/api) describe the `GetOrientationResponse` it reads. + +### The valid reading + +One field, one property, plain words in it. + +| Property | From | Reading | +|---|---|---| +| `id` | `id` | As sent | +| `type` | `type` | `IDEOLOGY`, `PARTY`, `IDENTITY`, `COMPASS` to their lower-case type; anything else, or nothing, is `other` | +| `name` | `generalName` | The text | +| `imageUrl` | `logoUrl` | The address | +| `color` | `color` | As sent | +| `description` | `description` | The text | +| `explanation` | `explanation` | As sent | +| `linkedOrientationIds` | `linkedOrientations` | As sent | +| - | `surveyId` | Not carried | + +### Packed text - legacy, read for old quiz versions + +Old quiz versions put a JSON object inside `generalName`, `logoUrl` and `description`. **This is not a valid format.** Nothing in the app produces it, and the list of keys below is closed - if the app needs a property the API does not send, the back-end is asked for a field. Do not add a key. + +| Field | Key | Read as | +|---|---|---| +| `generalName` | `name` | `name` | +| `generalName` | `m`, `f` | `nameForms.masculine`, `nameForms.feminine` | +| `generalName` | `slogan` | `slogan` | +| `generalName` | `websiteUrl` | `websiteUrl` | +| `generalName` | `isOfficial` | `isOfficial` | +| `generalName` | `isHidden` | `isHidden` | +| `logoUrl` | `m`, `f` | `imageUrlForms.masculine`, `imageUrlForms.feminine` | +| `description` | `short` or `shortDescription` | `description` | +| `description` | `long` or `longDescription` | `fullDescription` | + +- Packed text is recognised by its **content**, never by the orientation's type or by the quiz. The identity quiz packs `m` / `f` / `slogan`, the presidential quiz packs `name` / `slogan` / `websiteUrl` / `isOfficial` / `isHidden`; one reader takes both. +- Keep the reading of each property in one place, so that when the back-end adds a field for it, the field is read first and packed text stays only as the fallback for a quiz version with no value in the field. + +### Choosing a form - `toDisplayOrientation` + +A name and an image are chosen separately, by the same cases. + +| Case | Result | +|---|---| +| No forms | `name` / `imageUrl` as they are | +| One form only | That form, for everyone | +| Two forms, gender `female` | The feminine form | +| Two forms, gender `male` | The masculine form | +| Two forms, gender `other`, `prefer_not_to_share` or not passed | The masculine form | + +The gender passed is the one declared by the person **the result belongs to**, never the friend's. The function is pure, so a declaration that arrives later is simply a second call. + +### The other side as a person - `createPersonOrientation` + +| Property | Value | +|---|---| +| `id` | The friend's result identifier | +| `type` | `person` | +| `name`, `imageUrl`, `color` | What was passed, cleaned by the same rules as a quiz orientation | +| Everything else | Absent | + +### Finding and selecting + +| Case | Behaviour | +|---|---| +| `findOrientation` with an identifier the quiz has | That orientation - a hidden one included, with `isHidden` set | +| `findOrientation` with an identifier the quiz does not have | `undefined`. The caller drops the reference | +| `selectOrientations(list)` | Every orientation that is not hidden, in the quiz's order | +| `selectOrientations(list, type)` | The same, of that type only | +| Every orientation of the type is hidden | An empty list | + +### Invalid and edge input + +Nothing here throws, and one bad orientation never costs the quiz its others - read the list item by item, never with one all-or-nothing parse. + +| Input | Behaviour | +|---|---| +| The list is missing, empty or not a list | `[]` | +| An item without an `id` | Dropped | +| Two items with the same `id` | The first is kept | +| `type` missing or unknown | `other` | +| The API sends the type `person` | `other` | +| The order of the list | Kept | +| Text with space or line breaks around it | Trimmed | +| Text that is missing, empty or only space | Absent | +| Text that is valid JSON but not an object - a name like `"2050"` | Plain text, as written | +| Packed text with raw line breaks inside its values | Read as if they were escaped. Live data has it: every identity description of quiz `2b5c1d4d-5e3b-4fae-b86d-9be400fc6de8` | +| Text that opens like packed text and still does not parse | Absent. Broken machine text is never shown | +| Packed text with keys not in the table | Ignored | +| A packed value of the wrong sort - a number for a name, text for `isOfficial` | Absent, or `false` | +| Packed name with one form | That form only | +| Packed name with forms and a `name` | The forms. `name` is ignored | +| Packed name with no form and no `name` | No name. `slogan`, `websiteUrl` and the marks are still read | +| Both `short` and `shortDescription`, or both `long` and `longDescription` | The shorter key | +| Packed description with only a long one | `fullDescription` and no `description` | +| `isOfficial` when `isOfficialQuiz` is `false` | `false`. The text is the author's, and the mark is not theirs to give | +| Image or website that is not an `http` / `https` address | Absent | +| Colour missing, or not a colour | Absent | +| Colour that is white (`#FFF`, `#FFFFFF`, any case) | Absent. Quizzes in use store white for "not set" | +| `linkedOrientations` missing or not a list | `[]` | +| A link to an identifier the quiz does not have, or to itself | Dropped | +| The same link twice | Kept once | + +# Out of scope + +- **Fetching a quiz.** There is no API client in the app yet. This task reads a list it is handed; the Axios client, the survey schema and the `lang` parameter belong to the quiz loading task. A quiz fetched again in another language is simply read again. +- **Changing any component.** The result modules keep their current types here and move to `Orientation` in the follow-up task, `UniversalOrientation: one type and one term in the result modules`. +- **Scoring** - turning a result's `points` and `maxPossible` into a value. +- **Axes**, their schema and their packed names. +- **Not drawing a hidden orientation an author pointed a module at** - the result screen, which does not exist yet. +- **New API fields.** Asking the back-end for them is not this task. The reading is only built so that a field is a small addition. + +# Files to create + +``` +src/types/orientation.ts +src/services/api/schemas/orientation.ts +src/services/api/schemas/orientation.test.ts +src/services/api/utils/orientation/ +├── readQuizOrientations.ts # the list: item by item, duplicates, links +├── readQuizOrientations.test.ts +├── toQuizOrientation.ts # one item: valid reading, then the legacy fallback +├── toQuizOrientation.test.ts +├── parsePackedText.ts # plain / packed / absent, tolerant of raw line breaks +└── parsePackedText.test.ts +src/utils/orientation/ +├── toDisplayOrientation.ts +├── toDisplayOrientation.test.ts +├── createPersonOrientation.ts +├── createPersonOrientation.test.ts +├── findOrientation.ts +├── findOrientation.test.ts +├── selectOrientations.ts +└── selectOrientations.test.ts +src/utils/url/ +├── toWebAddress.ts # an http/https address, or undefined +└── toWebAddress.test.ts +``` + +Reuse `getSafeColor` from `src/utils/color/` for the colour check and add the white rule on top of it in the reading - do not change what `getSafeColor` returns for the components that already use it. Split further only if a file gets hard to read; no empty files. + +# Unit test cases (BDD) + +```ts +describe('parsePackedText()', () => { + describe('given plain words', () => { + it('returns them as plain text, trimmed', ...); + }); + describe('given missing, empty or blank text', () => { + it('returns absent', ...); + }); + describe('given a JSON object', () => { + it('returns it as packed', ...); + }); + describe('given valid JSON that is not an object', () => { + it('returns "2050", "true" and "null" as plain text', ...); + }); + describe('given packed text with raw line breaks inside its values', () => { + it('reads it and keeps the line breaks in the value', ...); + it('still reads pretty-printed packed text with line breaks between its keys', ...); + }); + describe('given text that opens like packed text and does not parse', () => { + it('returns absent', ...); + }); +}); + +describe('toQuizOrientation()', () => { + describe('given plain fields', () => { + it('maps id, generalName, logoUrl, color, description and explanation', ...); + it('does not carry surveyId', ...); + }); + describe('type', () => { + it('maps the four API types to their lower-case type', ...); + it('maps a missing or unknown type to other', ...); + it('maps a sent "person" to other', ...); + }); + describe('given a packed name with forms', () => { + it('reads m and f as the two forms and slogan as the slogan', ...); + it('keeps a single form as the only form', ...); + it('ignores name when a form is present', ...); + }); + describe('given a packed name without forms', () => { + it('reads name, slogan, websiteUrl, isOfficial and isHidden', ...); + it('has no name when neither a form nor name is present, and still reads the rest', ...); + it('treats an empty slogan as absent', ...); + }); + describe('given a packed image', () => { + it('reads m and f as the two image forms', ...); + it('trims a form that ends with a line break', ...); + }); + describe('given a packed description', () => { + it('reads short and long', ...); + it('reads shortDescription and longDescription', ...); + it('prefers the shorter key when both spellings are present', ...); + it('has a full description and no description when only long is present', ...); + }); + describe('given packed text on any type', () => { + it('reads a packed name on an identity, a party and an ideology alike', ...); + it('ignores keys it does not know', ...); + it('treats a value of the wrong sort as absent, and as false for a mark', ...); + }); + describe('official', () => { + it('keeps the mark in an official quiz', ...); + it('drops the mark in a community quiz', ...); + }); + describe('image and website', () => { + it('drops an address that is not http or https', ...); + it('treats null and "" as absent', ...); + }); + describe('colour', () => { + it('keeps a valid colour', ...); + it('drops a missing or invalid colour', ...); + it('drops white in any spelling', ...); + }); +}); + +describe('readQuizOrientations()', () => { + describe('given something that is not a list', () => { + it('returns an empty list', ...); + }); + describe('given a list', () => { + it('keeps the order it was sent in', ...); + it('drops an item without an id and keeps the others', ...); + it('keeps the first of two items with the same id', ...); + it('never throws on a malformed item', ...); + }); + describe('linked orientations', () => { + it('keeps a link to an orientation of the same quiz', ...); + it('drops a link to an unknown id and a link to itself', ...); + it('keeps a repeated link once', ...); + it('returns an empty list when the field is missing or not a list', ...); + }); + describe('given the live shapes', () => { + it('reads an identity of the identity quiz', ...); + it('reads a candidate of the presidential quiz', ...); + it('reads an identity of the presidential quiz', ...); + }); +}); + +describe('toDisplayOrientation()', () => { + describe('given no forms', () => { + it('returns the name and the image as they are', ...); + }); + describe('given one form', () => { + it('uses it for every gender', ...); + }); + describe('given two forms', () => { + it('uses the feminine form for female', ...); + it('uses the masculine form for male', ...); + it('uses the masculine form for other, prefer_not_to_share and undefined', ...); + it('chooses the name and the image independently', ...); + }); + it('carries every other property unchanged and leaves no forms on the result', ...); +}); + +describe('createPersonOrientation()', () => { + it('uses the result identifier as the id and person as the type', ...); + it('cleans the name, the image and the colour by the same rules', ...); + it('leaves everything else absent', ...); +}); + +describe('findOrientation()', () => { + it('returns the orientation with the id', ...); + it('returns a hidden orientation, marked hidden', ...); + it('returns undefined for an unknown id', ...); +}); + +describe('selectOrientations()', () => { + it('leaves hidden orientations out', ...); + it('returns only the given type when one is passed', ...); + it('keeps the order of the quiz', ...); + it('returns an empty list when every orientation of the type is hidden', ...); +}); +``` + +Build the "live shapes" fixtures from real responses of `GET https://api.mypolitics.pl/api/v1/survey/{id}`: the identity quiz `60beb898-a4e4-4160-88c4-07a9931ab499` and the presidential quiz `270f6c12-6551-4661-bfcf-52635a703928`. Keep them small - two or three orientations each, in a fixture file next to the test. + +# Remember about standards + +- Create unit tests with Vitest for 100% of the code created if feasible (check our [testing convention](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/TESTING_CONVENTION.md)) +- Validate what the API sends with Zod and derive the response type from the schema - but leniently and per item, as described above +- No new dependency. `JSON.parse` and Zod are enough +- No Storybook story - there is no component +- No user-visible strings are added, so nothing goes through Lingui +- Name the branch `feature/universal-orientation-85`, following [Conventional Branch](https://conventional-branch.github.io/) - never keep a generated or default branch name +- The PR follows the repository's pull request template: Changes, How to test, Checklist + +# Dependencies + +Nothing blocks this task. + +It blocks `UniversalOrientation: one type and one term in the result modules` (#86), which moves every module to the `Orientation` type created here. + +# Resources + +- [Spec - Universal orientation](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/universal-orientation.md) - every case, in full +- [Docs - Result modules](https://github.com/gi-org-pl/product/blob/main/mypolitics/docs/modules/quiz/results/modules/README.md) - why every entity with points is an orientation +- [Survey API docs](https://api.mypolitics.pl/api) - `GetOrientationResponse` and `GET /api/v1/survey/{id}` +- Legacy readers this replaces, one per quiz: [candidates of the presidential quiz](https://github.com/gi-org-pl/mypolitics-app-legacy/blob/develop/frontend/src/components/Results/ModernResults/Quizzes/Prezydencki2025/utils/candidates/mapOrientationToCandidate.ts), [identities of the presidential quiz](https://github.com/gi-org-pl/mypolitics-app-legacy/blob/develop/frontend/src/components/Results/ModernResults/Quizzes/Prezydencki2025/utils/identities/mapOrientationToIdentity.ts), [identities of the identity quiz](https://github.com/gi-org-pl/mypolitics-app-legacy/blob/develop/frontend/src/components/Results/ModernResults/Quizzes/IdentityQuizResults/utils/identities/mapOrientationToIdentity.ts) +- [Front-end standards](https://github.com/Generacja-Innowacja/gi-tech-standards/tree/main/docs/frontend) +- [Vitest docs](https://vitest.dev/guide/) +- [Zod docs](https://zod.dev/) + +# Definition of Done + +- [ ] Code follows folder structure (`docs/frontend/conventions/PROJECT_STRUCTURE.md`) +- [ ] Naming follows `docs/frontend/conventions/NAMING.md` +- [ ] `Orientation`, `QuizOrientation`, `OrientationType` and `DeclaredGender` live in `src/types/orientation.ts` +- [ ] `readQuizOrientations` is the only code that names an API field or parses packed text +- [ ] The valid reading and the legacy packed reading both follow the tables above; no key outside the table is read +- [ ] Packed text with raw line breaks is read; anything still broken is absent +- [ ] An official mark is kept only when `isOfficialQuiz` is true +- [ ] White, invalid and missing colours are absent; images and websites that are not web addresses are absent +- [ ] Links to unknown ids, to itself and repeated links are cleaned +- [ ] A malformed item is dropped without throwing and without losing the rest of the list +- [ ] `toDisplayOrientation` chooses the form by the declared gender and returns exactly one name and one image +- [ ] `createPersonOrientation`, `findOrientation` and `selectOrientations` behave as in the tables; `selectOrientations` never returns a hidden orientation +- [ ] The live-shape fixtures of the identity quiz and the presidential quiz are read correctly +- [ ] Unit tests added, BDD style, coverage ≥95% on all new files +- [ ] Biome lint clean +- [ ] TypeScript clean (no `any`, no `@ts-ignore`) +- [ ] CI green: build, lint, test diff --git a/mypolitics/tasks/epics/modules/cycle-5/universal-orientation-adoption.md b/mypolitics/tasks/epics/modules/cycle-5/universal-orientation-adoption.md new file mode 100644 index 0000000..06ce7b7 --- /dev/null +++ b/mypolitics/tasks/epics/modules/cycle-5/universal-orientation-adoption.md @@ -0,0 +1,249 @@ +# Story + +As a user reading a quiz result, I want every module to treat a party, a candidate, an identity and the friend I compare with as the same thing - an orientation - so that any module can draw any quiz, and a quiz about candidates looks and behaves exactly like a quiz about ideologies. + +# Component properties + +**Components:** every result module and what they are built from - `UniversalAxis`, `SingleAxisChart`, `DoubleAxisChart`, `MultiAxisChart`, `AxisRow`, `HorizontalBarChart`, `RankedRow`, `Archetype`, `Traits`, `NolanChart`, `ResultsHeader`, `OrientationChip` +**Location:** `src/components/results/`, `src/components/shared/UniversalAxis/`, `src/types/`, `src/utils/` +**Shared:** as today - nothing moves between domains + +This task adds **no new behaviour to draw and no new component**. It makes the result domain use one type and one word: + +- **One type.** `Orientation` from `src/types/orientation.ts` (created by the `UniversalOrientation` task) replaces `AxisOrientation` and every locally declared orientation shape. +- **One word.** Whatever is named, scored or compared is an `orientation`. The words `party` and `candidate` are no longer used for a variable, a prop, a type, a component, a file, a test title or a story. + +```ts +// src/types/axis.ts - AxisOrientation is removed +export interface AxisEntry { + orientation: Orientation; // from src/types/orientation.ts + value?: number; +} + +// src/types/results.ts +export interface ResultEntry { + orientation: Orientation; + value?: number; +} + +export interface RankedComparison { + orientation: Orientation; // the other side. Was `party` + values: Record; // their value, by orientation id +} +``` + +`Orientation.name` is **optional** where `AxisOrientation.name` was a required string. Every module already has a rule for a missing name in its spec; keep that rule working when the name is `undefined` and not only when it is `""`. + +# Behaviour + +The [spec](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/universal-orientation.md) is the source of truth. Each module's own spec still decides how that module draws; this task changes what the modules are handed, not what they draw - except in the three places listed under "Properties that move onto the orientation" and "Hidden". + +### One type + +| Where | Now | Becomes | +|---|---|---| +| `src/types/axis.ts` | `AxisOrientation` | Removed. `Orientation` everywhere it was used | +| `ResultsHeader.types.ts` | `ResultsHeaderOrientation { name, imageUrl }` | Removed. `orientation?: Orientation` | +| `OrientationChip.types.ts` | `name`, `imageUrl`, `color` declared by hand | Taken from `Orientation` with `Pick` | +| `Traits.types.ts` | `TraitItem { id, name, imageUrl, color, holder }` | `{ orientation: Orientation; holder: TraitHolder }` | +| `Archetype.types.ts`, `RankedRow.types.ts`, `SingleAxisChart.types.ts`, `NolanChart.types.ts`, `MultiAxisChart.types.ts` | `AxisOrientation` | `Orientation` | + +A module that needs less than the whole orientation takes what it needs from `Orientation` with `Pick`. No file declares an orientation-shaped interface of its own. The computed layout types in `src/types/axis.ts` (`AxisSideLayout`, `AxisComparisonLayout`) are results of a calculation, not definitions of an orientation, and stay as they are. + +### One word + +| Where | Now | Becomes | +|---|---|---| +| `RankedComparison`, `AxisComparison`, `NolanComparison`, `TraitsComparison` | `party` | `orientation` | +| `NolanMap`, `NolanRows`, `NolanRow` props | `otherParty` | `otherOrientation` | +| `NolanMap/PartyMarker/` - folder, component, props type, tests | `PartyMarker`, prop `party` | `OrientationMarker`, prop `orientation` | +| `TraitPill` prop | `party` | `otherOrientation` | +| `getPillHolder`, `getRowComparison` parameter | `party` | `otherOrientation` | +| `useTraitDescription` parameter | `partyName` | `otherName` | +| `getComparisonEntry`, `getAxisComparison` | read `comparison.party` | read `comparison.orientation` | +| `HorizontalBarChart.stories.tsx` | `createCandidate`, `candidates`, `candidateCategories`; stories `Candidates`, `CandidatesComparison`, `CandidatesOpen`, `CandidatesGrouped`, `CandidatesCategoryOpen` | A shared `createOrientation` test helper, `orientations`, `orientationCategories`; stories `Ranking`, `RankingComparison`, `RankingOpen`, `RankingGrouped`, `RankingCategoryOpen` | +| Test titles and comments | "the other party", "party", "candidate" | "the other side", "orientation" | + +The naming rule after this task: + +- **In a comparison, the other side is `orientation` on the comparison object and `otherOrientation` where it is passed next to the taker's own orientation.** +- **`party` survives in exactly one place:** as a member of `OrientationType`, because it is a type the API sends. +- **Fixture content is not naming.** A story may still show "Kandydaci" as a title or "Rafał Trzaskowski" as a name - that is what a quiz author writes. The variable holding it is an orientation. + +The check: `git grep -n -i -w -E "[a-z]*(party|candidate)[a-z]*" -- src` returns only the `"party"` member of `OrientationType`, its tests, and fixture strings. + +### Properties that move onto the orientation + +The universal orientation carries properties that modules were given separately. They are now read from the orientation. + +| Module | Now | Becomes | +|---|---|---| +| `ResultsHeader` | `slogan?: string` prop | Read from `orientation.slogan`. The prop is removed | +| `ResultsHeader` | `link?: { url, label }` prop | The address is `orientation.websiteUrl`. The prop becomes `linkLabel?: string` | +| `Archetype` | `ArchetypeEntry.shortDescription`, `ArchetypeEntry.fullDescription` | Read from `orientation.description` and `orientation.fullDescription`. Both fields are removed from `ArchetypeEntry` | + +Every existing case of the two modules keeps its behaviour: no match still draws neither slogan nor link, a link with an empty label still shows the address, an address that is not a web address is still not drawn, a full description identical to the short one still has no control. + +### Hidden + +| Case | Behaviour | +|---|---| +| `HorizontalBarChart` ranks its entries, flat or inside a category | An entry whose orientation `isHidden` takes no part: it is not ranked, not counted against the fold, and never leads a category | +| `Archetype` picks its leader and builds its ranking | An archetype whose orientation `isHidden` takes no part in either | +| Every entry is hidden | The module draws the empty state it already has for no entries | +| A comparison value for a hidden orientation | Ignored, like a value for an orientation not in the list | + +One shared helper decides it, so the two modules cannot drift. + +# Athena components to use + +None new. Every Athena component a module already uses stays. + +# Out of scope + +- **Reading the API** - done by the `UniversalOrientation` task. No module parses anything here. +- **An "official" badge.** `RankedRow` keeps drawing the badge it is given. Building a badge from `orientation.isOfficial` is the result screen's job, and the result screen does not exist yet. +- **Not drawing a hidden orientation an author pointed a single module at** - the result screen. +- **Choosing a form.** Modules receive an `Orientation` with one name and one image already chosen. +- **Any change to how a module looks.** If a story looks different after this task, something went wrong. +- **The product repository's task files**, which still say `party` - they are history. + +# Files to change + +``` +src/types/axis.ts # AxisOrientation removed +src/types/results.ts # RankedComparison.party -> orientation +src/utils/results/getComparisonEntry.ts +src/utils/results/hasOrientationTitle.ts # name may be undefined +src/utils/results/isOrientationShown.ts # new: false for a hidden orientation +src/utils/results/isOrientationShown.test.ts # new +src/utils/vitest/createOrientation.ts # new: one fixture builder for stories and tests +src/utils/vitest/createAxisPair.ts # builds on createOrientation +src/components/shared/UniversalAxis/ # types only +src/components/results/OrientationChip/ +src/components/results/RankedRow/ +src/components/results/SingleAxisChart/ +src/components/results/DoubleAxisChart/ +src/components/results/AxisRow/ +src/components/results/MultiAxisChart/ # AxisComparison.party, getAxisComparison +src/components/results/HorizontalBarChart/ # sortRankedEntries skips hidden; stories renamed +src/components/results/Archetype/ # descriptions from the orientation; ranking skips hidden +src/components/results/Traits/ # TraitsComparison.party, TraitPill, TraitItem +src/components/results/NolanChart/ # NolanComparison.party, otherParty, PartyMarker -> OrientationMarker +src/components/results/ResultsHeader/ # orientation, slogan, linkLabel +``` + +Rename `NolanMap/PartyMarker/` with `git mv`, so the history of the file follows it. + +# Unit test cases (BDD) + +Every existing test keeps its assertion and changes only its fixtures and its wording. On top of them: + +```ts +describe('isOrientationShown()', () => { + it('returns true for an orientation that is not hidden', ...); + it('returns true when isHidden is absent', ...); + it('returns false for a hidden orientation', ...); +}); + +describe('sortRankedEntries()', () => { + describe('given a hidden orientation', () => { + it('leaves it out of the ranking', ...); + it('keeps the order of the others', ...); + }); +}); + +describe('', () => { + describe('given a hidden orientation in a flat list', () => { + it('does not draw its row and does not count it against the fold', ...); + }); + describe('given a category whose best entry is hidden', () => { + it('leads the category with the best entry that is shown', ...); + }); + describe('given only hidden orientations', () => { + it('draws the empty state', ...); + }); + describe('given a comparison value for a hidden orientation', () => { + it('ignores it', ...); + }); +}); + +describe('', () => { + describe('given the orientation has a description and a full description', () => { + it('shows the description in the summary', ...); + it('opens the full description from the description control', ...); + }); + describe('given a hidden archetype with the highest match', () => { + it('leads with the best archetype that is shown', ...); + it('leaves the hidden one out of the ranking', ...); + }); +}); + +describe('', () => { + describe('given the orientation has a slogan', () => { + it('draws the slogan chip', ...); + }); + describe('given the orientation has a website', () => { + it('draws the link with linkLabel as its label', ...); + it('shows the address when linkLabel is empty', ...); + it('does not draw the link when the website is not a web address', ...); + }); + describe('given no match', () => { + it('draws neither the slogan nor the link, whatever the orientation carries', ...); + }); +}); + +describe('given an orientation without a name', () => { + it(' shows the image alone', ...); + it(' draws the bar under an empty name', ...); + it(' does not draw the trait', ...); + it(' keeps the label row reserved', ...); +}); +``` + +# Storybook stories + +No new story. Every existing story keeps its content and its picture; the five `Candidates*` stories of `HorizontalBarChart` are renamed as in the table above. Check each module's stories side by side with `main` - they must look the same. + +# Remember about standards + +- Use the standard colors palette, never add colors directly (check https://tailwindcss.com/docs/colors and our color palette in the `src/index.css` file and in [athena](https://github.com/gi-org-pl/athena/blob/main/src/index.css)) +- Create unit tests with Vitest for 100% of the code created if feasible (check our [testing convention](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/TESTING_CONVENTION.md)) +- Keep every Storybook story working, with all props variants of each component still covered +- Comply with [the component structure](https://github.com/Generacja-Innowacja/gi-tech-standards/blob/main/docs/frontend/conventions/COMPONENT_STRUCTURE.md) +- No user-visible wording changes. If a Lingui message id changes, run `yarn i18n:extract` and commit the `.po` files +- Name the branch `feature/universal-orientation-adoption-86`, following [Conventional Branch](https://conventional-branch.github.io/) - never keep a generated or default branch name +- The PR follows the repository's pull request template: Changes, How to test, Visual Preview (one story per module next to the same story on `main`), Checklist +- Keep the rename and the three behaviour changes in separate commits, so the mechanical part can be reviewed quickly + +# Dependencies + +- **Blocked by `UniversalOrientation`** (#85) - it creates `src/types/orientation.ts`, which every change here imports. + +# Resources + +- [Spec - Universal orientation](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/universal-orientation.md) - the definition, the words, and the hidden cases +- [Spec - Header](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/header.md), [Archetype](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/archetype.md), [Horizontal bar chart](https://github.com/gi-org-pl/product/blob/main/mypolitics/spec/quiz/horizontal-bar-chart.md) - the modules whose inputs change +- [Docs - Result modules](https://github.com/gi-org-pl/product/blob/main/mypolitics/docs/modules/quiz/results/modules/README.md) - why every entity with points is an orientation +- [Front-end standards](https://github.com/Generacja-Innowacja/gi-tech-standards/tree/main/docs/frontend) +- [Storybook docs](https://storybook.js.org/docs/writing-stories) +- [Vitest docs](https://vitest.dev/guide/) + +# Definition of Done + +- [ ] Code follows folder structure (`docs/frontend/conventions/PROJECT_STRUCTURE.md`) +- [ ] Naming follows `docs/frontend/conventions/NAMING.md` +- [ ] Component layout follows `docs/frontend/conventions/COMPONENT_STRUCTURE.md` +- [ ] `AxisOrientation` and `ResultsHeaderOrientation` no longer exist; no file declares an orientation shape of its own +- [ ] Every comparison object carries the other side as `orientation`; props passing it are `otherOrientation` +- [ ] `PartyMarker` is `OrientationMarker`, renamed with its history +- [ ] `git grep -n -i -w -E "[a-z]*(party|candidate)[a-z]*" -- src` returns only the `OrientationType` member, its tests and fixture strings +- [ ] `ResultsHeader` reads the slogan and the link address from the orientation; `Archetype` reads both descriptions from the orientation +- [ ] `HorizontalBarChart` and `Archetype` leave hidden orientations out of ranking and leading, through one shared helper +- [ ] Every module handles an orientation whose name is `undefined` as its spec says +- [ ] Every story looks the same as on `main`; the five renamed stories are the only story changes +- [ ] Unit tests updated and added, BDD style, coverage ≥95% on changed files +- [ ] Biome lint clean +- [ ] TypeScript clean (no `any`, no `@ts-ignore`) +- [ ] No user-visible string changed; `.po` files committed if any message id moved +- [ ] CI green: build, lint, test