Actaurus返回 Actaurus

Case Study · 2026

DailyMove

A home movement app built around a constraint most fitness apps ignore: the person using it does not want to be disciplined.

Behavioral product design · Constraint-driven scope · Offline architecture

Static site · Cloudflare Pages · Works offline · Bilingual · No backend


The brief

Build something that makes a body more capable, without ever making a person feel guilty.

Most training apps optimise for a number on a scale and an unbroken row of days on a calendar. This one was for someone who wanted neither — and who would quietly stop opening anything that turned a missed week into a failure.

The goal was stated as a capability rather than a shape:

Legs that can stand you up. Hips that can carry the shopping. A core that keeps you steady. Lungs with room to spare. For the next thirty years — not the next twelve weeks.

Which turns into a short, unusually strict specification:


Decisions

Most of the work was subtraction — which is exactly the part a running app cannot show you.

Removed

The streak counter

The first build had a flame and N day streak in the top-right corner. That is the default shape for this category; it arrives without anyone deciding on it.

It is also a guilt mechanism. The entire psychological force of a streak comes from it hurting to break — which is the precise opposite of what this product promises. A week off is fine. You do not owe it back.

Replaced with a flat statement of fact: 12 days active, last 30. It moves down when you move less, and it never accuses you of anything.

Changed

The complete button is never disabled

Originally you had to tick all eleven exercises before Complete would light up. So: do seven, decide that is enough, and the app informs you that you did not finish.

That contradicts the only instruction the programme actually gives — stop when it is enough. A rule the interface refuses to honour is not a rule.

The button is now always live and its label follows you: I moved today at zero, Log these 4 at four, Did all of it at eleven.

Fixed

The log records what you actually did

Easy to miss, and worse than the button: the original saved the whole session template regardless of what you ticked. Seven exercises done, eleven recorded.

The history looked better than the truth. A log that flatters you is not a log — it is decoration. It now stores only what was checked, alongside what was offered.

Added

The metric is how the body felt

After a session the app asks exactly one question: easy / just right / a bit hard.

Over months that produces the thing worth having — the same session drifting from a bit hard toward just right. It is a personal record no scale can report, and it is the only progress chart in the app.

Added

A learning path that is not training

The app quietly assumed you already knew how to perform eleven movements. The person it was built for had never done any of them.

So: six movements, ordered by risk and foundation rather than by training order, one at a time, each with a physiotherapist video and the two or three cues that actually matter. Marking one learned means you know roughly how it goes — explicitly not that you are good at it.


Under the hood

Two problems where the obvious implementation was quietly wrong.

Bug

Every evening session was filed under tomorrow

Dates came from toISOString(), which returns UTC. On the West Coast that means any session finished after 5pm was recorded as the following day. The calendar, the counters, the already logged today check — all wrong, and all silent.

Dates are now derived from local components. Existing records were repaired rather than discarded: each entry already carried a correct timestamp, so the true local date could be recomputed from it during migration.

Architecture

A cache strategy that did not depend on remembering

The app works offline, and the first service worker served everything cache-first. But its filenames carry no content hash — app.js is always app.js — so the browser has no way to know it changed. Shipping an update required manually bumping a version constant. A mechanism that depends on a human remembering is a mechanism that will fail.

Now HTML, CSS and JavaScript are network-first with a three-second fallback to cache; images and fonts stay cache-first. JavaScript has to be network-first too, because the script and the markup are coupled — the element IDs the script looks up are written in the HTML, so a new page paired with an old script renders nothing at all.

Production systems solve this with hashed filenames and atomic updates. Without a build step, the honest alternative is to fetch the coupled files together and accept the few milliseconds.

Refactor

One source of truth

The exercise library existed twice — once as hand-written markup, once as a JavaScript array. They had already drifted: one exercise's identifier did not match between the two, so its jump to details link silently never rendered at all.

Thirteen exercises now live in one array and the markup is generated from it. 489 lines of duplicated HTML went away, and that entire class of bug went with them.


How this was built

I defined the product brief, behavioral rules, edge cases, and acceptance criteria, and directed the work across several development sessions. An AI coding agent produced the implementation; I reviewed the resulting behavior, tested edge cases, and drove revisions. The source is private.

The brief said a missed week is fine. The first build still shipped a streak counter — the default shape of the category arrives whether or not anyone decides on it. Reconciling those two things was most of the work.

I caught Chinese text left in the English interface from a screenshot, which became a one-directional guard in the tests.

The guard is one-directional rather than symmetric because the Chinese interface is a workshop I use daily, while the English one is a shop window a stranger sees once. The two do not deserve the same standard — and that same distinction is what sent the demo to English by default.

Where it is now

In daily use. Static site, no backend, no accounts — the log lives in the browser. Which is also why the public link can simply be the real app: every visitor gets their own empty copy, and there is nothing of mine on a server to separate out.

The demo link seeds three weeks of fictional history so the page has something to show. It is stored under its own namespace and never touches a real log.

Case Study · 2026

DailyMove

一个居家训练应用。它围绕一条大多数健身产品会忽略的约束而设计:使用它的人,不想被督促。

行为产品设计 · 约束驱动的范围 · 离线架构

静态站点 · Cloudflare Pages · 可离线 · 双语 · 无后端


命题

做一个能让身体更有能力的东西,而全程不让人产生内疚。

大多数训练应用优化的是体重秤上的数字,和日历上不能断的一排天数。而这一个是为一个两者都不想要的人做的——她会在某个产品让「漏了一周」变成一次失败之后,安静地不再打开它。

目标被表述为一种能力,而不是一个形状:

腿有力量把你撑起来。髋能帮你提东西。核心能稳住身体。心肺有余量。为的是接下来的三十年,不是接下来的十二周。

翻译成规格,是一份很短但相当严格的清单:


取舍

大部分工作是减法——而减法恰恰是一个运行中的应用无法展示的部分。

删掉

连胜计数器

第一版的右上角有一个火焰图标和「N 天连续」。这是这类产品的默认形状——它会自己出现,不需要任何人决定要它。

它同时是一个内疚机制。连胜的全部心理动力来自「断了会痛」,而这恰好是这个产品承诺的反面。漏一周没关系,你不欠它。

换成了一句事实陈述:最近 30 天,动了 12 天。动得少它就往下走,但它从不指责你。

改掉

「完成」按钮永远可以点

原本必须把 11 个动作全部勾完,「完成」才会亮。于是:做了 7 个,觉得够了,应用告诉你——你没有完成。

这与这套方案唯一给出的指令直接冲突:觉得够了就停。一条界面拒绝兑现的原则,就不是原则。

现在按钮永远是活的,文案跟着你走:0 个时是「我今天动了」, 4 个时是「记下这 4 个动作」,11 个时是「全部做完了」。

修正

记录的是你真正做了的

比按钮更容易被忽略,也更严重:原来无论你勾了几个,存进去的都是整份清单。做了 7 个,记下 11 个。

历史比真相好看。一份讨好你的记录不是记录,是装饰。现在它只存实际勾选的动作,同时保留「当时提供了几个」。

新增

真正追踪的指标是身体的感受

一次训练之后,应用只问一个问题:轻松 / 刚好 / 有点吃力。

几个月之后,这会长出真正值得拥有的东西——同一个版本的训练,从「有点吃力」慢慢漂移到「刚好」。这是体重秤报不出来的个人纪录,也是整个应用里唯一的进度图表。

新增

一条不是训练的学习路径

应用默认你已经会做那 11 个动作。而它服务的这个人,一个都没做过。

于是:六个动作,按风险和基础性排序,而不是按训练顺序,一次只学一个,每个配一段物理治疗师的教学视频和两三条真正要紧的要点。把一个动作标记为「学过了」意味着你大概知道它怎么做——明确地不意味着你做得好。


技术细节

两处「显而易见的写法」其实是错的。

Bug

每一次晚上的训练都被记到了第二天

日期来自 toISOString(),而它返回的是 UTC。在美国西岸,这意味着任何下午 5 点之后完成的训练都会被记成第二天。日历、计数、「今天是否已记录」的判断——全都是错的,而且毫无提示。

现在日期从本地的年月日直接取。已有记录是被修复而不是丢弃的:每条记录本来就带着一个正确的时间戳,迁移时据此重新算出真正的本地日期。

架构

一套不依赖「记得」的缓存策略

这个应用可以离线使用,而第一版 Service Worker 对所有文件都是缓存优先。但它的文件名不带内容哈希——app.js 永远叫 app.js——浏览器无从知道它变了。于是每次发版都要手动改一个版本常量。一个依赖人记得去做的机制,迟早会失效。

现在 HTML、CSS 和 JavaScript 走网络优先、3 秒超时回落缓存,图片和字体保持缓存优先。JavaScript 也必须网络优先,因为脚本和标记是耦合的——脚本要找的那些元素 id 就写在 HTML 里,新页面配上旧脚本会什么都渲染不出来。

工业级系统用哈希文件名和原子更新解决这件事。在没有构建流程的前提下,诚实的替代方案是让耦合的文件一起走网络,并接受那几十毫秒。

重构

单一数据源

动作库存在了两份——一份是手写的标记,一份是 JavaScript 数组。它们已经漂移了:有一个动作的标识符在两处对不上,导致它的「跳转到详情」按钮从来没有被渲染出来过,而且毫无征兆。

现在 13 个动作只存在于一个数组里,标记由它生成。 489 行重复的 HTML 消失了,这一整类 bug 也跟着消失了。


这是怎么做出来的

我定义了产品命题、行为规则、边界情况和验收标准,并在几次开发会话中主导整个过程。代码实现由 AI coding agent 生成;我审查实际行为、测试边界情况并推动修订。源码不公开。

brief 里写着漏一周没关系,而第一版仍然带着一个连胜计数器——这个品类的默认形状会自己出现,不需要任何人决定要它。把这两件事对齐,是这个项目大部分的工作。

英文界面里残留的中文,是我从一张截图里发现的,后来变成了测试里的一道单向守卫。

这道守卫之所以是单向而不是对称的,是因为中文界面是我每天用的工作间,英文界面是陌生人看一次的橱窗,两者不该适用同一套标准。这个区别,也是演示模式默认英文的由来。

现状

在日常使用中。静态站点,没有后端,没有账号——记录存在浏览器里。这也正是为什么公开链接可以直接就是真正的应用:每个访客拿到的都是他自己的空白副本,服务器上没有任何属于我的东西需要分离。

演示链接会种入三周虚构的历史,好让这个页面有东西可看。它存在独立的命名空间下,永远不会碰到真实记录。

Built by Emilie ZhangBuilt by Emilie Zhang actaurus.com