Chat result rows, timeline style: metadata first
The time chip and tags in the chat popup were never a CSS problem. Metadata had to travel from SQL up to the DTO before the UI could dress up.
TL;DR
Popup hasil pencarian tampak datar karena API sama sekali tidak mengirim published_at, tags, dan kategori, jadi masalahnya bukan CSS melainkan kontrak data. Solusinya adalah mengangkat metadata dari query SQL lewat repository dan DTO ber-Pydantic sampai ke browser, lalu TimelineRow dipakai ulang di tiga konteks popup. Pelajarannya: perbaiki alur data dari database dulu, baru komponen UI bisa didandakan.
The article rows in the chat results popup looked flat: just a title and an excerpt, no time chip, no tags. Meanwhile the article page already had a beautiful timeline: a vertical spine, tech icons, category badges, citation badges. The visual gap was obvious, and the question was simple: why not give the popup the same treatment?
My first guess: copy the timeline component, drop it into the popup, done. A pure UI job, right?
Wrong. The time chip was missing not because the CSS was missing, but because the API never sent published_at in search results at all. Tags and categories? Also not sent. No matter how polished the frontend component got, there was no data to render. The assumption that this was "just visual" failed completely.
What the feature actually needed was lifting metadata all the way from SQL to the browser. Same lesson as the earlier piece on streaming AI answers through plain fetch: fix the data contract first, style later.
Lifting metadata from SQL
In repositories.py, the SELECT queries now fetch the tags_json and categories_json columns from the articles_posts table. Their values are raw JSON strings, so they go through json.loads before landing in SearchRow. The domain entity gains two new fields, tags and categories, both optional lists of strings defaulting to empty.
One layer up, the SearchHitDTO, a Pydantic BaseModel, takes tags and categories with a default of an empty list of strings. This is where the Pydantic contract pays off: once data passes validation, fields are guaranteed to match their declared types, so tags on the DTO will never be None even when the database returns NULL [4]. API consumers just use the list without defensive checks. SQLModel underneath is a thin layer over Pydantic and SQLAlchemy, so the data structure never has to be defined twice in two worlds [5].
Both the FTS query and the simple LIKE query were updated to fetch these JSON columns. The search response now carries tags and categories. Sources in about-mode, delivered over SSE, also bring the new fields plus published_at; because the SSE payload is a JSON string, the datetime is serialized to ISO format first, or sent as null when an article has no date.
Only after this pipeline was complete did the frontend have something to render.
One component, three contexts
TimelineRow is reused in three places inside the popup: search-mode results, FTS suggestions, and about-mode related articles. One component, one format. Each row shows a time chip, a tech icon tile, a category chip, a citation badge, then the title and excerpt beside them.
Dates are converted from UTC to WIB through formatDateWIB. The old safeDate helper, which only existed to fall back to an empty value for invalid dates, was removed from the component. The new conversion function already handles that case: invalid dates render as nothing, without throwing. Less code, same behavior.
I personally prefer this direction over copy-pasting the old timeline markup into the popup. Copying markup finishes faster, sure, but once the row design changes there are two places to remember, and one of them usually gets forgotten. One component with optional props is slower to design and never accrues that debt.
The backend change itself looks tiny: two extra SELECT columns plus JSON parsing. Yet it spread across seven files, 214 lines added, 91 removed. Quite a lot for "just adding a timeline to the popup". But that is the real shape of it: a UI component that displays metadata needs data that actually flows from the database. Without the full pipeline, the timeline in the popup is an empty decoration.
After going live I checked the popup manually: result rows now carry the same WIB time chip as the article page, the icon tile appears from the first tag, and citation badges still jump to sources. What remains is small polish, like row spacing on narrow screens, an afternoon's work, not an architecture one.
The lesson I take away: when a UI component needs data the API does not have yet, do not start from CSS or layout. Start from the data model in the database, pull it up one layer at a time, and make sure every layer forwards the fields the next one needs. Data lives first, then the component can dress up.
Sources
- [4] Pydantic docs: Models (accessed 2026-09-01)
- [5] SQLModel docs (accessed 2026-09-01)
- Related: AI answers streaming on a blog through plain fetch