An Honest Ticket Status Stepper: Learning from the History Array
The ticket stepper stops guessing from one status string: stage times come from the transition history, first row per status.
TL;DR
The stepper used to guess progress from one status string, now it maps real timestamps from the history array and falls back to created and updated times. Four display stages replace six cluttered steps, grouping terminal statuses while safely handling invalid dates and listing history in order. Transformations run during render, and const literal types prevent invalid pillar submissions.
When I opened ticket-flow.tsx in the KotaPortal project, I could only shake my head. The stepper component that was supposed to show ticket progress was just full of guesses. We only had a single status string from the API, and the frontend code was forced to derive three booleans from that string. If the status was "processing", the stepper guessed on its own whether it was stage two or three. Transition times? Only available at the aggregate level, no per-stage details.
Misconceptions about the number of steps
Initially, I thought the solution was to build a stepper with six steps because there were six possible statuses in the database. I also briefly assumed we had to ask the backend team to add new timestamp fields for each stage. It felt logical; visually, we needed six time points. I had even mapped out a new schema in my head.
But when I checked the OpenAPI contract again, it turned out the endpoint was already sending a history array. The six-step assumption immediately collapsed. Users don't need six tiny circles cluttering the screen. Four main stages are enough, and the final stage accommodates three terminal statuses: approved, rejected, or completed.
Extracting time from the history array
Rather than guessing, it is better to drive the timeline from the actual transition history. In ticket-flow.tsx, I changed the TicketHistoryItem type to come directly from the generated contract, the StatusHistoryItem schema [6]. Types from the openapi-typescript generator are runtime-free: they vanish at compile time, so they do not add to the bundle size [6].
The logic is short. A Map stores the timestamp: each history row checks its to_status, and if that status is not yet in the map, its timestamp is added. The first row recording a status is its transition time. If the history array is empty or fails to load, the code falls back to the created and last updated times from the main ticket data.
I ran into an issue when trying to parse dates from the history. Non-standard date string formats are implemented differently across browsers, and invalid strings parse to NaN [5]. The date formatting function in this component now always checks the parse result before using it; NaN means render nothing, rather than displaying weird text.
Separating display and data
This separation of four display stages versus six data statuses helps immensely. The flowStageOf function only selects which stage is active, while isFinalStatus checks whether the current status has entered the terminal group.
To display the full history below the stepper, I used the ol tag [7], because the order of this list is meaningful, like recipe steps or turn-by-turn directions [7]. I added a data-testid attribute to make automation testing easier. If there is an unrecognized, weird status string, the labelOf function falls back to the raw text so the UI does not go blank.
I placed this data transformation for rendering at the top level of the component, not inside a useEffect. React recommends running data transformation during render directly from props, to avoid unnecessary re-renders [4]. If you try this pattern and get a warning about state updates inside an effect, that is a sign the transformation logic is still awkwardly placed in the wrong spot.
Form validation with literal types
The same commit fixed the registration form. Previously, users could submit a pillar not on the list. Now, the option list is defined as const, and its type is derived from the array itself into a string literal union [1]. At compile time, TypeScript immediately complains if there is a value outside the union. At runtime, the guard remains form validation: the pillar must be selected before submission. Thus, types catch mistakes while writing code, not when the user complains.
I personally prefer this kind of defensive approach. Rather than adding manual validation that is prone to being forgotten, it is better to let the compiler guard the compile part, and the form guard the runtime part. A good stepper does not guess conditions; it shows facts from the recorded history, and the incoming input is made impossible to get wrong right from the form.