<?xml version="1.0" encoding="utf-8"?><?xml-stylesheet type="text/xsl" href="atom.xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://www.yuchenghsu.dev/projects</id>
    <title>Yu Cheng Blog</title>
    <updated>2026-07-18T00:00:00.000Z</updated>
    <generator>https://github.com/jpmonette/feed</generator>
    <link rel="alternate" href="https://www.yuchenghsu.dev/projects"/>
    <subtitle>Yu Cheng Blog</subtitle>
    <icon>https://www.yuchenghsu.dev/img/favicon.ico</icon>
    <entry>
        <title type="html"><![CDATA[【Left Side Escalator】An AI Matchmaking Bot That Turns One Sentence into a Community Match]]></title>
        <id>https://www.yuchenghsu.dev/projects/left-side-escalator-ai-matchmaking-bot</id>
        <link href="https://www.yuchenghsu.dev/projects/left-side-escalator-ai-matchmaking-bot"/>
        <updated>2026-07-18T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A production Discord bot that helps members of community find people with shared interests through natural-language queries, built with Discord.js, OpenAI GPT-4o, Node.js, and Express.]]></summary>
        <content type="html"><![CDATA[<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="project-overview">Project Overview<a href="https://www.yuchenghsu.dev/projects/left-side-escalator-ai-matchmaking-bot#project-overview" class="hash-link" aria-label="Direct link to Project Overview" title="Direct link to Project Overview" translate="no">​</a></h2>
<p><img decoding="async" loading="lazy" alt="Left Side Escalator AI matchmaking bot" src="https://www.yuchenghsu.dev/assets/images/leftside-escalator-cover-369bd9400cba61159b25b94b009f1f4d.png" width="1600" height="900" class="img_ev3q"></p>
<p><a href="https://www.leftsideescalator.com/" target="_blank" rel="noopener noreferrer" class="">Left Side Escalator (電扶梯走左邊)</a> is a Taiwanese personal-growth podcast focused on helping listeners step outside their comfort zones and become better versions of themselves. Its Discord community brings together listeners who want to exchange ideas, take action, and connect with people who share similar values.</p>
<p>After noticing that members had no easy way to find people with similar interests, I proactively proposed, designed, and built an AI matchmaking bot for the community.</p>
<p>A member simply tags <code>@AI Matchmaker</code> and writes a request such as:</p>
<blockquote>
<p>“I’m looking for friends who like yoga.”</p>
</blockquote>
<p>The bot interprets the request, searches the community’s member data, and returns relevant matches within seconds.</p>
<p>Built independently with Discord.js, OpenAI GPT-4o, Node.js, and Express, the bot went from proposal in December 2024 to public launch in February 2025.</p>
<p><img decoding="async" loading="lazy" alt="chatbot" src="https://www.yuchenghsu.dev/assets/images/chatbot-9b61a2b4b0b4067dcab66920899ac43c.png" width="1621" height="970" class="img_ev3q"></p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-problem-did-i-solve">What Problem Did I Solve?<a href="https://www.yuchenghsu.dev/projects/left-side-escalator-ai-matchmaking-bot#what-problem-did-i-solve" class="hash-link" aria-label="Direct link to What Problem Did I Solve?" title="Direct link to What Problem Did I Solve?" translate="no">​</a></h2>
<p>As the community grew, finding people with similar interests required members to search introduction posts manually or ask an administrator for help.</p>
<p>I turned this manual process into a self-service experience: members can describe who they want to meet in natural language and receive relevant matches within seconds.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="who-uses-it">Who Uses It?<a href="https://www.yuchenghsu.dev/projects/left-side-escalator-ai-matchmaking-bot#who-uses-it" class="hash-link" aria-label="Direct link to Who Uses It?" title="Direct link to Who Uses It?" translate="no">​</a></h2>
<p>The bot launched publicly in the Left Side Escalator (電扶梯走左邊) Discord community on February 20, 2025. Members can tag <code>@AI Matchmaker</code> and type a natural-language request to find people with shared interests or birthdays. Usage logs show that members continued to use the bot for matchmaking through at least June 2025, with queries ranging from yoga and hiking to psychology.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="results">Results<a href="https://www.yuchenghsu.dev/projects/left-side-escalator-ai-matchmaking-bot#results" class="hash-link" aria-label="Direct link to Results" title="Direct link to Results" translate="no">​</a></h2>
<ul>
<li class="">Replaced manual searching and admin-assisted matching with a one-sentence query.</li>
<li class="">Reduced the barrier to starting conversations with like-minded members.</li>
<li class="">Continued receiving real community usage for months after launch.</li>
</ul>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-key-decisions-did-you-own">What key decisions did you own?<a href="https://www.yuchenghsu.dev/projects/left-side-escalator-ai-matchmaking-bot#what-key-decisions-did-you-own" class="hash-link" aria-label="Direct link to What key decisions did you own?" title="Direct link to What key decisions did you own?" translate="no">​</a></h2>
<ol>
<li class=""><strong>Splitting the architecture into three layers.</strong> I deliberately split the system into a Discord bot (interaction layer), an Express API (data layer), and OpenAI (natural-language-to-query translation layer), instead of putting all logic inside the bot — so the data service could be deployed and verified independently.</li>
<li class=""><strong>Using the LLM as a query compiler, not a semantic search engine.</strong> I skipped embeddings and a vector database, and instead wrote a strict system prompt (<code>openaiSystemMessage.js</code>) that constrains GPT-4o to translate user input into exactly two fixed API path formats (<code>/find?birthday=</code> or <code>/find?hobbies=</code>). Few-shot examples in the prompt hard-code date-format handling (Chinese/English) and interest-synonym expansion (e.g. "爬山" expands to a dozen-plus synonyms like "登山, 百岳, 爬百岳...") to keep match rates high.</li>
<li class=""><strong>Choosing CSV over a database for storage.</strong> Given the member count and low update frequency, I used a CSV file as the MVP data source (<code>csvLoader.js</code> loads it into memory once on startup) — deliberately skipping a database and an admin UI, to put engineering effort into query quality instead.</li>
<li class=""><strong>API security.</strong> The Express server has a Bearer-token <code>authMiddleware</code>, so member data can't be accessed without authorization or scraped externally.</li>
<li class=""><strong>Discord embed pagination.</strong> I wrote <code>generateEmbeds.js</code> to handle Discord's three hard embed limits (field length, total embed length, field count), so a result set with many matches automatically splits across embeds instead of erroring out (full discussion below).</li>
<li class=""><strong>Operational trade-offs.</strong> Since the bot was expected to run on a memory-constrained free tier (Render, 512MB), I added scheduled GC cleanup and a <code>/healthz</code> health-check endpoint for UptimeRobot to hit, so the bot wouldn't get marked offline due to idling or cold starts.</li>
</ol>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-constraints-did-you-face-and-how-did-you-trade-off">What constraints did you face, and how did you trade off?<a href="https://www.yuchenghsu.dev/projects/left-side-escalator-ai-matchmaking-bot#what-constraints-did-you-face-and-how-did-you-trade-off" class="hash-link" aria-label="Direct link to What constraints did you face, and how did you trade off?" title="Direct link to What constraints did you face, and how did you trade off?" translate="no">​</a></h2>
<ul>
<li class="">
<p><strong>Match accuracy depends entirely on prompt quality.</strong> Because interest matching on the Express side is a simple <code>string.includes()</code>, not semantic search, all the "fuzzy matching" capability comes from the synonym lists hand-written into the OpenAI system prompt. That means adding a new interest category requires manually extending the synonym list — a known, accepted maintenance cost, since the community's interest categories are limited and this is far cheaper than adding vector search.</p>
</li>
<li class="">
<p><strong>Data updates are a manual process.</strong> When a new member joins, their data has to be manually added to <code>data.csv</code> and the service restarted. This is called out explicitly as a known limitation in the proposal deck ("Update database (CSV) manually if there are new friends"). The trade-off: the community doesn't grow fast enough to justify building an admin CMS for this.</p>
</li>
<li class="">
<p><strong>Discord's message-format limits (not fully solved).</strong> Discord embeds stack four hard limits — a single field's content ≤1024 characters, a single embed's total content ≤6000 characters, a max of 25 fields per embed, and <strong>a max of 10 embeds per message</strong>. <code>generateEmbeds.js</code> handles the first three (content over 1024 characters auto-splits into a new field marked <code>(cont.)</code>; content over 6000 characters or more than 25 fields opens a new <code>EmbedBuilder</code>) — but <strong>not the fourth</strong>. The current implementation sends every embed <code>transformDiscordEmbed()</code> produces in a single <code>message.edit({ embeds: transformDiscordEmbedResult })</code> call (see <code>index.js</code>). There's a commented-out earlier implementation still in the code that sent each embed as its own message (<code>message.channel.send({ content: '&lt;@...&gt;', embeds: [embed] })</code>), which would have sidestepped the 10-embed limit — but it flooded the channel and hurt the experience, so I switched to a single combined reply. This is a known, unresolved trade-off: it buys a clean single reply, at the cost that a query matching enough people (roughly 10+ embeds' worth) would theoretically get rejected outright by the Discord API — there's currently no guard or graceful degradation for that case.</p>
</li>
<li class="">
<p><strong>Free/low-cost deployment trade-offs.</strong> The proposal stage compared Vercel (cold starts), Zeabur (starting at $5/month), and AWS, and ultimately went with a free/low-cost tier constrained to 512MB of memory — which is why memory monitoring and manual GC exist, in exchange for zero or near-zero hosting cost.</p>
</li>
<li class="">
<p><strong>Deliberately narrow query scope.</strong> The bot only supports two query dimensions — birthday and interests — rather than open-ended full-text search, so that OpenAI's output format stays constrained and verifiable, avoiding natural-language translations that don't fit the API format.</p>
</li>
<li class="">
<p><strong>Cost is controlled but not built for future scale.</strong> GPT-4o usage cost ($2.50 / $10 per 1M tokens) is acceptable at the community's current size, so there's no caching or local embedding model. If usage grows significantly, the current architecture would need to be re-evaluated.</p>
</li>
</ul>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="other-notes">Other Notes<a href="https://www.yuchenghsu.dev/projects/left-side-escalator-ai-matchmaking-bot#other-notes" class="hash-link" aria-label="Direct link to Other Notes" title="Direct link to Other Notes" translate="no">​</a></h2>
<p>This project went from an architecture proposal deck on 2024-12-29, to a public launch for the full community on 2025-02-20, to real, stable usage logs at least through 2025-06-02 — spanning roughly six months of continuous production operation, which demonstrates a full design-build-deploy-operate delivery cycle, not just a POC. The code also defends against failure cases (if OpenAI or the API call fails, the bot replies "Error generating response, please try again later" instead of crashing), showing production robustness was considered as part of delivery.</p>]]></content>
        <category label="Discord Bot" term="Discord Bot"/>
        <category label="OpenAI" term="OpenAI"/>
        <category label="Node.js" term="Node.js"/>
        <category label="Express" term="Express"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[【QR Digital Pass】 A Cross-Platform Check-In App for Events and Conferences]]></title>
        <id>https://www.yuchenghsu.dev/projects/qr-event-checkin-app</id>
        <link href="https://www.yuchenghsu.dev/projects/qr-event-checkin-app"/>
        <updated>2026-07-18T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A cross-platform React Native app that streamlines attendee registration and check-in for exhibitions, conferences, and events using QR code scanning and image recognition.]]></summary>
        <content type="html"><![CDATA[<p><img decoding="async" loading="lazy" alt="QR Digital Pass" src="https://www.yuchenghsu.dev/assets/images/qr-a5516cbe753b8b75605913121f9edb73.png" width="2420" height="1566" class="img_ev3q"></p>
<p><a href="https://apps.apple.com/tw/app/qr-%E6%95%B8%E4%BD%8D%E9%80%9A%E8%A1%8C%E8%AD%89/id1605943011" target="_blank" rel="noopener noreferrer" class="">QR Digital Pass</a> is a cross-platform mobile application built with React Native, designed to streamline attendee registration and check-in for exhibitions, conferences, and events.</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-problem-did-you-solve">What problem did you solve?<a href="https://www.yuchenghsu.dev/projects/qr-event-checkin-app#what-problem-did-you-solve" class="hash-link" aria-label="Direct link to What problem did you solve?" title="Direct link to What problem did you solve?" translate="no">​</a></h2>
<p>Event organizers needed a faster way to manage participant entry than manual check-in lists — one that could handle high attendee volume at the door, confirm identity quickly, and still leave organizers with usable data afterward. The app uses QR code scanning with image recognition to check attendees in, with automatic event-specific login and real-time sync of check-in data to a backend that stores it for statistical analysis.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="who-actually-uses-it">Who actually uses it?<a href="https://www.yuchenghsu.dev/projects/qr-event-checkin-app#who-actually-uses-it" class="hash-link" aria-label="Direct link to Who actually uses it?" title="Direct link to Who actually uses it?" translate="no">​</a></h2>
<p>Event staff, at the door of exhibitions, conferences, and events, scanning attendee QR codes to manage entry — and event organizers afterward, who use the synced check-in data and backend statistics for post-event review and future marketing strategy. The app is live on the <a href="https://apps.apple.com/tw/app/qr-%E6%95%B8%E4%BD%8D%E9%80%9A%E8%A1%8C%E8%AD%89/id1605943011" target="_blank" rel="noopener noreferrer" class="">App Store</a>.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-results-did-it-produce">What results did it produce?<a href="https://www.yuchenghsu.dev/projects/qr-event-checkin-app#what-results-did-it-produce" class="hash-link" aria-label="Direct link to What results did it produce?" title="Direct link to What results did it produce?" translate="no">​</a></h2>
<p><em>Not yet documented here — worth adding concrete numbers if available: number of events run through the app, attendee volume handled, or check-in speed compared to manual entry.</em></p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-key-decisions-did-you-own">What key decisions did you own?<a href="https://www.yuchenghsu.dev/projects/qr-event-checkin-app#what-key-decisions-did-you-own" class="hash-link" aria-label="Direct link to What key decisions did you own?" title="Direct link to What key decisions did you own?" translate="no">​</a></h2>
<ul>
<li class=""><strong>Cross-platform via React Native</strong> — one codebase covers both iOS and Android, so check-in coverage at the door isn't limited by which platform an event's staff or attendees carry.</li>
<li class=""><strong>QR scanning paired with image recognition</strong> — rather than a bare QR decode, check-in combines QR code scanning with image recognition to confirm entry, aimed at holding up under high attendee volume at the door.</li>
<li class=""><strong>Automatic event-specific login</strong> — login is scoped to the event automatically instead of requiring staff to manually authenticate per event, cutting a step out of the door workflow.</li>
<li class=""><strong>Real-time sync to backend storage</strong> — check-in data syncs live rather than batching afterward, and is persisted specifically so organizers get statistical analysis for post-event review and future marketing strategy, not just a raw attendance log.</li>
</ul>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-constraints-did-you-face-and-how-did-you-trade-off">What constraints did you face, and how did you trade off?<a href="https://www.yuchenghsu.dev/projects/qr-event-checkin-app#what-constraints-did-you-face-and-how-did-you-trade-off" class="hash-link" aria-label="Direct link to What constraints did you face, and how did you trade off?" title="Direct link to What constraints did you face, and how did you trade off?" translate="no">​</a></h2>
<p><em>Not yet documented here — e.g. constraints around scanning reliability at high attendee volume, offline/connectivity handling at venues, or backend design trade-offs for real-time sync.</em></p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="project-details">Project Details<a href="https://www.yuchenghsu.dev/projects/qr-event-checkin-app#project-details" class="hash-link" aria-label="Direct link to Project Details" title="Direct link to Project Details" translate="no">​</a></h2>
<table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody><tr><td><strong>Category</strong></td><td>Event Tech / Mobile Application</td></tr><tr><td><strong>Services</strong></td><td>Cross-platform app development</td></tr><tr><td><strong>Tech Stack</strong></td><td>React Native</td></tr></tbody></table>]]></content>
        <category label="Mobile" term="Mobile"/>
        <category label="React Native" term="React Native"/>
        <category label="Event Tech" term="Event Tech"/>
        <category label="QR Code" term="QR Code"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[【Foxconn】 A Telehealth Platform Built to Make Medical Care Faster and More Convenient]]></title>
        <id>https://www.yuchenghsu.dev/projects/foxconn-telecare-platform</id>
        <link href="https://www.yuchenghsu.dev/projects/foxconn-telecare-platform"/>
        <updated>2026-07-17T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A remote-care platform built for Foxconn (Hon Hai) — a patient app for online registration and remote consultations, an NHI-compliant reporting system for physicians, and a bedside tablet that streams vitals in real time.]]></summary>
        <content type="html"><![CDATA[<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="project-overview">Project Overview<a href="https://www.yuchenghsu.dev/projects/foxconn-telecare-platform#project-overview" class="hash-link" aria-label="Direct link to Project Overview" title="Direct link to Project Overview" translate="no">​</a></h2>
<p><img decoding="async" loading="lazy" alt="Foxconn Telecare Platform" src="https://www.yuchenghsu.dev/assets/images/foxconn-cover-e1db54bf73fad75f90807a8f77d378b5.jpg" width="2000" height="1125" class="img_ev3q"></p>
<p>Foxconn's telehealth platform covers three parts — a patient app, an NHI-compliant physician reporting system, and a bedside tablet for clinical vitals. My contribution was the <strong>patient-facing app</strong>, which I built independently end-to-end with React Native + Redux-saga over a six-month project.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-i-solved">What I solved?<a href="https://www.yuchenghsu.dev/projects/foxconn-telecare-platform#what-i-solved" class="hash-link" aria-label="Direct link to What I solved?" title="Direct link to What I solved?" translate="no">​</a></h2>
<p>Patients had no self-service way to actually use telemedicine — book a doctor, join a video consultation, pay for it, link family members' accounts, and, for select consultations, share live vitals like blood oxygen without a separate clinical device. I independently built the patient app that covers all of that.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="who-actually-uses-it">Who actually uses it?<a href="https://www.yuchenghsu.dev/projects/foxconn-telecare-platform#who-actually-uses-it" class="hash-link" aria-label="Direct link to Who actually uses it?" title="Direct link to Who actually uses it?" translate="no">​</a></h2>
<p>Patients, through the app — for doctor appointment booking, video consultations, third-party login and payment, linking family member accounts via QR code, and real-time Bluetooth blood-oxygen monitoring during select consultations.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-results-did-it-produce">What results did it produce?<a href="https://www.yuchenghsu.dev/projects/foxconn-telecare-platform#what-results-did-it-produce" class="hash-link" aria-label="Direct link to What results did it produce?" title="Direct link to What results did it produce?" translate="no">​</a></h2>
<p>The app shipped and the project generated <strong>over 8M+ in revenue</strong> across its six-month build.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-key-decisions-did-you-own">What key decisions did you own?<a href="https://www.yuchenghsu.dev/projects/foxconn-telecare-platform#what-key-decisions-did-you-own" class="hash-link" aria-label="Direct link to What key decisions did you own?" title="Direct link to What key decisions did you own?" translate="no">​</a></h2>
<p>As the sole developer on the patient app, I owned the full frontend implementation:</p>
<ul>
<li class="">Built the doctor appointment calendar UI.</li>
<li class="">Integrated the Jitsi React Native SDK for video consultations, using polling to control when a consultation ends.</li>
<li class="">Integrated third-party login (Facebook, Google, Apple), third-party payment, and QR-code scanning to link family member accounts.</li>
<li class="">Used <code>React.memo</code> to cut unnecessary re-renders on the appointment list.</li>
<li class="">Integrated a Bluetooth blood-oxygen meter via <code>react-native-ble-manager</code> for real-time readings during consultations.</li>
</ul>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-constraints-did-you-face-and-how-did-you-trade-off">What constraints did you face, and how did you trade off?<a href="https://www.yuchenghsu.dev/projects/foxconn-telecare-platform#what-constraints-did-you-face-and-how-did-you-trade-off" class="hash-link" aria-label="Direct link to What constraints did you face, and how did you trade off?" title="Direct link to What constraints did you face, and how did you trade off?" translate="no">​</a></h2>
<p>Working solo meant integrating several third-party SDKs — Jitsi for video, BLE for the blood-oxygen meter, three OAuth providers, and payment — into one coherent app rather than splitting that surface across a team. Ending a video consultation reliably was a specific problem: rather than trust SDK event callbacks alone to signal call-end, I used polling as a more robust source of truth, trading a small amount of latency for reliability. On the appointment list, I addressed rendering performance with <code>React.memo</code> at the component level instead of a heavier state-management restructure, since it solved the actual bottleneck without touching the rest of the data layer.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="project-details">Project Details<a href="https://www.yuchenghsu.dev/projects/foxconn-telecare-platform#project-details" class="hash-link" aria-label="Direct link to Project Details" title="Direct link to Project Details" translate="no">​</a></h2>
<table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody><tr><td><strong>Category</strong></td><td>Healthcare / Long-term Care</td></tr><tr><td><strong>Services</strong></td><td>Telehealth systems, App development, Digital product design</td></tr><tr><td><strong>Tech Stack</strong></td><td>React Native, Redux-saga, Jitsi SDK, react-native-ble-manager</td></tr><tr><td><strong>Year</strong></td><td>2023 – 2024</td></tr><tr><td><strong>Client</strong></td><td>Hon Hai Precision Industry Co., Ltd. (Foxconn)</td></tr></tbody></table>]]></content>
        <category label="Healthcare" term="Healthcare"/>
        <category label="Telemedicine" term="Telemedicine"/>
        <category label="React Native" term="React Native"/>
        <category label="App Development" term="App Development"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[【Rybit】 Building the CMS Behind a 40,000-Device E-Bike Fleet Platform]]></title>
        <id>https://www.yuchenghsu.dev/projects/rybit-fleet-management-platform</id>
        <link href="https://www.yuchenghsu.dev/projects/rybit-fleet-management-platform"/>
        <updated>2026-07-17T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[At Rybit, I build the web CMS and maintain the app behind a fleet management platform running 2M+ riders and 40,000+ e-bikes — from a B2B service-ticket system to code-quality tooling across the codebase.]]></summary>
        <content type="html"><![CDATA[<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="project-overview">Project Overview<a href="https://www.yuchenghsu.dev/projects/rybit-fleet-management-platform#project-overview" class="hash-link" aria-label="Direct link to Project Overview" title="Direct link to Project Overview" translate="no">​</a></h2>
<p><img decoding="async" loading="lazy" alt="Rybit fleet management platform" src="https://www.yuchenghsu.dev/assets/images/rybit-cover-26ab1fa3b1beb4cdb5b2809196d5d2fd.jpg" width="1600" height="900" class="img_ev3q"></p>
<p><a href="https://rybit.io/" target="_blank" rel="noopener noreferrer" class="">Rybit</a> is a fleet-management platform supporting <strong>2M+ riders and 40,000+ devices</strong>. It helps delivery and shared e-bike operators manage vehicles, payments, tracking, maintenance, and IoT controls.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-i-solved">What I solved?<a href="https://www.yuchenghsu.dev/projects/rybit-fleet-management-platform#what-i-solved" class="hash-link" aria-label="Direct link to What I solved?" title="Direct link to What I solved?" translate="no">​</a></h2>
<p>Fleet operators lacked an end-to-end workflow connecting service tickets, asset status, outstanding fees, and repair charges. The asset-management flow also made redundant API requests during quota adjustments, while the existing codebase needed stronger typing and testing.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="my-contribution">My Contribution<a href="https://www.yuchenghsu.dev/projects/rybit-fleet-management-platform#my-contribution" class="hash-link" aria-label="Direct link to My Contribution" title="Direct link to My Contribution" translate="no">​</a></h2>
<ul>
<li class="">Built the <strong>B2B service-ticket and billing workflow</strong> with React and RTK Query.</li>
<li class="">Fixed a redundant API request caused by Ant Design Form remounting, while preserving all legitimate refetch triggers. (see the <a class="" href="https://www.yuchenghsu.dev/projects/eliminating-redundant-api-calls">full debugging write-up</a>)</li>
<li class="">Introduced strict TypeScript, Vitest, Playwright, and Storybook incrementally without disrupting feature delivery.</li>
<li class="">Helped maintain the rider-facing mobile app.</li>
</ul>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="who-actually-uses-it">Who actually uses it?<a href="https://www.yuchenghsu.dev/projects/rybit-fleet-management-platform#who-actually-uses-it" class="hash-link" aria-label="Direct link to Who actually uses it?" title="Direct link to Who actually uses it?" translate="no">​</a></h2>
<p>Fleet operators (B2B partners across food delivery, courier, and shared e-bike operations) and Rybit's own internal ops team, through the web CMS — the admin backend the whole platform runs on. Riders use the companion app I help maintain. The platform overall runs <strong>2M+ riders and 40,000+ devices</strong>, so anything shipped into the CMS or app is operating at that scale.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-results-did-it-produce">What results did it produce?<a href="https://www.yuchenghsu.dev/projects/rybit-fleet-management-platform#what-results-did-it-produce" class="hash-link" aria-label="Direct link to What results did it produce?" title="Direct link to What results did it produce?" translate="no">​</a></h2>
<ul>
<li class="">The <strong>B2B service-ticket system</strong> now runs the full ticket-to-billing flow end-to-end — asset-status tracking, outstanding-fee calculation, and the resulting repair charge — instead of requiring manual reconciliation across those steps.</li>
<li class="">The <strong>redundant API call fix</strong> eliminated an unnecessary server request that fired on every quota adjustment while preserving the three legitimate triggers (drawer open, asset removal, asset ID edit) — verified with a before/after network trace.</li>
<li class=""><strong>Code-quality tooling</strong> (strict TypeScript, Vitest + Playwright, shared Storybook catalog) is now part of the day-to-day workflow across the codebase.</li>
</ul>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="project-details">Project Details<a href="https://www.yuchenghsu.dev/projects/rybit-fleet-management-platform#project-details" class="hash-link" aria-label="Direct link to Project Details" title="Direct link to Project Details" translate="no">​</a></h2>
<table><thead><tr><th>Field</th><th>Detail</th></tr></thead><tbody><tr><td><strong>Category</strong></td><td>Last-mile Transportation / Fleet Management SaaS</td></tr><tr><td><strong>Services</strong></td><td>Web CMS (admin dashboard) development, App maintenance, Code quality tooling</td></tr><tr><td><strong>Year</strong></td><td>Jun 2024 – July 2026</td></tr><tr><td><strong>Company</strong></td><td>Rybit</td></tr></tbody></table>]]></content>
        <category label="Fleet Management" term="Fleet Management"/>
        <category label="B2B" term="B2B"/>
        <category label="React" term="React"/>
        <category label="Frontend" term="Frontend"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[【Debugging Notes】 How to Get a Cookie Sent on a Cross-Origin Request]]></title>
        <id>https://www.yuchenghsu.dev/projects/cross-origin-cookie-authentication</id>
        <link href="https://www.yuchenghsu.dev/projects/cross-origin-cookie-authentication"/>
        <updated>2026-07-16T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[Debugging notes on why a manually inserted cookie wouldn’t attach to a cross-origin API call — the Domain/Secure/SameSite rules browsers actually enforce, reproduced step-by-step in DevTools.]]></summary>
        <content type="html"><![CDATA[<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="background">Background<a href="https://www.yuchenghsu.dev/projects/cross-origin-cookie-authentication#background" class="hash-link" aria-label="Direct link to Background" title="Direct link to Background" translate="no">​</a></h2>
<p>This is a debugging write-up from my time at cacdi. We were integrating with a partner's ("Compal") API, and that API authenticated every request with a session cookie instead of a bearer token.</p>
<p><img decoding="async" loading="lazy" alt="localhost calling a third-party domain over GET/POST — does a cookie stored in the browser actually get attached to the request?" src="https://www.yuchenghsu.dev/assets/images/1-84264d16ad967b414b67a58d238cd69e.png" width="1470" height="385" class="img_ev3q"></p>
<p>The backend already had <code>Access-Control-Allow-Origin</code> configured, so the frontend wasn't blocked by CORS on a plain request. But calling the API from <code>localhost</code> during local development, the browser never sent the cookie along — even though Postman, given the same cookie, could call the API and get a valid response back. So the credential itself was fine. The question was purely about <strong>when a browser is willing to attach a cookie to a cross-origin request</strong>.</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-setup">The Setup<a href="https://www.yuchenghsu.dev/projects/cross-origin-cookie-authentication#the-setup" class="hash-link" aria-label="Direct link to The Setup" title="Direct link to The Setup" translate="no">​</a></h2>
<ol>
<li class="">The partner's API authenticates via cookie.</li>
<li class="">The backend already sets <code>Access-Control-Allow-Origin</code>, so the frontend isn't blocked by CORS.</li>
<li class="">Open question: how do we get a cookie attached when calling that API from <code>localhost</code>?</li>
<li class="">Postman, given the cookie manually, calls the API successfully and gets a result back.</li>
<li class="">The frontend needs to set <code>credentials</code>, and the backend needs <code>Access-Control-Allow-Credentials</code> — but that alone didn't explain what was happening in the browser.</li>
</ol>
<p>To isolate the cookie behavior from the rest of the app, I reproduced it directly in DevTools: manually insert a cookie in the <strong>Application</strong> panel, then see whether an outgoing request to the partner's domain actually carries it.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="reproducing-it-in-devtools">Reproducing It in DevTools<a href="https://www.yuchenghsu.dev/projects/cross-origin-cookie-authentication#reproducing-it-in-devtools" class="hash-link" aria-label="Direct link to Reproducing It in DevTools" title="Direct link to Reproducing It in DevTools" translate="no">​</a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="attempt-1--name--value-only">Attempt 1 — name &amp; value only<a href="https://www.yuchenghsu.dev/projects/cross-origin-cookie-authentication#attempt-1--name--value-only" class="hash-link" aria-label="Direct link to Attempt 1 — name &amp; value only" title="Direct link to Attempt 1 — name &amp; value only" translate="no">​</a></h3>
<p><img decoding="async" loading="lazy" alt="DevTools Application &amp;gt; Cookies panel: a cookie with only Name and Value set, no Domain/Secure/SameSite" src="https://www.yuchenghsu.dev/assets/images/2-ec30bd1808cf45da23d24496050745dc.png" width="1578" height="200" class="img_ev3q"></p>
<p>Insert just <code>Name</code> and <code>Value</code>, leaving <code>Domain</code>, <code>Secure</code>, and <code>SameSite</code> untouched. Result: the outgoing GET request still doesn't carry the cookie. With none of the attributes explicitly set, the browser doesn't consider it valid for a cross-site request.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="attempt-2--add-samesitenone">Attempt 2 — add <code>SameSite=None</code><a href="https://www.yuchenghsu.dev/projects/cross-origin-cookie-authentication#attempt-2--add-samesitenone" class="hash-link" aria-label="Direct link to attempt-2--add-samesitenone" title="Direct link to attempt-2--add-samesitenone" translate="no">​</a></h3>
<p><img decoding="async" loading="lazy" alt="DevTools shows the cookie row highlighted red after setting SameSite=None without Secure — Chrome rejects it" src="https://www.yuchenghsu.dev/assets/images/3-55476af463320322c4710dfb839501c7.png" width="1590" height="194" class="img_ev3q"></p>
<p>Set <code>SameSite</code> to <code>None</code>. Result: DevTools refuses to save the cookie at all — the row is highlighted red as invalid. Since Chrome 80, <code>SameSite=None</code> is only accepted together with <code>Secure</code>; without <code>Secure</code>, the browser drops it before it's even stored.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="attempt-3--add-secure">Attempt 3 — add <code>Secure</code><a href="https://www.yuchenghsu.dev/projects/cross-origin-cookie-authentication#attempt-3--add-secure" class="hash-link" aria-label="Direct link to attempt-3--add-secure" title="Direct link to attempt-3--add-secure" translate="no">​</a></h3>
<p><img decoding="async" loading="lazy" alt="DevTools Application panel: cookie saved successfully with SameSite=None and Secure checked, Domain still shows localhost" src="https://www.yuchenghsu.dev/assets/images/4-0f2e093039a3cf81390a637da2f5aee3.png" width="936" height="95" class="img_ev3q"></p>
<p>Check <code>Secure</code> alongside <code>SameSite=None</code>. Result: the cookie is now saved successfully — but the API call still doesn't include it. <code>Domain</code> is still <code>localhost</code>, which doesn't match the actual request domain, so the browser still won't attach it.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="attempt-4--match-the-requests-domain">Attempt 4 — match the request's <code>Domain</code><a href="https://www.yuchenghsu.dev/projects/cross-origin-cookie-authentication#attempt-4--match-the-requests-domain" class="hash-link" aria-label="Direct link to attempt-4--match-the-requests-domain" title="Direct link to attempt-4--match-the-requests-domain" translate="no">​</a></h3>
<p><img decoding="async" loading="lazy" alt="Network panel Cookies tab: request now carries the cookie once Domain is changed to match the partner&amp;#39;s domain" src="https://www.yuchenghsu.dev/assets/images/5-d6bc945a1d711b38143c750aabfcfb1c.png" width="1572" height="210" class="img_ev3q"></p>
<p>Change <code>Domain</code> to match the request's domain (the partner's domain). Result: the cookie is finally attached to the API call. One side effect worth knowing: once <code>Domain</code> no longer matches the current page's own origin, the cookie disappears from the <strong>Application → Cookies</strong> panel — you have to check it under <strong>Network → (request) → Cookies</strong> instead, which is the only place it still shows up.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="why-the-conditions-for-a-cookie-to-be-sent">Why: The Conditions For a Cookie to Be Sent<a href="https://www.yuchenghsu.dev/projects/cross-origin-cookie-authentication#why-the-conditions-for-a-cookie-to-be-sent" class="hash-link" aria-label="Direct link to Why: The Conditions For a Cookie to Be Sent" title="Direct link to Why: The Conditions For a Cookie to Be Sent" translate="no">​</a></h2>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token plain">Set-Cookie: Path=/; Domain=example.com; Secure; SameSite=Strict</span><br></div></code></pre></div></div>
<ul>
<li class=""><strong>Domain</strong> — must match the request's domain.</li>
<li class=""><strong>Secure</strong> — if set, the request must be made over HTTPS.</li>
<li class=""><strong>SameSite</strong> — one of three modes: <code>Strict</code>, <code>Lax</code> (the default), or <code>None</code>.<!-- -->
<ul>
<li class="">As of Chrome 80 (2020), the default changed from <code>None</code> to <code>Lax</code>.</li>
<li class=""><code>None</code> is only treated as valid when paired with <code>Secure</code>.</li>
</ul>
</li>
</ul>
<p>The table below (from Chrome's own developer docs) shows which request types still carry a <code>SameSite=Lax</code> cookie cross-site versus the old default behavior — top-level navigations (<code>Link</code>, <code>Form GET</code>) still do, but <code>Form POST</code>, <code>iframe</code>, <code>AJAX</code>, and <code>Image</code> requests no longer do under <code>Lax</code>:</p>
<p><img decoding="async" loading="lazy" alt="Comparison table: request type vs old default vs SameSite=Lax — Link/Perender/Form GET still fire, Form POST/iframe/AJAX/Image do not" src="https://www.yuchenghsu.dev/assets/images/6-a44b45e3c8c53ad5ac8bf1164af4a0f8.png" width="1316" height="728" class="img_ev3q"></p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="applying-it-to-the-real-integration">Applying It to the Real Integration<a href="https://www.yuchenghsu.dev/projects/cross-origin-cookie-authentication#applying-it-to-the-real-integration" class="hash-link" aria-label="Direct link to Applying It to the Real Integration" title="Direct link to Applying It to the Real Integration" translate="no">​</a></h2>
<p>Reproducing the mechanism in DevTools made the actual fix straightforward — it needed changes on both sides, not just one:</p>
<ol>
<li class=""><strong>Backend</strong>: issue the session cookie with <code>Domain</code>, <code>Secure</code>, and <code>SameSite=None</code> set, so the browser is willing to store it and forward it on a cross-site request in the first place.</li>
<li class=""><strong>Frontend</strong>: set <code>credentials: 'include'</code> (fetch) or <code>withCredentials: true</code> (axios/XHR) — without this, the browser won't attach <em>any</em> stored cookie to a cross-origin request, no matter how the cookie itself was issued.</li>
<li class=""><strong>Backend</strong>: return <code>Access-Control-Allow-Credentials: true</code>. <code>Access-Control-Allow-Origin</code> alone lets the browser send the request; without <code>Allow-Credentials</code> the browser blocks the frontend from reading the response even though the cookie went out.</li>
</ol>
<p>All three have to be true at once — missing any one of them looks identical from the frontend: the request goes out, but the API responds as unauthenticated.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="key-takeaways">Key Takeaways<a href="https://www.yuchenghsu.dev/projects/cross-origin-cookie-authentication#key-takeaways" class="hash-link" aria-label="Direct link to Key Takeaways" title="Direct link to Key Takeaways" translate="no">​</a></h2>
<ol>
<li class=""><strong>CORS and cookie-attachment are two independent gates.</strong> <code>Access-Control-Allow-Origin</code>/<code>Allow-Credentials</code> control whether the browser lets your JS <em>read</em> the response. <code>Domain</code>/<code>Secure</code>/<code>SameSite</code> control whether the browser <em>attaches</em> the cookie to the request in the first place. Both have to pass — fixing one without the other still fails.</li>
<li class=""><strong>Chrome 80 changed the default.</strong> Any flow that needs a cookie sent on a cross-site <code>fetch</code>/<code>XHR</code> (not just a top-level navigation like clicking a link) needs <code>SameSite=None; Secure</code> set explicitly — the old implicit behavior no longer applies.</li>
<li class=""><strong>DevTools is a fast way to isolate the mechanism from the app.</strong> Manually editing a cookie's <code>Domain</code>/<code>Secure</code>/<code>SameSite</code> in the Application panel let me test each condition in isolation without a real login flow — but remember that once <code>Domain</code> is changed away from the page's own origin, you have to inspect it via <strong>Network → Cookies</strong>, since it drops out of the Application panel view.</li>
</ol>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="references">References<a href="https://www.yuchenghsu.dev/projects/cross-origin-cookie-authentication#references" class="hash-link" aria-label="Direct link to References" title="Direct link to References" translate="no">​</a></h2>
<ul>
<li class="">Chrome Developers — "Get Ready for New SameSite=None; Secure Cookie Settings"</li>
<li class="">MDN — SameSite cookies explainer</li>
<li class="">Chrome's SameSite=Lax default rollout notes for third-party cookies</li>
</ul>]]></content>
        <category label="Cookies" term="Cookies"/>
        <category label="CORS" term="CORS"/>
        <category label="Security" term="Security"/>
        <category label="Browser" term="Browser"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[【Pandago】 A Responsive Vendor Landing Page Built to Convert Merchants]]></title>
        <id>https://www.yuchenghsu.dev/projects/pandago-vendor-landing-page</id>
        <link href="https://www.yuchenghsu.dev/projects/pandago-vendor-landing-page"/>
        <updated>2026-07-15T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A look at the frontend patterns behind foodpanda’s pandago vendor landing page — anchor navigation, a hero carousel, a logo marquee, and full responsive design.]]></summary>
        <content type="html"><![CDATA[<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="background">Background<a href="https://www.yuchenghsu.dev/projects/pandago-vendor-landing-page#background" class="hash-link" aria-label="Direct link to Background" title="Direct link to Background" translate="no">​</a></h2>
<p><a href="https://vendor.foodpanda.com.tw/pandago" target="_blank" rel="noopener noreferrer" class="">Pandago</a> is foodpanda's standalone, real-time delivery service — it lets any merchant, not just foodpanda-partnered restaurants, request an on-demand courier for local delivery. I worked on the front end of the public vendor landing page, the marketing site merchants land on before they sign up.</p>
<p><img decoding="async" loading="lazy" alt="pandago landing page hero section" src="https://www.yuchenghsu.dev/assets/images/1-21e9e83b5ae9e2412b10c84bdc5fa21f.png" width="2486" height="1574" class="img_ev3q"></p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-problem-did-you-solve">What problem did you solve?<a href="https://www.yuchenghsu.dev/projects/pandago-vendor-landing-page#what-problem-did-you-solve" class="hash-link" aria-label="Direct link to What problem did you solve?" title="Direct link to What problem did you solve?" translate="no">​</a></h2>
<p>The landing page had to do two jobs that pull in opposite directions: cover everything a prospective merchant needs to know (what pandago is, pricing, how to sign up, FAQ, contact) while still getting a visitor to the one answer they actually came for, fast. Most visitors arrive with a single specific question — "how much does it cost" or "how do I sign up" — not intent to read a long page top to bottom. On top of that, a meaningful share of traffic lands on a phone via a shared link, not a desktop browser, so the page couldn't just be a scaled-down version of a desktop design.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="who-actually-uses-it">Who actually uses it?<a href="https://www.yuchenghsu.dev/projects/pandago-vendor-landing-page#who-actually-uses-it" class="hash-link" aria-label="Direct link to Who actually uses it?" title="Direct link to Who actually uses it?" translate="no">​</a></h2>
<p>Prospective merchants — any local business considering pandago's on-demand courier service, not just existing foodpanda restaurant partners — landing on the public page at <code>vendor.foodpanda.com.tw/pandago</code> before they sign up. This is the first touchpoint in the acquisition funnel, so it's largely cold traffic arriving from search, ads, or a shared link, often on mobile.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-results-did-it-produce">What results did it produce?<a href="https://www.yuchenghsu.dev/projects/pandago-vendor-landing-page#what-results-did-it-produce" class="hash-link" aria-label="Direct link to What results did it produce?" title="Direct link to What results did it produce?" translate="no">​</a></h2>
<p>The page shipped as the live, public vendor-acquisition page for pandago — the page every prospective Taiwan merchant sees before signing up. It runs as a single long-scrolling page with anchor navigation, a hero carousel, an infinite logo marquee for social proof, and full responsive behavior across desktop, tablet, and mobile.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-key-decisions-did-you-own">What key decisions did you own?<a href="https://www.yuchenghsu.dev/projects/pandago-vendor-landing-page#what-key-decisions-did-you-own" class="hash-link" aria-label="Direct link to What key decisions did you own?" title="Direct link to What key decisions did you own?" translate="no">​</a></h2>
<ul>
<li class=""><strong>Anchor navigation over routing.</strong> The top nav (<code>pandago 是什麼</code>, <code>運費價格</code>, <code>教學指南</code>, <code>常見問題</code>, <code>聯絡我們</code>) smooth-scrolls to sections on the same page instead of routing to separate pages — matching navigation to the page's single-scroll structure so visitors can jump straight to the one section they need.</li>
<li class=""><strong>A carousel instead of a static hero banner.</strong> The hero section is the highest-attention real estate on the page, so I built it as a carousel that rotates through multiple promotional slides with manual arrow controls, rather than committing that space to one static message.</li>
<li class=""><strong>A logo marquee instead of a static logo grid.</strong> Partner logos (McDonald's, Carrefour, KFC, and other enterprise clients) scroll continuously rather than sitting in a fixed block — a compact way to establish social proof and scale ("業界最多品牌使用的") without pushing the rest of the page further down.</li>
<li class=""><strong>Mobile-first responsive behavior for every interactive piece</strong>, not just the static layout — the nav collapses into a mobile menu, sections restack vertically, and the carousel/marquee both adapt, since I treated mobile as a primary case rather than a fallback.</li>
</ul>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-constraints-did-you-face-and-how-did-you-trade-off">What constraints did you face, and how did you trade off?<a href="https://www.yuchenghsu.dev/projects/pandago-vendor-landing-page#what-constraints-did-you-face-and-how-did-you-trade-off" class="hash-link" aria-label="Direct link to What constraints did you face, and how did you trade off?" title="Direct link to What constraints did you face, and how did you trade off?" translate="no">​</a></h2>
<p>The single-scroll structure meant I couldn't lean on traditional multi-page routing to organize content — everything (pricing, tutorial, FAQ, contact) had to fit into scrollable sections reachable by anchor link, which constrains how much can live on the page before it feels bloated. The hero section also forced a trade-off between simplicity and message coverage: a static banner is simpler to build and reason about, but a carousel was worth the added interaction complexity because it let the highest-attention spot on the page carry more than one message. Given that a large share of visitors arrive on mobile via a shared link rather than desktop, I prioritized mobile-first responsive behavior for every interactive component over polishing desktop-only interactions.</p>]]></content>
        <category label="Frontend" term="Frontend"/>
        <category label="Landing Page" term="Landing Page"/>
        <category label="UI/UX" term="UI/UX"/>
        <category label="Responsive Design" term="Responsive Design"/>
    </entry>
    <entry>
        <title type="html"><![CDATA[【Development Journey in Rybit】 How Changing an Asset Quota Was Quietly Overloading Our Server]]></title>
        <id>https://www.yuchenghsu.dev/projects/eliminating-redundant-api-calls</id>
        <link href="https://www.yuchenghsu.dev/projects/eliminating-redundant-api-calls"/>
        <updated>2026-06-13T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[A small quota-field edit in Rybit’s B2B order management was silently firing a redundant API call on every change — how unstable Antd Form initialValues caused it, and how useWatch fixed it.]]></summary>
        <content type="html"><![CDATA[<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="background">Background<a href="https://www.yuchenghsu.dev/projects/eliminating-redundant-api-calls#background" class="hash-link" aria-label="Direct link to Background" title="Direct link to Background" translate="no">​</a></h2>
<p>This is one of the fixes I implemented in Rybit company and I want to share the story behind it. It’s a great example of how a small UI change can have hidden performance implications, and how understanding React's rendering behavior is key to optimizing user experience.</p>
<p><img decoding="async" loading="lazy" alt="Edit Assets Drawer: Adjusting asset quota and site details" src="https://www.yuchenghsu.dev/assets/images/1-c4c4fe0c28147f0b01b7be257b5b47c0.png" width="1327" height="417" class="img_ev3q"></p>
<p>In Rybit's B2B order management, the <code>Edit Assets Drawer</code> is one of the most frequently used workflows — operators use it to adjust how many assets a <code>Partner</code> needs for the <code>next subscription period</code> and which site each asset should transfer to.</p>
<!-- -->
<p><img decoding="async" loading="lazy" alt="Network panel: 3 legitimate triggers vs 1 unnecessary trigger on quota adjust" src="https://www.yuchenghsu.dev/assets/images/2-bcee3803c1f764ad5bbe6c0ea9c9a7ab.png" width="1149" height="526" class="img_ev3q"></p>
<p>I discovered that every time the operator adjusted the <code>"Number of assets for next period"</code> field, the system sent a POST API with all asset IDs to fetch each asset's current site — even if the operator didn't change any asset IDs. This was an <code>unnecessary API call</code> that could <code>overload our server</code> and <code>degrade user experience</code>. Even when server response was fast, the redundant API call caused a noticeable delay in the UI, making the application feel sluggish.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="problem">Problem<a href="https://www.yuchenghsu.dev/projects/eliminating-redundant-api-calls#problem" class="hash-link" aria-label="Direct link to Problem" title="Direct link to Problem" translate="no">​</a></h2>
<p><img decoding="async" loading="lazy" alt="Keep all 3 legitimate API triggers: open drawer, remove asset, edit asset ID" src="https://www.yuchenghsu.dev/assets/images/3-dbde334695c90e5f3b54f208786d788b.png" width="1866" height="736" class="img_ev3q"></p>
<p>These three actions require fetching asset site data, we want to keep those API calls:</p>
<ol>
<li class="">
<p>When the drawer opens, the system fetches each asset's current site to populate the "Site" column.</p>
</li>
<li class="">
<p>When the operator removes an asset, the system refetches sites for the remaining assets.</p>
</li>
<li class="">
<p>When the operator edits an asset ID and blurs the input, the system fetches the site for the new asset ID.</p>
</li>
</ol>
<p><img decoding="async" loading="lazy" alt="Unnecessary API trigger: adjusting quota number without changing asset IDs" src="https://www.yuchenghsu.dev/assets/images/4-d26d958f335920c281af516ba867b535.png" width="1362" height="424" class="img_ev3q"></p>
<ol start="4">
<li class="">When the operator adjusts the "Number of assets for next period" field, the system sends the same POST API call again — even if the operator doesn't change any asset IDs. <code>This is unnecessary and should be eliminated.</code></li>
</ol>
<table><thead><tr><th>Action</th><th>Expected to trigger api?</th></tr></thead><tbody><tr><td>Open the drawer</td><td>✓ Keep</td></tr><tr><td>Remove an asset from the table</td><td>✓ Keep</td></tr><tr><td>Edit an asset ID (onBlur)</td><td>✓ Keep</td></tr><tr><td><strong>Adjust "Number of assets for next period"</strong></td><td><strong>✗ Unnecessary — payload is identical, asset list unchanged</strong></td></tr></tbody></table>
<hr>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="root-cause-unstable-initialvalues-causes-child-remount">Root Cause: Unstable <code>initialValues</code> Causes Child Remount<a href="https://www.yuchenghsu.dev/projects/eliminating-redundant-api-calls#root-cause-unstable-initialvalues-causes-child-remount" class="hash-link" aria-label="Direct link to root-cause-unstable-initialvalues-causes-child-remount" title="Direct link to root-cause-unstable-initialvalues-causes-child-remount" translate="no">​</a></h2>
<p><strong>Original data flow:</strong></p>
<p><img decoding="async" loading="lazy" alt="Original data flow: state-driven initialValues causes Form to remount children on quota change" src="https://www.yuchenghsu.dev/assets/images/5-87ad882c3754cd2f790311e04df7415f.png" width="1414" height="672" class="img_ev3q"></p>
<p>When the user adjusts the quota, <code>AssetQuotaArea</code> called <code>updateEditAssetsDrawerFormValues</code>, writing the new value back into <code>Detail.tsx</code> state. State update → new <code>initialValues</code> object reference → <strong>Antd Form treats any new <code>initialValues</code> reference as a reset and remounts all child components</strong>.</p>
<blockquote>
<p>adjust quota → state update → <code>initialValues</code> reference changes → Form remounts children → <code>useEffectOnce</code> fires → API call</p>
</blockquote>
<p><code>useEffectOnce</code> is designed to fire only once per component lifetime — but the guarantee is per mount, not per lifetime. Each remount creates a fresh component instance, so the effect fires again as if it had never run.</p>
<p>Original code causing the redundant API call</p>
<div style="display:flex;gap:10px;align-items:flex-start"><img src="https://www.yuchenghsu.dev/assets/images/6-fe1233169d1a0afd6a0b52249814a202.png" alt="original-code-1" style="width:50%;height:auto"><div style="width:50%;height:auto;display:flex;flex-direction:column;gap:10px"><img src="https://www.yuchenghsu.dev/assets/images/7-65f465be2e72f2d0a983dc48241035eb.png" alt="original-code-2" style="height:auto"><img src="https://www.yuchenghsu.dev/assets/images/8-2c272c691c601400d47fd5b4941b1036.png" alt="original-code-3" style="height:auto"></div></div>
<hr>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="fix">Fix<a href="https://www.yuchenghsu.dev/projects/eliminating-redundant-api-calls#fix" class="hash-link" aria-label="Direct link to Fix" title="Direct link to Fix" translate="no">​</a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="overview">Overview<a href="https://www.yuchenghsu.dev/projects/eliminating-redundant-api-calls#overview" class="hash-link" aria-label="Direct link to Overview" title="Direct link to Overview" translate="no">​</a></h3>
<p><img decoding="async" loading="lazy" alt="Improved flow: adjusting quota updates form state directly, no remounts, no unnecessary API calls" src="https://www.yuchenghsu.dev/assets/images/9-e640e23437c42088361922d67a833b4d.png" width="1404" height="684" class="img_ev3q"></p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="1-stabilize-initialvalues-with-usememo">1. Stabilize <code>initialValues</code> with <code>useMemo</code><a href="https://www.yuchenghsu.dev/projects/eliminating-redundant-api-calls#1-stabilize-initialvalues-with-usememo" class="hash-link" aria-label="Direct link to 1-stabilize-initialvalues-with-usememo" title="Direct link to 1-stabilize-initialvalues-with-usememo" translate="no">​</a></h3>
<p>Replace state-driven <code>initialValues</code> with a <code>const</code> computed from server data. Reference only changes when the dependency (order data) changes, so the Form doesn't remount on quota adjustments:</p>
<p><img decoding="async" loading="lazy" alt="Stabilized initialValues: useMemo computes a stable reference that only changes when order data changes" src="https://www.yuchenghsu.dev/assets/images/10-0f478bb7ba1bad316f42077db48ff94d.png" width="717" height="460" class="img_ev3q"></p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="2-move-quota-updates-inside-the-form-usewatch--setfieldvalue">2. Move quota updates inside the Form: <code>useWatch</code> + <code>setFieldValue</code><a href="https://www.yuchenghsu.dev/projects/eliminating-redundant-api-calls#2-move-quota-updates-inside-the-form-usewatch--setfieldvalue" class="hash-link" aria-label="Direct link to 2-move-quota-updates-inside-the-form-usewatch--setfieldvalue" title="Direct link to 2-move-quota-updates-inside-the-form-usewatch--setfieldvalue" translate="no">​</a></h3>
<p>Convert <code>AssetQuotaArea</code> to a controlled component under <code>&lt;FormItem&gt;</code>. Remove <code>updateEditAssetsDrawerFormValues</code>. Detect quota changes with <code>useWatch</code>, update disabled state with <code>setFieldValue</code>:</p>
<p><img decoding="async" loading="lazy" alt="Move quota state inside Form: useWatch detects changes, setFieldValue updates dependent field state, no external state or remounts needed" src="https://www.yuchenghsu.dev/assets/images/11-4fd1dfea4be8fe2f8f4f387fd67dcea6.png" width="855" height="468" class="img_ev3q"></p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="3-detect-the-formnextassetquota-value-with-usewatch">3. Detect the <code>formNextAssetQuota</code> value with <code>useWatch</code><a href="https://www.yuchenghsu.dev/projects/eliminating-redundant-api-calls#3-detect-the-formnextassetquota-value-with-usewatch" class="hash-link" aria-label="Direct link to 3-detect-the-formnextassetquota-value-with-usewatch" title="Direct link to 3-detect-the-formnextassetquota-value-with-usewatch" translate="no">​</a></h3>
<div class="language-ts codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#F8F8F2;--prism-background-color:#282A36"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-ts codeBlock_bY9V thin-scrollbar" style="color:#F8F8F2;background-color:#282A36"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#F8F8F2"><span class="token keyword" style="color:rgb(189, 147, 249);font-style:italic">const</span><span class="token plain"> formNextAssetQuota </span><span class="token operator">=</span><span class="token plain"> </span><span class="token generic-function function" style="color:rgb(80, 250, 123)">useWatch</span><span class="token generic-function generic class-name operator">&lt;</span><span class="token generic-function generic class-name builtin" style="color:rgb(189, 147, 249)">number</span><span class="token generic-function generic class-name operator">&gt;</span><span class="token punctuation" style="color:rgb(248, 248, 242)">(</span><span class="token plain">editAssetsDrawerFormKeys</span><span class="token punctuation" style="color:rgb(248, 248, 242)">.</span><span class="token plain">nextAssetQuota</span><span class="token punctuation" style="color:rgb(248, 248, 242)">,</span><span class="token plain"> form</span><span class="token punctuation" style="color:rgb(248, 248, 242)">)</span><span class="token plain"> </span><span class="token operator">??</span><span class="token plain"> </span><span class="token number">0</span><span class="token punctuation" style="color:rgb(248, 248, 242)">;</span><br></div></code></pre></div></div>
<p><code>useWatch</code> subscribes to <code>formNextAssetQuota</code>, so when the operator adjusts the quota, we can conditionally disable or enable the site inputs without causing any remounts or API calls.</p>
<p><img decoding="async" loading="lazy" alt="Disable site input when quota is zero: useWatch observes quota changes and conditionally disables site inputs to prevent invalid edits" src="https://www.yuchenghsu.dev/assets/images/12-830fac3922f50e8670ecf95ef3cfd663.png" width="756" height="514" class="img_ev3q"></p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="4-sync-editableprotables-editablekeys">4. Sync <code>EditableProTable</code>'s <code>editableKeys</code><a href="https://www.yuchenghsu.dev/projects/eliminating-redundant-api-calls#4-sync-editableprotables-editablekeys" class="hash-link" aria-label="Direct link to 4-sync-editableprotables-editablekeys" title="Direct link to 4-sync-editableprotables-editablekeys" translate="no">​</a></h3>
<p>After adjusting <code>Number of assets for next period</code>, update new datasoure and set <code>new newEditableKeys</code> for EditableProTable:</p>
<p><img decoding="async" loading="lazy" alt="Sync editableKeys with dataSource: ensure input display state matches the current data, especially after edits or deletions" src="https://www.yuchenghsu.dev/assets/images/13-78fdf21d6857e5d2bbe032f7e6f09d40.png" width="800" height="410" class="img_ev3q"></p>
<hr>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="result">Result<a href="https://www.yuchenghsu.dev/projects/eliminating-redundant-api-calls#result" class="hash-link" aria-label="Direct link to Result" title="Direct link to Result" translate="no">​</a></h2>
<table><thead><tr><th>Action</th><th>Before</th><th>After</th></tr></thead><tbody><tr><td>Open the drawer</td><td>Fires ✓</td><td>Fires ✓</td></tr><tr><td>Remove an asset</td><td>Fires ✓</td><td>Fires ✓</td></tr><tr><td>Edit an asset ID (onBlur)</td><td>Fires ✓</td><td>Fires ✓</td></tr><tr><td><strong>Adjust the quota number</strong></td><td><strong>Fires (unnecessary)</strong></td><td><strong>Does not fire ✓</strong></td></tr></tbody></table>
<hr>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="key-takeaways">Key Takeaways<a href="https://www.yuchenghsu.dev/projects/eliminating-redundant-api-calls#key-takeaways" class="hash-link" aria-label="Direct link to Key Takeaways" title="Direct link to Key Takeaways" translate="no">​</a></h2>
<ol>
<li class=""><strong>Antd Form <code>initialValues</code> is for initialization only.</strong> Any reference change triggers child remounts — don't use it as a live state channel.</li>
<li class=""><strong>For cross-field interactions inside a Form, keep state inside the Form.</strong> Use <code>useWatch</code> to observe and <code>setFieldValue</code> to update, rather than lifting to external <code>useState</code> and feeding back through <code>initialValues</code>.</li>
<li class=""><strong><code>EditableProTable</code>'s <code>editableKeys</code> must stay in sync with datasource.</strong> It controls the input display state of each row independently from the data itself.</li>
</ol>]]></content>
        <category label="B2B" term="B2B"/>
        <category label="API" term="API"/>
        <category label="Performance" term="Performance"/>
    </entry>
</feed>