<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Zhou Yi&apos;an&apos;s blog</title><description>Notes on builds, shipping and running small products</description><link>https://dev-demo-en.zanesites.com/</link><item><title>Year three of going indie: I deleted 60% of my features</title><link>https://dev-demo-en.zanesites.com/blog/indie-year-three/</link><guid isPermaLink="true">https://dev-demo-en.zanesites.com/blog/indie-year-three/</guid><description>I went back through three years of product data and found most features were used a handful of times a year. Here&apos;s how the cleanup went.</description><pubDate>Sun, 02 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This summer I did something that felt wrong: I deleted 60% of Beacon&apos;s features.&lt;/p&gt;
&lt;h2&gt;How it started&lt;/h2&gt;
&lt;p&gt;One night I exported a year of usage logs and counted clicks per feature. The result was quiet. The bottom 60% of features, added together, made up less than 5% of all usage. I had spent a week or two building each of them, and every one was quietly eating maintenance time.&lt;/p&gt;
&lt;h2&gt;How I cut them&lt;/h2&gt;
&lt;p&gt;Deleting is harder than adding, so I set three rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Hide the entry point first, keep the code.&lt;/strong&gt; If nobody asks about it for two weeks, then I touch the code.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Old users can always export their data.&lt;/strong&gt; Removing a feature should never make someone feel abandoned.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Write one sentence explaining each removal.&lt;/strong&gt; If I can&apos;t explain why, I haven&apos;t thought it through, so it stays for now.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;What happened next&lt;/h2&gt;
&lt;p&gt;Pages loaded twice as fast, support email dropped by a third, and the surprise was that renewals went up by two points. My theory is that the product finally looked like it was doing one thing.&lt;/p&gt;
&lt;p&gt;There were regrets too. One customer wrote in asking where a small feature had gone, so I brought it back, just for him. That&apos;s fine. The nice thing about being independent is that you&apos;re allowed to change your mind for a single person.&lt;/p&gt;
</content:encoded></item><item><title>Offline natural-language bookkeeping in a WeChat mini program</title><link>https://dev-demo-en.zanesites.com/blog/offline-nlp-bookkeeping/</link><guid isPermaLink="true">https://dev-demo-en.zanesites.com/blog/offline-nlp-bookkeeping/</guid><description>Type &quot;lunch 28 noodles&quot; and it&apos;s logged, even with no signal. How the parser behind Shiguang Ledger works.</description><pubDate>Thu, 09 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The most loved feature in Shiguang Ledger is one-sentence bookkeeping: type &quot;taxi 35 yesterday&quot; and it figures out the date, category and amount. Plenty of people assume a large language model is behind it. It isn&apos;t. It&apos;s a rule-based parser of about 200 lines that runs on the phone, fully offline.&lt;/p&gt;
&lt;h2&gt;Why not an LLM&lt;/h2&gt;
&lt;p&gt;Logging an expense is frequent and low-stakes. If a call takes half a second longer, costs a fraction of a cent, or needs a connection, people get annoyed. Financial notes are also sensitive, so I&apos;d rather they never leave the device.&lt;/p&gt;
&lt;h2&gt;Three layers&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Amount.&lt;/strong&gt; Find the number that looks most like a price, and cope with forms like &quot;28.5&quot;, &quot;twenty-eight&quot; written out, or &quot;two hundred bucks&quot;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Date.&lt;/strong&gt; Turn &quot;yesterday&quot;, &quot;last Friday&quot; or &quot;the night before last&quot; into a real date, defaulting to today.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Category.&lt;/strong&gt; A word list of just over 300 entries, matched longest-first. Anything unmatched goes under &quot;Other&quot;, and the parser remembers how the user files it later.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;export function parse(input: string, now = new Date()) {
  const amount = extractAmount(input);
  const date = extractDate(input, now);
  const category = matchCategory(input) ?? learned.get(input) ?? &quot;Other&quot;;
  return { amount, date, category, note: input.trim() };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The biggest lesson&lt;/h2&gt;
&lt;p&gt;Learning matters more than recognizing. Once someone corrects a category, the same phrase is right the next time. After three months accuracy went from 82% to 94%, and the credit goes to users&apos; own habits rather than a cleverer algorithm.&lt;/p&gt;
&lt;p&gt;Mini programs have strict package size limits, and the whole parser minifies to under 8KB. For me that is its best feature.&lt;/p&gt;
</content:encoded></item><item><title>SQLite is enough: notes on single-file deployment</title><link>https://dev-demo-en.zanesites.com/blog/sqlite-is-enough/</link><guid isPermaLink="true">https://dev-demo-en.zanesites.com/blog/sqlite-is-enough/</guid><description>Yehang is an RSS reader built from one binary and one SQLite file. Why I picked SQLite, and how I keep it steady on a tiny server.</description><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;The goal for Yehang was simple: let someone run an RSS reader on the cheapest server they can find, with one command. No Redis, no Postgres, no message queue. So from day one I chose SQLite.&lt;/p&gt;
&lt;h2&gt;A few settings you should always turn on&lt;/h2&gt;
&lt;p&gt;SQLite&apos;s defaults aren&apos;t made for web services. These lines are close to mandatory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;WAL mode lets readers and writers stay out of each other&apos;s way. That is what lets Yehang fetch feeds in the background while you read in the foreground.&lt;/p&gt;
&lt;h2&gt;Backups are easy&lt;/h2&gt;
&lt;p&gt;People worry that a single file is unsafe. Yehang runs a scheduled job that uses &lt;code&gt;VACUUM INTO&lt;/code&gt; to write a consistent backup every day and keeps the last seven. Restoring means copying a file back. Compared with running a database server, there is far less that can go wrong.&lt;/p&gt;
&lt;h2&gt;Where it stops&lt;/h2&gt;
&lt;p&gt;I drew a line for myself: one user, or a small group of a few dozen, with low write concurrency. If someone ever needs to scale out across instances, Yehang will have become a different product, and I can switch then.&lt;/p&gt;
&lt;p&gt;It has run in under 40MB of memory for a year without a single data problem. For a side project, that&apos;s enough.&lt;/p&gt;
</content:encoded></item><item><title>Taking tidemark&apos;s build from 38 seconds to 4</title><link>https://dev-demo-en.zanesites.com/blog/tidemark-build-4s/</link><guid isPermaLink="true">https://dev-demo-en.zanesites.com/blog/tidemark-build-4s/</guid><description>Why one open-source CLI took long enough to make me want to shut the laptop, and the three steps that got it down to 4 seconds.</description><pubDate>Fri, 18 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;tidemark is a small tool I maintain that turns Markdown into HTML you can paste straight into WeChat articles. There isn&apos;t much code, but the build took 38 seconds in CI, and even a one-line theme tweak meant waiting over ten seconds locally. For a tool whose whole job is tweaking styles, that feedback loop is fatal.&lt;/p&gt;
&lt;h2&gt;Measure first&lt;/h2&gt;
&lt;p&gt;I didn&apos;t reach for a new bundler. I put timestamps around each stage first:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;time pnpm build --reporter=verbose
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The result was humbling. Actual compilation took only 6 seconds. The rest went to two things: &lt;strong&gt;re-parsing the CSS for all 12 themes on every run&lt;/strong&gt;, and &lt;strong&gt;bundling test snapshots into the output&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;Three fixes&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Precompile the themes.&lt;/strong&gt; Theme CSS is compiled to JSON before publishing, and at runtime it&apos;s only read. That saved 19 seconds.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stop bundling what doesn&apos;t belong.&lt;/strong&gt; Snapshots and fixtures were removed from the build entry, and the output shrank by 70%.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Incremental caching.&lt;/strong&gt; The cache key is a hash of file contents, so unchanged themes are reused, and local hot reload dropped to around 300 milliseconds.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;A small lesson&lt;/h2&gt;
&lt;p&gt;Looking back, the biggest win came from doing less rather than doing things faster. Most slow builds repeat work that never needed repeating. Next time a build feels slow, don&apos;t swap tools yet. Look at the timestamps first.&lt;/p&gt;
&lt;p&gt;CI now holds steady at about 4 seconds, and I can finally finish tweaking a theme before my coffee goes cold.&lt;/p&gt;
</content:encoded></item><item><title>Five gotchas from running multi-region probes on Workers</title><link>https://dev-demo-en.zanesites.com/blog/workers-multi-region-probe/</link><guid isPermaLink="true">https://dev-demo-en.zanesites.com/blog/workers-multi-region-probe/</guid><description>Beacon&apos;s probe nodes run on Cloudflare Workers. Here are five problems from a year in production, and how I solved each.</description><pubDate>Sun, 21 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Beacon needs to check customers&apos; endpoints from several regions at once, and Workers is a natural fit: deploy once, get nodes everywhere. But a natural fit isn&apos;t the same as a smooth one.&lt;/p&gt;
&lt;h2&gt;Gotcha one: probes get blocked as bots&lt;/h2&gt;
&lt;p&gt;Many customers have a WAF in front of their API. We now send probes with a fixed &lt;code&gt;User-Agent&lt;/code&gt; and link to a public page listing our IP ranges, so customers can allowlist us in one click.&lt;/p&gt;
&lt;h2&gt;Gotcha two: CPU time is not wall-clock time&lt;/h2&gt;
&lt;p&gt;A probe may wait 8 seconds for a response, but that wait doesn&apos;t count as CPU time. Parsing a large response body does. So we only read the headers and the first 4KB, which is plenty to judge the status.&lt;/p&gt;
&lt;h2&gt;Gotcha three: don&apos;t trust local clocks&lt;/h2&gt;
&lt;p&gt;When results from several regions are merged, node clocks drift slightly. We sort by the time the central node received each result, and use node timestamps only to compute durations.&lt;/p&gt;
&lt;h2&gt;Gotcha four: retries need a cap and a delay&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;const delays = [0, 1500, 4000];
for (const delay of delays) {
  await sleep(delay);
  const res = await probe(target);
  if (res.ok) return res;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Three failures in a row count as a real outage. Otherwise a single network blip wakes a customer at 3 a.m.&lt;/p&gt;
&lt;h2&gt;Gotcha five: alert fatigue&lt;/h2&gt;
&lt;p&gt;If you notify about the same incident every minute, people mute you quickly. Now it&apos;s one message when an incident starts, one reminder after 30 minutes, and one when it&apos;s resolved. That&apos;s all.&lt;/p&gt;
&lt;p&gt;The biggest lesson from building a monitoring product: &lt;strong&gt;bothering users less is harder than detecting more problems.&lt;/strong&gt;&lt;/p&gt;
</content:encoded></item></channel></rss>