EV fleet mobility
Two products on one spine - and charging infrastructure - with a sales console and a content system the client runs themselves.
EV fleet leasing, rentals, transport and logistics platform with a charging-infrastructure arm - public sites, a sales console, and a self-serve content system.
Two products on one spine - and charging infrastructure - with a sales console and a content system the client runs themselves.
Made validation , imported by the API and every form. No drift, because there is nothing to drift from.
unreadable session credentials where untrusted content renders, something simpler where it doesn't.
Chased a credential bug that looked like . It was one assumption underneath all of them.
Caught a pipeline that was passing green while shipping nothing. Now I check artifacts, not exit codes.
Left working code alone instead of rewriting it to match the stack.
Trio EV is an electric-mobility company, and the platform is two products that share a spine.
The mobility side is the public face of the fleet business - leasing, rentals, transport, and logistics. A visitor reads a service page, looks at the live network map, and submits an inquiry. That inquiry is the conversion event. Everything behind it exists to make sure a human answers fast.
The charging side at charging.trioev.com is the infrastructure arm - charge-point management, charger supply, O&M services, EV-infra consultancy, and premium charging hubs. Same business model, different buyer, different visual language.
Behind both: a shared API, a content system the client runs themselves, and a console where the sales team lives all day. What follows is not a code tour - it is the short list of problems that actually cost me time, and what I changed so they would not come back.
The problem. Several frontends and one API all validate the same things - an inquiry, a login, a post. The default outcome is that each grows its own copy of the rules, and they drift. The version you find out about is the one a customer hits at 11pm, where the form accepted something the server rejected.
What I did. Made the validation schema a first-class piece of the system rather than a detail inside each app. One definition per concept, imported by the API's request validation *and* by every form on every frontend. Not copied - imported.
Why it holds. There is nothing to drift *from*. A field that changes shape changes in one place, and every consumer either updates or fails to compile. The errors show up at build time instead of in a support ticket.
The cost, stated honestly. Shared source across a multi-app setup complicates the build and container plumbing - every image has to know about the shared piece before it installs anything. That bit me more than once, and the failure looks like a dependency problem when it is actually a packaging problem. I still think the trade is obviously worth it.
The problem. The internal console and the public site are both browser apps talking to the same API, but they carry very different risk. The console renders rich content authored by humans and holds the keys to the whole business. The public site holds almost nothing. Treating them identically means either over-securing the public side into uselessness, or under-securing the console.
What I did. Gave them different credential transports on purpose.
My most expensive debugging session, and the one I would re-tell in an interview.
The symptom. Everything worked locally. On a real deployment, every write from the console failed. Reads were fine.
1. The browser was silently dropping the credential. Default rules refuse to attach a credential on a request to a different site. Not an error - just absent. Fixing it forces the connection to be fully encrypted, which turns TLS from a launch-week task into a hard prerequisite.
2. The anti-forgery check could not read its own token. The scheme needs the client to read a value and echo it back on writes. But a page can only read values scoped to its own address - and this one was scoped to the API's. The read returned empty. Not an error. Empty. So every write went out missing the header, and every write was correctly rejected.
The fix for that one: stop treating browser storage as the source of truth and hold the value in memory for the session, sourced from the response itself. That worked only because the endpoint returned the value in its body as well as setting it in the browser. When I built it that looked like pointless redundancy. It turned out to be the only thing that made the situation recoverable without a redesign.
3. Third-party credential blocking. Even with the first two fixed, browsers are progressively refusing this whole category of cross-site credential - and by definition that is exactly what this was.
I stopped fighting the browser and removed the cross-origin condition entirely, routing API traffic so it is same-origin from the app's point of view. Every one of the three problems evaporated, and the pre-flight round trips went with them.
The lesson is the part I actually keep. I spent days fixing symptoms one at a time. All three shared a root cause I had accepted as a given - *the app and its API live at different addresses*.
When three unrelated-looking bugs cluster, question the assumption underneath them instead of patching each one.
The symptom. CI passed. Deployments reported success. The live site did not change. For days.
The cause. A command-line flag in the wrong position. In that position the build tool treated it as a request for information, printed some help text, and exited successfully. Not an error. Not a warning. A success code, a step that finished in under a second, and an empty output folder. Every guard in the pipeline - lint, typecheck, build - was passing by doing nothing at all.
A second, related issue compounded it: the deploy ran in a shell that does not load a normal user environment, so a tool that works when you type it by hand was simply absent there.
Both share one root cause: a step that 'passed' is not the same as a step that ran. I now check pipelines against what they produced, not what they returned. A build that finishes in under a second is a red flag, not a fast build.
The problem. Content editors upload whatever their phone produced. Nobody is going to resize anything, and nobody should have to. Here is what the system does with a single upload:
The problem. The client wanted a real editor - headings, images, embeds, formatting. The obvious implementation stores what the editor produces and injects it into the page later. That means permanently storing markup you did not write and rendering it with the browser's safety rails off.
What I did. Stored the content as structured data rather than markup, and wrote a small renderer that walks that structure and produces page elements.
Why it is better. The renderer is an allowlist by construction. If a content type is not something it explicitly knows how to draw, it renders as nothing - rather than as an injection. There is no 'off' switch to forget.
The second benefit was accidental and mattered as much: because the renderer is shared, the editor's preview and the published page run through the exact same code. Preview mismatch is a whole class of bug that simply never appeared.
Every page on both sites funnels to one form, so the inquiry system got more care than anything else. An inquiry moves through a clear lifecycle, can be assigned to a person, and carries two distinct kinds of history: private internal notes, and the replies that actually went out.
That second one matters more than it looks. The reply is stored, not just sent. Six weeks later, 'what exactly did we tell this customer?' has an answer that is not buried in one salesperson's mail client.
The problem. The console's landing page shows a dozen-plus numbers - totals, month-over-month movement, response rate, visitor counts, breakdowns, a rolling trend, top sources. Computed naively that is a pile of database work on every page load, and the team opens this page constantly.
The obvious answer is a caching layer: a whole additional piece of infrastructure to run, monitor, and pay for.
What I did instead. Fired all the queries concurrently rather than in sequence, and held the assembled result in the application's own memory for sixty seconds. Roughly a dozen lines. No extra service, no invalidation protocol, no new failure mode.
One service, one small team, and a set of numbers nobody acts on within the same minute - the constraints made the simple answer the correct one. If it ever runs on several machines, each keeps its own brief view, and for a KPI tile that is still fine.
One detail I am glad I handled: growth percentages against a previous value of zero. There is no honest percentage there, so the calculation returns nothing at all and the interface shows a dash rather than a number that means nothing.
The network section renders a live map of India - pulsing location markers, region hover cards, an animated vehicle tracing routes. It is intricate, hand-tuned, and not built with the same UI framework as the rest of the site. The instinct is to port it. I did not.
Rewriting meant either injecting the whole thing wholesale - which defeats the point - or spending a week rebuilding animation logic into a framework's lifecycle. And after that week, every future visual tweak becomes a framework problem instead of a drawing problem.
So it stays what it is, embedded with a hard boundary around it. That boundary turned out to be a feature: its styles cannot leak into the app, and the app's styling cannot reach in and break it. Exactly two things cross the line - the address it fetches live location data from, and the current light/dark theme.
On that theme: its dark mode is a warm near-black with soft slate text, not black-and-white inversion, and the landmass is *lifted* slightly above the background so it reads as raised material rather than a hole punched through the page.
The lesson. 'It does not match our stack' is not by itself a reason to rewrite working, finished code. Isolate it, define the contract across the boundary, and move on.
Trio EV is client work, delivered with a small team over roughly four months. Two products, several frontends, one API, and a content system the client has been running on their own ever since.
If you are hiring, this is what I would want you to take from it:
Open to full-time roles and freelance projects. If you are building something hard - multi-app platforms, real internal tooling, or delivery pipelines that have to survive contact with production - drop a message.