Our assessment centre exercises used to end like this: you finish, the component flips to its completed phase, it scores what you did, and it renders a results screen from the exercise object it is already holding in memory. Close the tab and that screen is gone forever. The attempt was in the database the whole time. There was simply nothing that could render it again.
So we added a URL for it, /dashboard/exercises/report/<attemptId>, and that one small feature turned out to be a data modelling problem rather than a page.
The results screen had the wrong props
The old component took the things that happened to be lying around in the browser:
interface ExerciseResultsProps {
exercise: Exercise;
score: ExerciseScore;
category: ExerciseCategory;
hasWritten: boolean;
aiFeedback: WrittenExerciseFeedback | null;
onRetry: () => void;
}
Three of those props are unusable on a server-rendered page. Exercise is the whole scenario: the brief, the reference documents, every option on every item and the expert key that explains why the strongest option is the strongest one. onRetry is a closure that resets a reducer which does not exist outside the runner. And the server has a stored row, not a live scorer output.
The honest reading is that the component never had props. It had access to a scope.
One shape, built in two places
What it has now is a single DTO built by a pure function, buildExerciseReport, which collapses the exercise and the attempt down to only the parts a result is allowed to show:
export interface ExerciseReport {
title: string;
completedAt: string;
hasMcq: boolean;
rawScore: number;
category: ExerciseCategory;
answeredCount: number;
totalMcqItems: number;
perCompetency: Array<{ label: string; value: number }>;
decisions: ExerciseDecisionResult[];
written: ExerciseWrittenResult[];
writtenScore: number | null;
}
The module that defines it imports types and nothing else, which is the part that does the work. It means a client component can import ExerciseReport without dragging the content library in behind it.
The browser calls buildExerciseReport immediately after an attempt, from the exercise it already has. The server page calls the same function from the stored row, and only the return value crosses to the browser. On a saved report the scenario itself never leaves the server, so the key for the options you did not pick is not in the payload even though the page can still explain the one option it is telling you about.
Two call sites, one function, and a shape small enough to read in a diff. Then a third consumer appeared for free: the PDF generator now takes { report } instead of an exercise and a score, so the download and the screen cannot disagree. They are the same object.
The content moves under the attempt
A saved attempt is a row that outlives the content it refers to. We edit scenarios. An item can be reworded or removed between the attempt and the day somebody reopens it, and a report that renders half an item is worse than one that renders none, so the builder drops anything it cannot fully resolve:
const decisions = score.itemResults.flatMap((res): ExerciseDecisionResult[] => {
const item = exercise.items.find((i) => i.id === res.itemId);
if (!item || item.type !== 'mcq') return [];
// ...
});
The numbers themselves are read back from what the scorer wrote at the time rather than recomputed today, for the same reason. A report should show you the attempt you made, not the attempt you would have made against this week's content.
The one thing that is not frozen is wording. The saved row carries ids, so a report rendered for an account that has asked for American spelling gets American spelling, even though the attempt was made before the preference existed.
Two retries, which is not the same prop
The runner can restart an exercise in place by resetting its reducer. A server-rendered report has no reducer to reset, so its retry is a link. Rather than fake a callback, the component takes both and renders whichever it was given:
/** Restarts the exercise in place, back at the brief (the runner). */
onRetry?: () => void;
/** Opens the exercise for a fresh attempt instead (a saved report). */
retryHref?: string;
Two optional props with a comment each beat one required prop with a lie in it.
See it
The e-tray scenario named on cogniprep.app/assessment-centre-practice is free to play in full, and the page names which one it is. Play it to the end and the screen you land on is the in-tab one: built in the browser, from the scenario the runner is holding, and gone when you close the tab. That is the renderer this post started with, and it is the one currently live.
The second renderer, the one behind /dashboard/exercises/report/<attemptId>, is what the refactor was for, and it ships with the next release rather than being there to click today. When it is, the check worth running is the payload one: open a saved report with the Network tab recording and search the document response for the word rationale. The explanations for options you were never shown should not be in it, because the builder never put them in the shape.
If you want the background on the exercise format before you try one, the e-tray guide is about 2,000 words and needs no sign-up to read.
The general version
If a screen is built from whatever the surrounding scope happens to hold, it has exactly one caller and you will not find out until you need a second one. Writing down the shape it actually renders is the cheap half. The valuable half is that the shape turns out to be much smaller than the scope, and everything you left out is something you no longer have to ship to the browser.
Top comments (0)