Easy-to-manage touch screens
무료 오픈소스 터치스크린으로 USB 설정 가이드, 드래그‑드롭 편집기, 로컬 제어를 제공하며 Home Assistant용으로 제작되었습니다
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기Today's Taiwan wholesale vegetable & fruit prices, at a glance — plus an estimated traditional-market retail band so you can sanity-check a stall's price while standing in front of it.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Today's Taiwan wholesale vegetable & fruit prices, at a glance — plus an estimated traditional-market retail band so you can sanity-check a stall's price while standing in front of it. Open the app and immediately see a calm, MUJI-inspired board of the produce people buy most — green when cheaper, clay when pricier, priced per catty (台斤). Three questions the board answers without typing: what does this cost today, is that cheap for this crop (against its own recent norm), and which variety am I actually being quoted when one crop trades at two very different prices.README 전문 보기 ↗
Today's Taiwan wholesale vegetable & fruit prices, at a glance — plus an estimated traditional-market retail band so you can sanity-check a stall's price while standing in front of it. Open the app and immediately see a calm, MUJI-inspired board of the produce people buy most — green when cheaper, clay when pricier, priced per catty (台斤).
Three questions the board answers without typing: what does this cost today, is that cheap for this crop (against its own recent norm), and which variety am I actually being quoted when one crop trades at two very different prices.
Note on language: the UI is in Traditional Chinese (its audience); code, comments, and this documentation are in English.
元/台斤 (the market convention;
1 catty = 600 g). The MOA source is 元/公斤, converted on the client (×0.6).約 44 元/台斤) — that is what a
shopper transacts at. The measured wholesale closing average and the reference
band sit under it (市場 35–55・批發 14.6), and the drawer shows the markup
the estimate adds. The estimate is never presented as a quoted price — see §4.
Comparisons ranked for the shopper (「同類更划算的選擇」) use the same market
basis, because a crop that is cheaper at auction can be dearer at the stall.↓ 7.0% tracks the wholesale average, the
only measured series, and is labelled 批發較昨日 in the drawer. The band is
rounded to NT$5, so it would read as frozen against small real moves.↓ 便宜了 (cheaper), clay ↑ 變貴了 (pricier),
grey → 持平 (flat), compared with the previous trading day. Arrows + text back
up the colour so it never relies on colour alone.↓ 7.0% compares with the previous trading day. The sage badge
「比近月便宜 23%」 compares today's wholesale price with the median of that
crop's own last 28 trading days, and 「划算優先」 sorts the board by it so the
day's real bargains sit on top. Only the discount side gets a badge — the
change column already covers the pricier side. See §5.#/i/高麗菜, #/?f=葉菜類&sort=value),
because the way this board spreads is one shopper pasting today's price into
a LINE group — and a link that opens the home screen instead of that price is
not worth pasting. The drawer is a history entry too, so the Android back key
closes it rather than leaving the app; a link to a crop that is out of season
today says 「今日無交易資料」 and lands on the board instead of erroring.
A typed word reaches the hash once its debounce settles — one word, not one
keystroke, and with replaceState, so the board you are looking at is always
the board your URL describes without typing filling up the back button.MOA open-data API ──▶ GAS refresh (4-hourly trigger) ──▶ CacheService + chunked
(single-date and board → the day's prices ScriptProperties
range queries) history → 28 trading days → baseline (§5)
│
GET /exec │
▼
Frontend (GitHub Pages) ◀── validate ◀── GitHub Actions (deploy-pages)
data/board.json, published runs when a crawl lands (the
inside the bundle's own artifact backend dispatches it), on
│ push, and 2-hourly as fallback
▼
Browser: localStorage (paints first) ──▶ data/board.json ──▶ GAS /exec
authoritative only when the mirror
while < 6 h old is stale or missing
Data source: Taiwan MOA "Agricultural Products Wholesale Market Transactions"
open data, no API key. https://data.moa.gov.tw/api/v1/AgriProductsTransType/
(ROC-calendar dates, e.g. 115.08.26; prices in 元/公斤).
Backend (backend/*.gs): one file per domain — Config (every tuneable
constant plus the board definition), RetailCalibration (generated by
tools/calibrate: the three retail-markup tables and nothing else, §4),
WebApp (the doGet router),
Board, Aggregate, History, Alerts, Moa, Search. Apps Script merges
them into one global scope, so the split is organisational, not architectural;
no ordering config is needed or used, because no top-level initialiser here
depends on another file — a test enforces that, so load order stays
irrelevant rather than becoming something this repo has to pin.
refreshBoardCache() — run by a 4-hourly time-driven trigger; crawls the
board items with UrlFetchApp.fetchAll (concurrent, batched at 13), stores
the board in CacheService plus durable ScriptProperties, and appends the
day's price to the per-item history behind the baseline (§5) — no extra MOA
requests for it.doGet (default) — serves the stored board instantly. It never crawls
synchronously (a cold crawl exceeds the Web App response window and 404s).
A board past BOARD_MAX_AGE_MS (6 h) is still served, but the request also
queues a background rebuild, so a dead trigger self-heals instead of
freezing the app on an old date. The threshold deliberately sits above
the 4 h cadence plus the crawl: when the two were equal, a healthy board
reported itself stale in the minutes before every scheduled run.doGet?action=search&query=<name> — the board, then a crop-name index,
then a live query; see "The search path" below. Accepts Traditional and
simplified Chinese, common English and colloquial terms, and price
questions like 「高麗菜多少錢」.doGet?action=getTrend&cropName=<name>&days=7 — one MOA range query
(days clamped to 14), cached per crop per day and shared by every visitor,
so drawer traffic stops scaling with users.doGet?action=warm — queues a rebuild and returns immediately. The crawl
takes minutes, so it runs in a one-off trigger rather than on the request.
force=1 jumps the once-per-15-minutes lock and is honoured only with the
operator token (below); anyone else gets the plain, lock-bounded path.Both caused wrong numbers on the live board before being fixed; frontend/backendCode.test.ts
locks them down.
CropName matches as a substring of <root>-<variety>, not as a prefix.
Querying 蔥 also returns 洋蔥-本產 and 大蒜-蔥蒜; 蘿蔔 also returns
胡蘿蔔-清洗; 胡瓜 also returns 花胡瓜 (小黃瓜); 薑 also returns
薑荷花 (an ornamental flower). Every board item therefore declares the exact
official root plus an optional variety / excludes, and rows are filtered
locally by selectRows. Several everyday names are not the MOA root name —
地瓜葉 = 甘薯葉, 山藥 = 薯蕷, 蒲瓜 = 扁蒲, 佛手瓜 = 隼人瓜,
香瓜 = 甜瓜, 木耳 = 濕木耳, 金針菇 = 金絲菇, 檸檬 = 雜柑-*檸檬*.CropName: "休市"
and zeroed price/quantity. Counting those as "this date has data" makes the
build pick a date on which every item aggregates to nothing, so the board
silently stops updating. isTradingDate() therefore requires real traded
volume (PROBE_MIN_VOLUME), not a non-empty response.Two further robustness measures: fetchRootRows() retries roots that came back
empty once, because a throttled 13-request batch used to drop a whole slice of
the board (including 高麗菜) without any error; and writeChunkedProp() splits
the ~34 KB board — and the price history — across numbered ScriptProperties
chunks, since a single property value is capped at 9 KB — cut at 8000 UTF-8
bytes, not characters, since a crop's name takes three bytes a character.
Search is the one place a shopper can ask for something the board does not carry, and it used to treat every such ask the same way: probe the trading dates (up to 16 fetches), query MOA for today, query MOA for the previous day, then answer 「查無此品項」 — 8–31 s to say "that does not exist". 「iphone」 and 「蓮子」 cost exactly the same.
Three steps now, cheapest first:
| Step | Cost | Ends here |
|---|---|---|
| 1. The served board | a cache read | Most queries. The board carries ~90 items and is what people search for |
2. The CROP_CATALOG gate | zero MOA traffic | Everything MOA has never published: typos, gibberish, non-produce, brand names |
| 3. A live query per matching root | 1 crawl per root per hour, shared | Real crops outside the board (蓮子, 山蘇) |
Step 2 is the whole win. backend/CropCatalog.gs lists every MOA root crop
name that really traded in the last 400 days (185 of them, generated — §7;
MOA's ~600 daily crop names are mostly cut flowers and <root>-<variety>
spellings of one root). If a query is a substring of no root, and no root is a
substring of it, the crop cannot be in the feed, so the answer is immediate and
free: 「查無此品項」 plus up to three suggestions — catalogue roots one edit away
when the query looks like a typo (「甘籃」 → 甘藍), otherwise the board's biggest
sellers.
The gate expires. The crawl samples 100 of those 400 days, so a crop whose
entire season falls between two samples can be missing, and MOA does publish
new roots. Refusing on a fresh index is the feature; refusing forever on an
index nobody re-crawled would be a wrong answer no deploy fixes. So the
generated file carries CROP_CATALOG_CRAWLED_AT, and past
CATALOG_MAX_AGE_DAYS (180 — two quarters, so the quarterly refresh can slip
once) the gate opens and a miss costs what it always used to: slower, never
wrong.
What survives the gate goes to at most SEARCH_MAX_ROOTS (3) live queries.
Each root is aggregated under the board's own definitions, so a live answer
for a root the board splits by variety (青椒 vs 甜椒, both root 甜椒) comes back
split the same way instead of as one blended average the board never shows.
Results are cached per root for an hour under
veggie_search_<root>_<trading date>, the same policy the trend uses — so a
burst of the same miss costs one crawl for everybody. The key carries the
trading date, not today's: a miss cached on a Saturday would otherwise keep
answering 「查無此品項」 for an hour after Monday's prices published.
One query normaliser, two implementations, one fixture.
normalizeQuery() (in backend/Search.gs and frontend/src/lib/normalizeQuery.ts)
folds a query — trim, full-width → half-width, collapse whitespace, lower-case —
then de-simplifies it against a ~60-character produce table (簡體 丽/萝/葱/姜,
not OpenCC and not a full conversion), strips price-question suffixes
(「菜價」「多少錢」「今天」, longest first, repeatedly, never down to nothing) and
finally resolves the alias table once. Alias resolution comes last so the
output is the most MOA-resolvable form of the query: that is what lets
「荷蘭豆多少錢」 reach 豌豆, and it makes the result independent of the script the
shopper typed in — 「高丽菜」 and 「高麗菜」 both normalise to 甘藍. Both
implementations are tested against the same
shared/normalize-query.fixture.json, so they cannot drift.
Matching keeps both the typed form and the alias target, because they find different rows: 「蔥」 is a board name of its own while its alias 青蔥 is the root behind 蔥 and 紅蔥頭 — resolving only to 青蔥 would lose 洋蔥, which a shopper typing 蔥 expects to see.
The alias table exists once. It lived in Config.gs, where the client could
not read it, so 「onion」 missed on the board and paid for a live query for an
item that was on screen all along. shared/search-aliases.json is now the
single source: the frontend imports it directly, Apps Script gets a generated
backend/SearchAliases.gs, and repoHygiene.test.ts re-renders the generated
file and fails on any drift. Its keys are stored already folded (lower-case,
half-width) so a lookup is one map hit on both ends, and the whole table costs
the client 4.1 kB raw / 2.3 kB gzip — measured, and cheaper than one avoided
round trip.
The client runs the same normaliser, so 「onion」, 「高丽菜」 and 「大白菜」 now hit
the board with no request at all, and the search box narrows the board locally
300 ms after the last keystroke (useSearch.preview). A keystroke never sends
a request and never shows 「查無此品項」 — mid-word we do not know, so the board
simply stays whole and Enter is what asks the backend.
refreshBoardCache() used to reject exactly one thing: an empty board.
Everything else was stored — so a throttled crawl (40 items instead of 94) or a
MOA unit change (every price ×1.67) overwrote a good board with a wrong one, and
updateHistory baked the wrong numbers into the 28-day baseline on the way.
validateBoard() in backend/Validate.gs now sits between the build and the
store. Board-level rules reject the whole build; item-level rules only mark
the item, because withholding 93 good prices over one bad transaction is the
worse trade.
| Rule | Threshold | Why that number |
|---|---|---|
| Too few items | count < max(30, 0.6 × previous) | A normal day carries 90–94 of the 104 defined items and seasonal drop-out is under 10 %/day, so losing 40 % is a fetch failure, not a season. The floor of 30 means "certainly broken" and sits deliberately far below the 60 the external probe alerts at — that one asks "worth a look?", this one withholds data from users |
| Mass price jump | ≥ 20 % of the common items moved beyond ×3 | A real market moves a handful of crops; a fifth of the board tripling at once is a unit or column change |
| Whole-board displacement | median common-item price ratio outside [0.5, 2] | Every price shifting by the same factor is arithmetic, not trading |
| Trading date regression | roc_date older than the stored board's | The date probe picked the wrong day, which makes every change_percent on the board wrong |
| Item: outlier transaction | abs(change_percent) > 150 and volume below 20 % of the previous board's | A crop can double overnight; 2.5× on a tenth of the volume is one mistyped Trans_Quantity carrying the average. Measured against the previous board rather than a history median, because the history store keeps prices only |
| Item: grouping error | one variety above 95 % of the volume whose price differs by more than 50 % | A variety that dominant is the item, so a gap that wide means rows from another crop were folded in. varietyBreakdown cannot currently emit that shape (it needs ≥ 2 varieties, each ≥ 10 %), so this guards a future publisher change |
Every board-level rule that triggers is reported, not just the first, and a first deploy has no stored board to compare against — then only the absolute floor applies.
Nothing is corrected: no outlier is dropped, no price is rescaled. Choosing
which of two numbers is the real one is guessing, and a price board that guesses
has nothing left to offer. A rejected build is instead kept whole in its own
chunked property (veggie_board_rejected_chunk_*) as the only evidence of what
MOA answered, veggie_last_refresh_fail records implausible: <reasons>, and
the same recordRefreshOutcome(false, …) path feeds the existing 3-failure
alert — so the reasons arrive by mail rather than only in a log. diag exposes
the last verdict as last_validation.
The volume rule compares today with the previous trading day, which the
card carries from the crawl for exactly as long as the guard needs it and no
longer — dropTransient runs on every path that hands cards to a client, the
refresh before it stores and the live search before it answers and caches. The
stored board is only the previous trading day on the day's first refresh, and
comparing against it on the second published a flagged item unflagged a few
hours later, with its badges and its place in 划算優先 back (#77).
Marked items stay on the board with suspect: true: an old price beats a
blank. What they lose is everything derived from comparing days — they are
excluded from the price history (a flagged observation must not bend the 28-day
median), and the frontend hides their change badge, their 「比近月便宜」 badge
and the drawer's baseline sentence, replacing the drawer's change block with
「今日成交異常,暫不顯示漲跌」. That includes the 「划算優先」 ordering, which
sank them to the bottom only after #70: a flagged item could take first place
on the very number its own card refuses to show, above items with real
discounts. One rule (lib/utils/baseline.ts) now answers "can this comparison
be trusted?" — as a value, since every reader needs the number too — for the
ordering, the badge, the drawer's sentence and whether the sort is offered at
all.
Two Apps Script limits bite long before anything else: 30 simultaneous
executions per account and the daily UrlFetchApp budget. The board was a
cache read rather than a crawl, so it never spent MOA quota — but at ~99 % of
the traffic it was almost every execution, which is the limit that queues
requests behind each other. The static mirror below takes that path off Apps
Script entirely; these were the per-user actions:
| Path | Before | Now |
|---|---|---|
| Trend (one drawer open) | 7 sequential fetches + 480 ms of sleeps, 5–10 s holding an execution slot | 1 range query, then a shared cache: ~1 crawl per crop per hour for all users (1.3 s warm) |
| Search, impossible query (typo, gibberish, non-produce) | 16 probe fetches + 2 live queries, 8–31 s | 0 fetches, answered from CROP_CATALOG in milliseconds |
| Search, real crop outside the board | the same 8–31 s, every time | 1 crawl per root per hour, shared by all users |
| Board | one execution per visit, served from cache | no execution at all while the static mirror is fresh (below); GAS only for a stale or missing mirror |
History writes (updateHistory, backfillHistory) run inside a LockService
critical section: the 4-hourly refresh and a queued backfill genuinely can
overlap, and a read-modify-write race would silently drop observations. The
backfill crawls every window before taking the lock, so the critical section
lasts milliseconds.
The rolling history above is 28 trading days, and that is a quota decision: ScriptProperties gives the project 500 KB, and the baseline every 「比近月便宜」 claim is measured against has to fit in it. Anything longer — 「比去年同期」, a per-variety baseline — needs somewhere else, so a refresh can also append the day to a Google Sheet the deployer owns (#22).
It is off unless HISTORY_SHEET_ID is set, and it is deliberately the least
privileged thing in the backend that it can be:
updateHistory — the same two executions
overlap (the 4-hourly trigger and a ?action=warm rebuild), and a
check-then-append race would archive a day twice. That lock is now held
across a Sheets round trip rather than a property write, so backfillHistory
retries the merge once and reports merged: false rather than throwing away
a crawl that took minutes — and frees its queue lock, so the operator can
simply ask again;date, item, root, variety, avg_price_kg, volume_kg, markets, share_percent. A variety row carries the share and
leaves volume and markets empty: the breakdown publishes shares and prices
and drops the volume it grouped by, so share × total would be a number
nobody measured sitting in an archive;diag.sheet_history reports configured and the last write from the
properties alone. diag is public and unauthenticated, and a spreadsheet
read there would let anyone spend the deployment's Sheets quota.Enabling it needs the spreadsheets OAuth scope, which is now in
appsscript.json — so the next deploy asks the deploying owner to
re-consent before the Web App serves again. §8 has the canary procedure;
this is the same step the script.send_mail scope needed.
The archive starts empty on the day it is configured, and both of its readers
need what came before — 「比去年同期」 a year of it, a per-variety baseline the
last 45 days. ?action=backfill&sheet=1&months=12 (admin token) fills that in
from MOA's range queries — the months asked for, plus the week before them,
which is the far half of the year-ago window the comparison reads for today:
boardCards, validateBoard, historyRowsFor. The guard's
board-level rules compare a day with another, and the live path has the
board it stored; the past has no such anchor. A chain of "the last day let
through" starts every window from a day judged against nothing, and a vote
of the two neighbours lets a broken stretch vouch for itself — so a day is
judged against the rest of the 12-day span, each item at its median
price across the other days. A broken day, or a short run of them, is
outvoted, and every day has a reference, the first and the newest alike.
What it cannot tell from a broken stretch is a real shift that lasts (a
typhoon week): the minority side of the span is refused — a missing day over
a wrong one — and those days are holes that a later job, whose spans fall
differently, judges again. A refused day is not written, and is listed
under the job's rejected. Each window fetches three
extra leading days that are never written: they are the previous trading
day rule (e) judges the first day against, and without them one day in
every nine would go into the archive unjudged. After a longer closure
(春節 runs 4–6 days) the first day still has none in reach, so it is
deferred: the next window ends on it, with its own days behind it.Data — so a root it did not answer, even
after the retry, is told apart from one that did not trade, and fails the
window; the next link tries again. Two cases are stepped past once MOA has
answered the window the same way three times, or nothing older could ever
be reached: no probe rows dated inside the window at all (a hole in MOA's
own data; listed under gaps), and one or two crops refused while the
probe answered (written without them; listed under partial). A refused
probe, or a batch-sized hole, is a throttle — it hits the same place in
every burst — and keeps failing the window instead. Retries waiting on
MOA's answer to settle are not charged to the failure budget, so an
unrelated failure in between cannot stop the job one answer short.The archive's first reader is 「比去年同期」 (#22 §2). The refresh reads the
archived blend rows within a week either side of the trading date a year
back — column A of the year tab (two, across New Year) to find them, then
only those rows; the current year's tab under the history lock, which only
late in December is part of the window, since that is where the live path
rewrites its day — and takes each crop's median, one value per day, over the SAME number of
days from each side of the day itself (the nearest ones, and the day if
archived), so a window with more on one side — a backfill that stopped
part-way, a gap, a closure — cannot lean to that week. With at least two on
each side, a crop gets
last_year_price (元/台斤) and vs_last_year_percent, wholesale against
wholesale like the baseline. A window spread too thin over the tab to read
in a few calls — a tab sorted by another column, most likely — is reported as
scattered in diag rather than read around.
The read is the refresh's last step, after the board is stored and its
outcome recorded — a slow Sheets read must never cost the board, and a
timeout there costs only the comparison. The board is built with the
medians kept from the last read, which after a new trading date are those of
a window a day or two older: a ±7-day median barely moves. They are read
again once a day; every six hours while a backfill that reaches the window
runs, or after a tab was found too spread to read; and as soon as a backfill
has finished since the last read — and
nothing is published for a window a running backfill has not finished
walking through, whose later days alone would pass for 「去年此時」. A
refresh that has already run four minutes leaves the read to the next one,
and diag.sheet_history.year_ago.skipped_at says so, with skipped saying
why: late, or failed (the Sheet unreachable, or the history lock busy past
the readers' five-second wait). It is shown as one line in the drawer and on no
card: drawer_opened carries has_last_year, and whether it earns a badge
is for those numbers to say. diag.sheet_history.year_ago reports which
trading date it compares and how many crops it covers.
The drawer breaks a blended price into its varieties, and could say what each
costs today but not whether that is cheap for that variety: the 28-day
baseline is the blend's, and 綠竹筍 at twice 麻竹筍 is not "expensive" (#22 §3).
The archive keeps each variety's own row a day, so each gets its own median —
over the item baseline's days (the item's most recent 28 archived trading days
within the 45 before the board's date, the day itself left out). A variety's
row is archived only on days the board broke the item down, which takes two
varieties past the share and volume floors, so a crop nearly all one variety
has rows for it only on a few contested days; a variety gets a median only
when it was listed on at least half those 28 days, and ten at least. A
variety just in season therefore waits a fortnight or so: from these rows it
cannot be told from a dominant one only lately broken down. The
row shows 「批發比近月低/高 N%」 as varieties[].vs_baseline_percent —
wholesale, in 元/公斤 as the archive holds it, since the row leads with a
retail estimate — hidden on a
suspect day like everything that compares days. It is read exactly as the
year-ago medians are: the refresh's last step, kept per trading date (chunked
— a hundred items' varieties are more than one property holds), applied by
the next build, read again on the same terms, the live year's tab under the
lock — and not read while a backfill is still walking through the span, whose
later days alone would give a fortnight's median as the month's
(waiting_for_backfill). diag.sheet_history.variety_baseline reports what it covers, and a
read left undone the same way — skipped also not kept when the property
store would not take the result.
Rows land in the order they were written, not in date order — the live days,
then each window newest-first. Nothing reads the tab in order (the readers
group by date), so sorting column A in the Sheets UI is safe at any time: the
header row is frozen (the backfill freezes tabs the live archive made before
it did), so it stays on row 1, and a sort by date keeps each
day's rows together, which is all the correction path and the readers rely
on. (Sort by any other column and a later correction reports the day
scattered and leaves it alone. A reader checks every row's date, so order
matters to it only as cost: a span it would need more than
ARCHIVE_MAX_READS (20) separate reads for — a block an item — is reported
scattered rather than read.)
data/board.json is published inside the frontend's own Pages artifact by
deploy-pages.yml, and the client reads it before it considers GAS at all:
localStorage → data/board.json → GAS /exec.
Three reasons, none of which the client-side fallback could reach:
fetchBoard retries three
times. Static JSON has no warm-up.The mirror is republished when a crawl lands, plus on every code deploy and
on a 2-hourly cron as the fallback. The dispatch is the mechanism: after a
successful refreshBoardCache() the backend POSTs {"event_type": "board-crawled"} to this repository's /dispatches, which
deploy-pages.yml listens for. API-triggered runs are not subject to the
schedule throttling described below, so the mirror is published minutes after
the board it mirrors exists, and its age is then bounded by the backend's own
4 h refresh cycle — inside the client's 6 h authority window and the probe's
8 h bound.
That took two measurements to arrive at. The mirror trails the backend by the
board's age when the deploy ran — 0–4 h, since the refresh trigger is installed
by hand and its phase is arbitrary — plus everything since that deploy, and
that second term is not what the cron says it is. Over the 222 h to
2026-09-16 a 20 */2 * * * schedule asked for ~111 runs and GitHub created 46:
a median gap of 4.5 h against the 2 h it was written for, a worst of 11.2 h,
none cancelled or skipped. So a typical mirror sat near the client's 6 h
authority window, past which every visitor falls through to GAS, and the tail
sat past the probe's 8 h bound — [prod-alert] mirror_stale on a mirror 8.8 h
old beside a board crawled 0.8 h earlier (#66), the same fault as #42 one
cadence earlier.
#67 then asked hourly for a
week, to tell a ~59 % drop rate from an effective floor near 4 h. Over the
137 h to 2026-09-21 that produced 33 successful runs: a median gap of 4.64 h, a
worst of 7.34 h, a best of 2.07 h. The median did not move — twelve times the
asks for a quarter of the runs is a throttle, not a lottery — so the cron went
back to 2-hourly and the mirror's freshness moved onto the dispatch
(#68). The tail did
improve, which is worth having but is not the number mirror age turns on. The
schedule: comment in .github/workflows/deploy-pages.yml carries the
re-measurement recipe — gaps between runs that succeeded, slurped with
jq -s rather than gh's per-page --jq, both of which change the answer.
The dispatch needs a GH_DISPATCH_TOKEN script property: a fine-grained PAT
scoped to this repository with contents: write (§8). Unset, the backend
skips the POST entirely and the 2-hourly schedule is all there is — the state
this section described before the dispatch existed. A failure to dispatch is
logged and swallowed, never thrown: it must not cost a crawl that succeeded.
Every attempt is recorded as diag.mirror_dispatch
({ at, outcome, last_ok } — last_ok being when the mirror was last actually
asked to publish, which the outcome alone cannot say), so an expired PAT reads
as rejected 401 where an operator looks rather than only in a log — otherwise
mirror freshness would revert to the cron with nothing saying so. The dispatch also keeps a 30-minute floor: ?action=warm is public and
releases its lock when the crawl ends, so a visitor can drive crawls every few
minutes, and a crawl costs the backend while a deploy costs a minute of CI
against Pages' ten-an-hour soft limit. A crawl inside that window is dropped
rather than deferred — the mirror keeps the previous board until the next
crawl, and that board is at most one window older. Only an accepted dispatch
arms the floor, since only that one cost a deploy; a rejection is retried by
the next crawl, behind a 5-minute backoff of its own so an expired PAT cannot
POST a doomed request every few minutes for as long as anyone keeps crawling.
For a board of wholesale closing prices, published once a day after market
close, the remaining lag is invisible.
A mirror is a file, and a file cannot know it went stale. The stale: false
and age_ms inside it froze the moment it was written, so the client recomputes
the age from generated_at against the backend's own BOARD_MAX_AGE_MS (6 h,
mirrored in src/lib/utils/freshness.ts): under it, the mirror answers the
visit outright and is written to localStorage; over it, it is ranked against
whatever is already on screen — it paints immediately when it is the newer of
the two (see the table below) — and the read continues to GAS either way.
The self-heal chain is therefore unchanged — a stale mirror sends the
client to /exec, whose readBoard queues the rebuild exactly as before. The
mirror is a layer in front of GAS, never a replacement for it.
What each failure does, in the order the client meets them:
| Failure | Client |
|---|---|
| No mirror deployed yet (404) | the pre-mirror path: GAS, with the localStorage fallback |
| Mirror is a truncated file, an error page, or breaks the §3 contract | reports board_schema_mismatch and asks GAS |
| Mirror does not answer within 3 s | asks GAS — a CDN that slow is only delaying the request its absence makes necessary |
| Mirror is stale and GAS is down | the newer of the stale mirror and whatever is already on screen stays there with the connection note (board_fallback names which: static, cache, or gas for a retry over prices GAS gave us earlier) |
Past the authority window neither of those two is "what the app serves" — they
are two copies of the past, ranked by the only thing that can rank them: when
the backend crawled each one. Ranking them by source instead let a mirror
whose deploy pipeline had been stuck for days outrank a board this browser
loaded an hour earlier (#79). A board whose generated_at cannot be parsed
never wins: its real age is unknown, which is the same reason boardAgeMs
counts it stale.
The publish side is symmetric: a mirror is only overwritten by a payload that
passes frontend/scripts/validate-board.mjs (the §3 contract, ≥ 60 items,
crawled < 8 h ago, every item priced), and a failed fetch or a rejected board
re-publishes the previous mirror rather than failing the deploy — a code
change must not be blocked by a backend outage, and an unvalidated file would
serve wrong prices until the next deploy republishes the mirror (§8).
?action=diag only helps someone who thinks to look. Two failures are
emailed to the maintainer instead, each once per 24 h incident window:
| Signal | Trigger | Why it needs its own detector |
|---|---|---|
| Failure streak | 3 consecutive refreshes that yield no board | One failure is routine (MOA throttles a batch) and self-heals; alerting on it would train the recipient to ignore the mail |
| Silence | Served board older than 12 h | A deleted or broken trigger produces no failures to count. Nothing is running to notice, so the serving path raises this one |
MOA itself refuses connections from outside Taiwan (#103), and Apps Script
runs on Google's servers abroad. With the MOA_RELAY_URL / MOA_RELAY_KEY
script properties set, every MOA request — the crawl's, and the trend's and
search's — goes through relay/, a small keyed forwarder running in Taiwan
(Cloud Run, asia-east1); relay/README.md has the deploy. Unset, requests
go to MOA directly, and diag.moa_relay says which.
Without a relay, the board can be refreshed by hand from any machine in
Taiwan: tools/push-board runs the backend's own crawl there and POSTs the
rows to ?action=push, where a normal refresh — guard, store, archive,
mirror — runs on them. Nothing to host; see tools/push-board/README.md.
A refresh Apps Script kills at its six-minute limit is neither: it records
nothing at all, so there is no failure to count. That is what an MOA that
does not answer used to cause — each request waits about a minute before it
times out, and the trading-date probe alone asks up to sixteen times. So the
crawl has its own budget (CRAWL_BUDGET_MS, 3 min, far above a healthy
crawl's): past it no more MOA requests go out, and the build fails as
「向 MOA 抓取資料逾時…MOA 可能無法連線」 — a failure like any other, counted
toward the streak. A crawl cut short publishes nothing, never the cards that
came back in time. The retry of roots that came back empty is the one pass
the budget may end without failing the build — out-of-season roots are empty
every time — unless a root among them was never answered at all (a throttled
batch), which would be published as missing.
A recovering refresh closes the incident with one 「已恢復正常」 mail, so an alert always has a matching all-clear — and the mail is sent before the state is cleared, because clearing first would close the incident even when the send failed and strand the reader on a "still broken" impression.
Every one of those decisions is a read-modify-write on shared state, so they
all run inside one script-lock section (withAlertLock). Without it, Apps
Script's 30 simultaneous executions could turn a single incident into 30 mails
against a ~100/day quota. The lock uses tryLock, not waitLock: losing the
race means another execution is already deciding, which is the desired outcome.
History writes use waitLock instead — there a skipped turn would lose an
observation.
Alerting swallows every error by design: it sits on both the refresh and the
serving path, and no mail-quota, properties or lock failure may take the board
down with it. diag reports alert.failure_streak / alert.incident_open /
alert.last_attempt / alert.recipient_configured / alert.last_send_failure —
never the address, since diag is public. The recipient is not in the source
either: alertRecipient() reads the ALERT_EMAIL script property, and that
property is required — there is deliberately no fallback to the deploying
account's e-mail, because reading it needs the userinfo.email scope the
manifest does not grant, and adding a scope forces re-consent before the Web
App runs again. Unset, every mail fails as no_recipient and diag shows
recipient_configured: false.
Opening an incident does not depend on being able to send one, which is
also why the timestamp is last_attempt rather than last_sent: it is when
the incident window armed, and last_send_failure is what says whether
anything left the building.
sendAlertMail returns a failure category rather than throwing, and both
callers open the incident either way, so incident_open says what the backend
knows about itself and last_send_failure says why the mailbox is silent. It
used to throw: openIncident never ran, the cooldown never armed, and a
deployment with no ALERT_EMAIL reported incident_open: false for a pipeline
that had already given up — which the external probe reads as health (#65).
Recovery is the mirror of that rule: a failed all-clear keeps the incident open
so the next healthy refresh retries it, except for no_recipient, where no
retry can ever deliver and holding it open would page the probe forever for a
backend that recovered.
?action=alerttest needs the operator token; its limiter stays
as defence in depth and is a durable timestamp rather than a cache key, since
cache eviction would otherwise re-open the endpoint.
MailApp needs the script.send_mail scope, now declared explicitly in
appsscript.json. Changing scopes requires the deploying owner to re-consent,
so verify a canary deployment before redeploying the pinned production one.
date / roc_date is the trading date of the prices. It legitimately stands
still over weekends, holidays and typhoon closures, when MOA publishes nothing but
休市 placeholder rows — 2026-08-27 and 2026-08-28 were two such days island-wide.
generated_at is when the backend last crawled, and that must keep moving.
Reporting only the trading date is what made a normal market closure look like a
broken app: the UI showed a date frozen two days back with no way to tell whether
the markets were shut or the pipeline had died. The board therefore carries both,
plus stale, and the frontend (src/lib/utils/freshness.ts) turns the pair into
one of "今日行情尚未公布", "批發市場休市中" or "資料更新中".
GET {WEB_APP_URL}/exec
{
"type": "board",
"date": "2026-09-02",
"roc_date": "115.09.02",
"prev_date": "115.09.01",
"generated_at": "2026-09-02T16:05:08.087Z",
"age_ms": 84210,
"stale": false,
"count": 94,
"items": [
{
"code": "LA1",
"name": "高麗菜",
"official_name": "甘藍",
"category": "葉菜類",
"avg_price": 22.1,
"catty_price": 13.3,
"retail_low": 35,
"retail_price": 42,
"retail_high": 55,
"retail_estimated": true,
"change_percent": -13.5,
"baseline_price": 16.3,
"vs_baseline_percent": -18.5,
"varieties": [
{ "name": "改良種", "catty_price": 12, "retail_price": 41, "share_percent": 62 },
{ "name": "初秋", "catty_price": 18.9, "retail_price": 48, "share_percent": 19 }
],
"trade_volume": 570700,
"unit": "公斤",
"markets_count": 13
}
],
"cached": true
}
avg_price is 元/公斤. catty_price, the three retail_* fields,
baseline_price, last_year_price and both varieties[].catty_price /
varieties[].retail_price are 元/台斤.
retail_estimated is always true — see §4.
Every derived field is optional and clients must treat it as such: an older deploy's cached board lacks them, and the backend omits them whenever the data does not justify publishing.
| Field(s) | Omitted when |
|---|---|
retail_* | the cached board predates the retail band |
baseline_price, vs_baseline_percent | fewer than 10 in-horizon observations for that crop (§5) |
last_year_price, vs_last_year_percent | no long-term archive configured, or fewer than two archived trading days for that crop on either side of this date a year back, within a week of it (§2) |
varieties | fewer than 2 varieties clear the share and volume thresholds (§5) |
varieties[].vs_baseline_percent | no long-term archive configured, or fewer than 10 archived days for that variety within the 45 days before this date (§2) |
suspect | the item's numbers are plausible; it appears only on an item the guard flagged (§2), whose change, baseline and year-ago comparison the client must then hide — everything that compares today with another day |
date is the trading date; generated_at is when the backend crawled. See
"Trading date vs. refresh time" in §2 — clients must not present the trading date
alone as "last updated". stale: true means the board is past its max age and a
rebuild has been queued (refresh_queued); the stale board is still served.
The same payload is mirrored as a static file at
https://sean1093.github.io/VeggieRadar/data/board.json, which the client
reads before /exec (§2). It is the response of one past ?action=board
call, byte for byte — including age_ms and stale, which are therefore
frozen at publish time and must be recomputed from generated_at by anything
reading the file.
frontend/src/types/board.schema.ts is the executable form of this contract:
the client's types are inferred from it, api.ts measures every live board
against it, backendCode.test.ts runs the real buildBoard() output through
it, and the production probe (§8) checks the deployed endpoints with it — so a
field renamed on one side of the wire fails in CI instead of reaching a
shopper as undefined. A client mismatch is reported, never enforced: the
board still renders (board_schema_mismatch, §6).
GET {WEB_APP_URL}/exec?action=search&query=高麗菜
→ { "type": "search", "query": "高麗菜", "date": "2026-09-02", "count": 1, "items": [ ... ] }
GET {WEB_APP_URL}/exec?action=search&query=iphone
→ { "type": "search", "query": "iphone", "error": "查無此品項", "suggestion": "試試:高麗菜、番茄、蔥" }
Same items shape as the board, with type: "search". query echoes what was
asked, not the normalised form — 「高丽菜」 comes back as typed.
suggestion is a string, ready to render: 「試試:」 plus up to three names
joined with 、. It carries catalogue roots one edit away when the query looks
like a typo, and otherwise the board's biggest sellers (§2, "The search path").
A string rather than a list because it is one line of UI, and because clients
that already read suggestion keep working.
The web client renders it: useSearch keeps it on the not_found state and
the empty state reads 「找不到「」,試試:…」 with the backend's names. An
older deploy that omits the field falls back to the fixed trio, which is why
the field is optional in the type.
A board hit and a gated miss both answer with zero MOA traffic; only a real
crop outside the board falls through to a live query. Other errors:
請輸入查詢關鍵字 (empty query), 近期查無交易資料 (no trading date could be
resolved) and 查無符合條件的品項(可能交易量過低) (rows existed but every one
was below MIN_TRADE_VOLUME).
GET {WEB_APP_URL}/exec?action=getTrend&cropName=甘藍&days=7
→ { "cropName": "甘藍", "days": 7, "trend": [23.1, 24.0, null, 24.4, ...] }
Oldest → newest, 元/公斤; null = no market that day (holiday, or today before
the closing prices publish). days is clamped to 14: one MOA response caps
near 1000 rows, and 14 days of a high-volume crop stays under it. The payload is
cached for an hour per crop and shared across visitors, so repeat opens cost no
MOA traffic.
GET {WEB_APP_URL}/exec?action=warm[&force=1&token=…]
→ { "type": "warm", "queued": true, "forced": false, "message": "已排入背景更新,約一分鐘後生效", "board": { ... } }
GET {WEB_APP_URL}/exec?action=backfill&token=…[&force=1]
→ { "type": "backfill", "queued": true, "message": "已排入背景回填,約數分鐘後生效",
"history": { "items": 97, "min_days": 1, "max_days": 24 } }
→ { "type": "backfill", "error": "unauthorized", "message": "此操作需要 token 參數" } # wrong or missing token
GET {WEB_APP_URL}/exec?action=backfill&sheet=1&token=…[&months=12 | &cancel=1]
→ { "type": "backfill", "sheet": true, "queued": true, "message": "已排入背景回填",
"job": { "status": "running", "months": 12, "from": "2025-09-21", "to": "2026-09-20",
"cursor": "2026-09-20", "windows": 0, "days_written": 0, "days_skipped": 0,
"rows_written": 0, "failures": 0, "last_error": null, ... } }
→ without `months` — status only, nothing crawled or queued, plus what the Sheet holds
(counted at most every ten minutes; `as_of` says when):
{ ..., "queued": false, "archive": { "rows": 61234, "days": 249, "first_date": "2025-09-22",
"last_date": "2026-09-22", "as_of": "…" } }
GET {WEB_APP_URL}/exec?action=diag[&token=…]
→ { "type": "diag", "board": { "generated_at": ..., "stale": false },
"triggers": ["refreshBoardCache"], "last_refresh_ok": "...", "last_refresh_fail": null,
"last_validation": { "at": "2026-09-02T16:05:08.087Z", "ok": true, "reasons": [], "suspects": [] },
"history": { "items": 97, "min_days": 1, "max_days": 24 },
"mirror_dispatch": { "at": "2026-09-21T16:04:11.201Z", "outcome": "dispatched",
"last_ok": "2026-09-21T16:04:11.201Z" },
"sheet_history": { "configured": true, "last_write": { "date": "2026-09-22", "generated_at": "…" },
"backfill": { "status": "running", "cursor": "2026-06-14", "windows": 11, ... } },
"alert": { "failure_streak": 0, "incident_open": false, "last_attempt": null, "recipient_configured": true,
"last_send_failure": null } }
GET {WEB_APP_URL}/exec?action=alerttest&token=…
→ { "type": "alerttest", "sent": true, "message": "已寄出測試信" }
POST {WEB_APP_URL}/exec?action=push&token=… body: { "dates": { "latest": "115.10.27", "prev": "115.10.26" },
"today": { "甘藍": [ …MOA rows… ], … }, "prev": { … } }
→ { "type": "push", "stored": true, "reasons": [], "roc_date": "115.10.27", "count": 97, "generated_at": "…" }
→ { "type": "push", "stored": false, "message": "dates.latest must be a ROC date like 115.10.27" } # not a crawl
warm and backfill both queue their crawl in a one-off trigger and answer at
once — the crawls take minutes and would blow the Web App response window.
backfill is idempotent per trading date, so re-running only fills gaps; with
sheet=1 it backfills the long-term archive instead (§2), and diag publishes
that job's progress but never its last_error, which is platform text.
push is the one POST: a crawl made in Taiwan by tools/push-board (MOA
refuses Apps Script's servers, #103), refreshed on as if the backend had
fetched it. diag
is how you tell "markets closed" from "refresh pipeline dead" without the GAS
console, and how you confirm history coverage after a backfill.
last_validation is public with or without the token, unlike last_refresh_fail:
its reasons are our own rule text and our own item names, never MOA or platform
text that could quote something we would rather not publish.
| Action | Anonymous | With token |
|---|---|---|
board, search, getTrend | ✅ | — |
warm | ✅ (force ignored, forced: false) | ✅ force honoured |
backfill, alerttest, push | ❌ unauthorized | ✅ |
diag | ✅ failure reason as a category | ✅ raw failure reason |
token must equal the ADMIN_TOKEN script property; see §2 and §7.
There is no retail price API to consume. Verified against the MOA OpenAPI
spec (60 endpoints), the full MOA dataset catalogue (1,711 datasets) and
data.gov.tw (53,103 datasets): Taiwan publishes vegetables and fruit at
farmgate and wholesale only. The single government retail price series is
CH11 畜產都市零售價格 — livestock. amis.afa.gov.tw is wholesale-only and has
no JSON API.
So the band is computed, and labelled as an estimate everywhere it appears.
retail_元/台斤 ≈ wholesale_元/台斤 + markup(crop)
Additive, not multiplicative, because a stall's margin is driven by handling, shrinkage and rent amortised per unit sold rather than by a percentage. The same ~NT$28/catty markup explains a NT$9 cabbage retailing at NT$35 (a 3.9× ratio) and a NT$41 pear retailing at NT$67 (1.6×). Fitting a multiplier instead roughly doubles the error; fitting a free-slope line overfits and produces a negative slope for 瓜果類, which would predict cheaper retail as wholesale rises.
Three tiers, most specific first:
| Tier | Table | Crops | Band |
|---|---|---|---|
| 1 | RETAIL_MARKUP_ROOT | 30 | fitted midpoint, band × 0.75 … × 1.35 |
| 2 | RETAIL_BAND_ROOT | 21 | fitted [p10, median, p90] of the crop's own markup distribution |
| 3 | RETAIL_MARKUP_CATEGORY | the rest | one hand-tuned band per category |
Everything is rounded outward to NT$5, because stalls price in round numbers and implying single-digit precision on an estimate would be dishonest. Board items on the coarse tier-3 fallback dropped from 72 of 104 to 50.
Markups are fitted offline by joining MOA wholesale to two municipal retail feeds:
| Source | Granularity | Coverage |
|---|---|---|
| 臺中市公有零售市場每日蔬果價格表 | daily, 14 markets | 42 produce items, rolling 365 days |
| 臺北市公有零售市場行情 | monthly | 122 items × 18 monthly snapshots |
Both are keyless JSON quoting 元/台斤 — the same unit the app displays, so no
conversion is involved on the retail side. A survey for a third municipal feed
found none: Kaohsiung, Tainan and New Taipei publish no retail produce series,
and data.gov.tw's dataset search API is broken (405/404).
The fit lives in tools/calibrate, and the numbers it produces live in
backend/RetailCalibration.gs. That is the whole point of this section: the
headline digits on every card used to come from a script nobody could re-run,
so the accuracy claims underneath them could not be reproduced or improved —
only trusted. Now npm run calibrate fetches, joins, fits, evaluates and
writes both the .gs file and tools/calibrate/report/<YYYY-MM>.md; every
download is cached, so a second run is offline. The latest report, not this
file, carries the current coverage, error and per-crop numbers — see
tools/calibrate/report/.
The rules that decide what gets listed are functions with tests
(tools/calibrate/src/fit.ts), each one a measurement that contradicted the
obvious guess:
× 0.75 … × 1.35 band applied to these crops lost coverage against the
fallback it was meant to beat — the spread is not proportional to the markup.A refit is a proposal, never an automatic change.
.github/workflows/recalibrate.yml runs the pipeline on the 1st of each month
and, when the generated file moves, opens a data-quality pull request whose
body is the report summary (old vs new, the ten largest changes, the adoption
gate). Nothing auto-merges: the card's big digits must not change because a
cron job woke up and a municipality revised a month.
The tables shipping today are still the previously fitted ones. The refit
on data through 2026-09 reproduces tier 1 to a median of NT$3.5 and 14 of 17
shipped tier-2 medians to within NT$2 — under the adoption gate of 17 of 17,
so --keep-shipped was used: the report is committed, the numbers are not.
What moved is worth reading rather than adopting blindly — the current window
has no overlap with its holdout, and on it the tier-1-style band scores above
the per-crop quantile band on coverage, which is the opposite of what the
original cut measured.
That residual error is why the UI shows a range rather than a single number, and why the drawer says 「非實際報價」. Both feeds are used offline to derive the constants; the runtime has no dependency on them, so the retail band adds no network call and no failure mode.
An absolute price answers "what does this cost". Shoppers also ask "is that cheap?" and "cheap for which variety?" — neither of which the blended daily average can answer.
vs_baseline_percent)Comparing crops with each other is meaningless (香菇 at NT$60/catty is not "expensive" beside 高麗菜 at NT$15). Each crop is therefore compared with itself: the median of its own last 28 trading days, wholesale basis.
Why 28 trading days and not two weeks or a whole season: a 14-day window gets swallowed by the very disruption it should flag — a typhoon rally runs 2–6 weeks (roughly the leafy-green replant cycle), so the baseline climbs with the price and then reports "cheaper" at NT$60 when normal is NT$30. A 90-day window answers a question nobody asks at a market: it drags a whole season's structural shift into today's comparison, so an entire early winter reads as "cheap". The 28-day window also captures the in-season signal the user actually wanted — entering peak supply is the moment a crop drops below its own recent norm.
The history costs no extra MOA traffic. Each refresh already computes every
item's price, so it appends (trading date, 元/公斤) to a chunked-
ScriptProperties store. Trimming rides on every write (window, horizon, and
items no longer on the board), so the store is bounded by construction at
~17 KB against the 500 KB properties quota — there is deliberately no separate
cleanup job that could silently die. ?action=backfill seeds ~20 trading days
once via range queries; the dailies top it up from there.
varieties)MOA rows are <root>-<variety>, and the spread inside one crop can dwarf the
day's move. On 2026-09-02: 綠竹筍 at NT$57.1/catty against 麻竹筍 at NT$23.2
(2.5×), 愛文 mango at NT$58.5 against 凱特 at NT$23.8 (2.5×). The board's
blended 竹筍 number matched neither stall.
The drawer decomposes it, published only when a breakdown adds something: ≥2 varieties, each holding ≥10% of the item's traded volume and clearing the absolute volume floor, volume-sorted (so the market mainstream reads first) and capped at 4 rows; unlabelled rows group as 一般. Shares are computed against the item's total volume, so folded-away varieties leave an honest gap — and the drawer discloses that remainder whenever it is nonzero, not merely when it is large. A "roughly complete" threshold used to hide gaps of 1–9%, which let the weighted-average sentence below describe rows that quietly omitted volume.
Each row carries both bases, exactly like the card: the estimated market price leads and the measured wholesale price supports it. Publishing wholesale alone made the section unusable — a shopper is quoted retail, so a wholesale-only row cannot be compared with anything at the stall, and it silently disagreed with the card's retail headline.
Applying the root markup to a variety works precisely because the markup is additive and constant per crop:
retail_variety = wholesale_variety + markup(root)
retail_blend = wholesale_blend + markup(root) = Σ(share × retail_variety)
The markup's error is identical for both, while the variety row uses a more
precise wholesale input — so for the variety in front of the shopper it is
more accurate than the headline. The identity also makes the card's headline
the volume-weighted average of the rows, which mockBoard.test.ts holds the
bundled demo to. Verified in the UI: a 竹筍 card reading 約 61 against rows of
54 / 51 / 85 at shares 41 / 34 / 25 — a weighted average of 60.7.
Two limits are stated in the drawer rather than papered over:
This also exposes a pre-existing subtlety honestly: a seasonal rotation in the variety mix moves the blended average even when no single variety moved. The breakdown lets a shopper see through such a day.
index.html loads GA4. Page views alone could not answer the questions the
roadmap keeps deferring on ("does anyone use 划算優先?") or size the degraded
modes §2 describes, so the app sends a small set of events through one typed
wrapper, src/lib/analytics.ts. Each event exists to settle a decision:
| Event | Params | Decision it informs |
|---|---|---|
board_loaded | source (static / gas), stale, age_bucket | Baseline for every ratio below; source is how the mirror's share of the reads is measured — the number that says whether GAS still carries the board (§2). A cache paint sends nothing: it is not yet a load |
board_fallback | served (static / cache / gas / none) | Fallback rate. static means the mirror went stale and GAS is down — a pipeline incident; cache is one browser's own copy saving one visit; gas is a retry over a board GAS itself gave us earlier |
search_result | outcome (local_hit / remote_hit / not_found / transient), query_length | Live-miss and busy rates → the search index in #21; whether the 15 s deadline holds |
sort_changed | mode | 划算優先 adoption → 「今日推薦」 (§9) |
filter_changed | filter | Which categories and 關注 get used |
watch_toggled | on, count_bucket | Whether a watchlist summary is worth building |
drawer_opened | has_varieties, has_baseline, has_last_year, has_retail | Whether §5's variety breakdown and baseline are ever seen; has_last_year is how #22 decides whether 「比去年同期」 earns a place on the card (it is drawer-only until then). Sent once per open, with what the user saw: when the board's read finishes (useBoard().settled), or when the drawer closes first — so an open that lasts into the fresh board is not counted with a cached copy's flags, and one closed before it is still counted |
share | method (web_share / clipboard), has_retail | Whether sharing earns the per-item preview pages (§9), and how much of it goes through the native sheet |
What is deliberately not sent: the search text (only its outcome and
length — a search box accepts anything), watched item names (only a count
bucket), and anything else that could identify a person. Ages and counts are
bucketed so GA4 can aggregate them. track() is a no-op without gtag
(tests, offline, ad blockers) and swallows a throwing gtag: analytics can
never take the board down. Geography needs no event — GA4's built-in city
dimension is what decides the regional board (#23).
cd frontend
npm install
npm run dev # http://localhost:5173/VeggieRadar/
With no VITE_API_BASE_URL, the app uses src/services/mockBoard.ts (fully
interactive offline). To use the real backend, set it in frontend/.env:
VITE_API_BASE_URL=<your GAS Web App /exec URL>
npm run build # tsc typecheck + vite build
npm run test:run # vitest once — includes backendCode.test.ts, which loads
# every ../backend/*.gs with stubbed GAS services
npm test # vitest in watch mode
npm run test:coverage # v8 coverage report
./scripts/icons.sh # rasterise public/icon-*.png from favicon.svg (needs librsvg);
# only after the brand mark changes — the PNGs are committed
577 tests at ~97% statement / ~93% branch coverage. vitest.config.ts pins
TZ=Asia/Taipei: the freshness assertions are written in the audience's local
time and would otherwise pass only on machines in that zone (a UTC CI runner
caught exactly that).
tools/catalog)Two backend files are generated and must never be hand-edited:
backend/SearchAliases.gs (from shared/search-aliases.json) and
backend/CropCatalog.gs (from MOA). No runtime dependencies; Node strips the
types.
cd tools/catalog
npm run sync-aliases # seconds — re-renders SearchAliases.gs from the JSON
npm run build-catalog # ~50 min, ~1800 MOA requests — re-crawls the crop catalogue
sync-aliases is what you run after editing an alias: the JSON is the single
source (§2) and frontend/repoHygiene.test.ts fails while the generated copy
lags behind it. Its output carries no timestamp, precisely so that re-rendering
can be compared byte for byte.
build-catalog is a quarterly job — new crops appear in the feed a few
times a year, not weekly. It walks 100 sampled days × 14 wholesale markets:
MOA's response caps near 1000 rows and ignores Page, so a whole day is
unreachable, while adding MarketName= makes one day-market slice complete
(~300 rows, Next: false). The stride is 4 days, never 7, because every market
rests on fixed weekdays and a 7-day stride would sample one weekday for the
whole window; 100 samples over 400 days is also what catches a crop whose
entire season is three weeks long. Only transaction types N04 (vegetables)
and N05 (fruit) are kept: N06 is cut flowers, more than half of every day's
rows, and letting 「火鶴花」 through the gate would answer it with a per-catty
price and a retail band fitted on vegetables. Raw responses are cached under
tools/catalog/.cache/ (gitignored), so an interrupted crawl resumes for free,
and tools/catalog/last-seen.json (committed) records the last month each root
traded — that index is what lets a later run retire a root MOA stopped
publishing 24 months ago instead of carrying it forever. Board roots are always
included: the daily refresh already crawls them, so a crop whose three-week
season fell between two samples must not be gated out of search.
Both commands write into backend/, so review the diff and commit it with the
change that motivated it.
tools/region-spread)A one-off, read-only measurement for the regional board (#23). It writes a report and changes nothing the app serves.
cd tools/region-spread
npm ci
npm test # the statistics, on hand-checked series — no network
npm run measure # the last 30 days, every board item
The feature rests on three unmeasured claims, and the tool answers each with a
number: how far apart the regions really are, whether each region clears
MIN_TRADE_VOLUME once a crop's volume is split four ways, and whether a
regional change-percent would be a price move or just the market mix changing
(markets rest on fixed weekdays — nationwide that is diluted by twelve other
markets, inside one region it can be half the sample). It also produces the
market roster #23's design marks "to be verified against the actual
MarketCode", and its mapping check stays red while any market is unmapped.
Every number the board would publish comes from backend/*.gs — BOARD_ITEMS,
selectRows, weightedAverage, MIN_TRADE_VOLUME, CATTY_PER_KG — loaded the
way backendCode.test.ts loads them, with the GAS services stubbed to throw.
The tool groups and compares; it re-implements none of the arithmetic.
.cache/ and report/ are gitignored, so re-running after fixing the region
table costs no requests, and the run you draw a conclusion from belongs in the
issue rather than in the tree. See tools/region-spread/README.md.
Two files look like secrets and are not:
| File | Contents | Why committing it is fine |
|---|---|---|
.clasp.json | scriptId | An identifier, not a credential — like a Drive file ID. Verified anonymously: script.google.com/d/<scriptId>/edit returns 302 to Google login, and the Apps Script API returns 401. Access is gated by the project's Drive ACL plus OAuth, so this is safe as long as the project is never link-shared. |
frontend/.env | VITE_API_BASE_URL | The /exec endpoint is public by necessity — the browser calls it. Vite also inlines every VITE_* variable into the client bundle, so this file is public whatever git does with it. A test asserts it holds nothing else. |
The actual credentials live outside the repo and must stay there: ~/.clasprc.json
(your OAuth refresh token) and the GCP_SA_KEY / CLASP_TOKEN repo secrets.
The root .gitignore covers them plus gcp-sa-key.json, which the GAS deploy
workflow writes into the checkout at runtime — harmless on a throwaway runner,
a live credential in a commit anywhere else. It also ignores node_modules/
and dist/, because tooling run from the repo root creates them where
frontend/.gitignore cannot see them — which is how a stray vitest cache file
got committed once.
frontend/repoHygiene.test.ts asks git whether each dangerous path is
ignored (git check-ignore) rather than pattern-matching the ignore file:
only git implements gitignore semantics, including the !frontend/.env
negation that keeps the one intentional env file tracked. It also asserts that
nothing credential-shaped, and no dependency, build or coverage output, is tracked.
Code lives in backend/*.gs, deployed with clasp (.clasp.json sets
rootDir to backend/; no filePushOrder, see §2).
clasp push, then deploy as a Web App (execute as: me; access: anyone).
clasp push only moves HEAD — the /exec URL serves a pinned version, so
redeploy the same deployment to publish code:
clasp deploy -i <deploymentId> -d "<description>".ADMIN_TOKEN — a long random string, e.g. openssl rand -hex 32. It
gates warm&force, backfill and alerttest (§2); an empty value
counts as unset (everything refused). It travels as a URL query
parameter: the CI deploy URL-encodes it, but when you paste it into a
browser or a hand-written curl a value containing +, & or # is
mangled before it reaches e.parameter — hex avoids the question. Keep
it out of the repo; CI reads it from the GAS_ADMIN_TOKEN secret (§8).
There is one token, with no expiry or scope: rotating it means changing
both places.ALERT_EMAIL — required: where failure alerts go. Unset, no alert
can be sent (diag shows recipient_configured: false and, once
something has failed, last_send_failure: "no_recipient";
?action=alerttest answers no_recipient). The incident still opens, so
the external probe still sees it. See §2 for why there is no fallback to
the deploying account.GH_DISPATCH_TOKEN — optional, and the difference between a mirror that
follows the crawl and one that follows a cron. A fine-grained PAT
scoped to this repository with contents: write — which is what
POST /dispatches requires, and it is push access to this repository, so
the Apps Script project holds it. With it set, every
successful refresh asks GitHub to republish the mirror; unset, the
backend skips the POST and deploy-pages.yml's 2-hourly schedule is the
fallback (§2). Rotating it is one property: a stale token logs
requestMirrorDeploy: GitHub answered 401 and costs nothing else.HISTORY_SHEET_ID — optional: the id of a Google Sheet you own, and the
whole of the long-term archive's configuration (§2). Unset, nothing is
written and nothing is opened. The spreadsheets scope is declared
whether or not you set this, so the next deploy asks you to re-consent
either way — verify it on a canary deployment first, as with the mail
scope. diag.sheet_history says whether it is configured and what was
last written, from the properties alone. Once a refresh has stored a
board, fills in the past
year (§2); shows it walking back.The MOA API needs no key.
.github/workflows/ci.yml: every pull request runs ESLint, the
suite and a typecheck/build. Pushes are gated inside the deploy workflows
themselves (both run lint and the suite before publishing), so a red check
blocks either surface without duplicating the run. Dependabot
(.github/dependabot.yml) opens weekly PRs for the frontend toolchain and
the workflow actions; those PRs run the same gate..github/workflows/deploy-pages.yml: pushing to
the default branch runs the tests, builds frontend/ and publishes to Pages. In
the repo, set Settings → Pages → Source: GitHub Actions. Live at
https://<user>.github.io/VeggieRadar/ (vite.config.ts base is /VeggieRadar/).
It also runs on repository_dispatch: [board-crawled] — the backend asks for
the deploy when a crawl lands (§2), which is what keeps the static board
mirror fresh — and on schedule: '20 */2 * * *' as the fallback for a
deployment with no GH_DISPATCH_TOKEN set. The schedule used to be the
mechanism; a week of hourly ticks showed GitHub throttling them to a 4.64 h
median whatever the cron asks, so the asks went back to 2-hourly (§2). At
most 12 scheduled deploys a day plus one per crawl — the backend refreshes
4-hourly, and the dispatch keeps a 30-minute floor of its own, since
?action=warm is public — sits well below Pages'
soft limit of ten per hour, and concurrency: pages keeps one deploy at a
time — with cancel-in-progress: false, so a scheduled tick can never kill a
push deploy inside actions/deploy-pages. Both jobs carry
timeout-minutes: 15 for the same reason: a hung run holds the group for the
whole workflow, so the worst case is half an hour of ticks queuing and being
cancelled with nothing republished, against the 12 h two jobs at the default
would allow. Every run lints and tests before
publishing, scheduled ones included: skipping that would let the next tick
publish a master whose own deploy had just failed on a red test.
The Fetch board mirror step runs after the suite and before the build:
it fetches ?action=board with scripts/fetch-retry.mjs (the URL read from
the committed frontend/.env, so no secret), validates it with
node --experimental-strip-types scripts/validate-board.mjs, and copies it
to frontend/public/data/board.json — which frontend/.gitignore covers,
since the file belongs in the artifact and not in the history. Both the GAS
fetch and the fallback fetch of the published mirror go through
scripts/fetch-retry.mjs, which is the probe's own policy
(, Monitoring): three attempts, 2 s then 4 s apart,
for a 404, a 5xx or a timeout, and never for a 200 — a bad body is the
validator's question, an empty one is refused outright. The Pages fetch runs
in mode, where a 404 is the final answer ("nothing published") and
only a 5xx or a dead connection is retried. One attempt was how the mirror froze for a whole day
on 2026-09-13 (#53): Apps Script answered each scheduled fetch with its
cold-start 404 after queueing it for ~15 s, every run "succeeded" by
republishing the same 04:22 board, and GAS itself was healthy the entire
time. : a failed fetch or a rejected board
re-publishes the mirror already on Pages, and if that is missing too the
site deploys without one and the client goes straight to GAS. Every run
records which of the three happened, with the attempt count, in its step
summary and in its log:
A board rebuilt out of band — after
warm, or after a backfill — reaches the mirror only on the next scheduled deploy — and §2 measures that wait at a median 4.5 h under the previous cadence, rather than the interval the cron asks for. Runningdeploy-pagesbyworkflow_dispatchrefreshes it immediately; visitors see the new prices either way, since a mirror older than 6 h sends the client to GAS (§2).
Both deploy workflows need Node 22+: the suite uses
Promise.withResolvers.
Every alarm above this line runs inside whatever breaks. The failure mail
(§2) is sent by the same Apps Script project whose deploy, OAuth scopes or
mail quota is the likely fault, and the frontend hides an outage behind its
cached board on purpose. .github/workflows/prod-probe.yml therefore watches
production from outside, every 6 hours at :40 — offset from the 4-hourly
refresh so a probe never lands while a crawl is replacing the board. It runs
one script, frontend/scripts/prod-probe.mjs, which imports the same schema
the app uses (§3) under Node's type stripping rather than keeping a copy that
could drift:
| Check | Passes when | Failure category |
|---|---|---|
pages | 200, <title> still contains 今日菜價, and a <script type="module"> is present — a Pages deploy that lost its bundle still serves a plausible shell | pages_down |
mirror | data/board.json is 200, matches the schema and was crawled < 8 h ago — the same bound the publish-side validator applies (§2). A 404 stays skipped, not a failure: a deploy that could obtain no mirror at all publishes without one on purpose, and the visitors it sends to GAS are covered by gas_board below | mirror_stale |
gas_board | the body is JSON (Apps Script answers platform errors with HTML and HTTP 200), matches the schema, stale === false, count ≥ 60, crawled < 8 h ago | gas_unreachable / gas_error / gas_stale |
gas_diag | ?action=diag answers JSON | gas_unreachable / gas_error |
gas_trigger | triggers includes refreshBoardCache | trigger_missing |
gas_incident | alert.incident_open === false | incident_open |
gas_history | history.items ≥ 60 | history_thin |
mirror_dispatch | the dispatch is working, or failed within the last 8 h of an accepted one | dispatch_failing (⚠️ only, never a page) |
mirror_dispatch is the one check that can never page. With no
GH_DISPATCH_TOKEN it is skipped — that is how the backend ships, and the
2-hourly cron is the mechanism (§2). With one configured, a rejection (an
expired PAT answers 401 on every crawl) costs freshness, not availability: the
cron still publishes and the mirror check above is what bounds how old the
file may get. So it is reported and named, with the property to look at, and
the run stays green.
What it reports is the silence, not a single bad POST: one rejection minutes
after an accepted dispatch is a missed deploy the next crawl retries, so it
warns only once nothing has been accepted for 8 h — two missed crawls at the
backend's 4 h cadence, which is also the age at which the mirror check itself
starts calling a board too old. A record with neither a known outcome nor a
timestamp is skipped rather than assumed healthy.
60 is BOARD_HEALTHY_ITEMS in board.schema.ts: a typical day publishes
~90 of ~100 defined items (§1), and MOA throttling a batch shows up as a board
that is complete enough to serve yet clearly short. It sits deliberately above
BOARD_MIN_ITEMS (30), the hard floor below which the backend refuses to
publish at all, so degradation is reported while the board still works.
Freshness is judged on generated_at alone — date is never compared with
today. The trading date legitimately stands still over weekends, holidays and
typhoon closures (§2, "Trading date vs. refresh time"), so a date-based check
would page a human every Sunday and be ignored by the second one.
Every scheduled request this repo makes retries — the probe's four checks
and the deploy's two mirror fetches (§8), all through scripts/gas-retry.mjs,
so there is one policy to tune. What differs is whose 404 it is: on Apps
Script a 404 is a cold start and is asked again, while on GitHub Pages it is
the definitive “nothing published here” and is final (isTransientStatic).
The two static checks matter more than they look, because mirror passing is
what softens an unreachable backend — a lone CDN blip there would withdraw the
softening and open the false alarm this policy exists to prevent.
Apps Script answers a cold start on /exec with a platform 404 HTML page, and
an account near its quota queues a request until the deadline expires — the app
itself makes three attempts for exactly this reason (§2). The probe made one,
so two cold starts in two days opened two prod-alert issues that the next run
closed again, with an e-mail each time. Reachability failures (a timeout, DNS,
404, 5xx) are therefore retried, and the alert says after N attempts so a
blip stays distinguishable from an outage. The shared default is 3 attempts
with a 2 s then 4 s backoff; the probe's two GAS checks widen that to 4
attempts, 5 s linear, because on 2026-09-15 a cold-start window outlasted the
default and opened one more self-closing issue. How long that actually takes
depends on the symptom: a cold-start 404 comes back in about a second, so the
default spent ~10 s and the widened budget spends ~35 s, while a request queued
against the 30 s deadline can stretch either to 3 × or 4 × that plus the
backoff (96 s and 150 s respectively). That deadline is deliberately no
shorter than the deploy's: the deploy still refreshing the mirror is what
softens an unreachable backend, so a probe that gave up sooner than the deploy
does would hold a queued backend green forever. A 200 is never retried, whatever its body: a stale board, a short
count, schema drift or a platform HTML page is evidence that doGet answered,
and a second attempt would only hide a real fault for a minute.
An unreachable backend does not page while the mirror is serving. The app
reads data/board.json before it ever reaches GAS (§3), so /exec answering
404 for a minute still leaves every visitor with today's prices. Two things do
degrade meanwhile: the drawer's trend sparkline is empty, and a search for a
crop the board does not carry answers 「服務忙碌中,請稍後再試」 instead of a
result. Both are bounded; neither is the board going dark. So
gas_unreachable — a request that never reached doGet — is reported as ⚠️
degraded rather than failed while the mirror is genuinely carrying
visitors: a row in the summary and a run annotation, no issue and no red run.
frontend/scripts/probe-verdict.mjs holds that one rule, and four guards keep
it honest:
useBoard serves
the mirror without asking GAS only while it is under BOARD_MAX_AGE_MS
(6 h) — not the 8 h the mirror check allows. A mirror in the 6–8 h band
passes its own check while every visitor falls through to a backend that is
not answering, and that band is exactly where an outage lands, because the
deploy cannot refresh the mirror while GAS is down. Above 6 h, the probe
pages.deploy-pages.yml refreshes the mirror from ?action=board, so it engages
when that endpoint is silent. A quiet ?action=diag beside a healthy board
has no backstop at all — the mirror would keep refreshing forever while
gas_trigger, gas_incident and gas_history sat at skipped and nobody
learned the baselines stopped publishing — so it pages. handleDiag does
real work per call while readBoard is a cache read, which is exactly how
diag fails alone.useBoard paints the stale mirror and
fetchBoard then replaces it with current prices, so what a late deploy
costs is the CDN fast path — a round trip and a GAS execution per visit —
not the board. That softening needs gas_board to be ok and to have
answered on its first attempt, inside BOARD_TIMEOUT_MS — the probe is
more patient than the app in two directions, and neither may count here. It
waits 30 s per attempt where the browser waits 12, so an answer a queued Apps
Script took 25 s to give is one every visitor timed out on; and it retries
four times over 30 s where fetchBoard spends three attempts in about 2.7 s,
so a board won on the fourth attempt is fast on arrival and still a board
nobody was served. Either way a stale mirror must still page. Both numbers
are in the gas_board row, so the issue says why. It stops at 16 h: the worst lateness this project has produced is
the 11.2 h deploy gap plus a 4 h crawl, and the margin above that is kept
small because every hour of it is an hour the CDN fast path is bypassed with
nobody told. That is a bound on the verdict, not on the alert: the probe's
own schedule is measured at a 6.6 h median and an 8.8 h worst, so a frozen
mirror can go unreported until about 25 h, against 17 h before any softening
existed. Past it the mirror is not late — #53's
froze while the deploys themselves kept succeeding, which no gap bounds. A mirror whose
is missing, unparsable or in the future is corrupt rather
than late and is never softened. Each softening requires the other path to
be healthy, so they are mutually exclusive and a run where neither serves
always pages.With no mirror published at all the mirror check is skipped, GAS is the
only path a visitor has, and its silence pages like any other outage. Contract
failures (gas_error, gas_stale) always page — something answered, and
answered wrongly.
A failing run comments on the open issue labelled prod-alert, and only
opens [prod-alert] <categories> since <date> when there is none (creating the
label on first use). It also folds any category it found into that title, so
the title always states what the whole incident covers — later runs only
comment, and the recovery rule above reads the title to decide what a degraded
run is allowed to close. A fully passing run comments 「recovered」 on that issue
and closes it, except for the categories a degraded run cannot vouch for
(above), which keep it open. So at most one alert is ever open: a fresh issue every 6 hours
would bury the first one and train its reader to ignore the label — the same
reason the e-mail alerting has an incident window. The job also goes red
whenever the probe did, and appends the summary table to the run's step
summary. It needs no secret; every endpoint it touches is public (§2).
cd frontend
node --experimental-strip-types scripts/prod-probe.mjs # writes probe-result.json, exit 1 on anything that pages
npx vitest run scripts/ # the verdict rules, and the probe end to end
The flag is required on Node 22.6–22.17 and a no-op from 22.18 on. A degraded
run exits 0 — the exit code is the paging decision, not a health score, and
probe-result.json carries the per-check detail either way.
workflow_dispatch takes pages_url / api_base_url inputs, so the alert
path can be rehearsed against a deliberately bad URL instead of waiting for a
real outage. Use pages_url: a bad api_base_url alongside the real, fresh
mirror is precisely the case the degraded rule absorbs, so it now ends green
with two ⚠️ rows and no issue — which rehearses the softening, not the
alert. To exercise the alert through GAS, point pages_url somewhere with no
data/board.json, so the mirror check is skipped and nothing is softened.
Not UptimeRobot or a similar service: Actions is already free here, and what has to be verified is the schema and the freshness rather than an HTTP 200 — a dead pipeline serves a perfectly healthy-looking board.
The plan lives in issue #24, which tracks every improvement with its rationale and order; this list is only what is still open there, so the two cannot drift.
tools/region-spread (§7) measures it: the spread between regions, the
sample each one would have, and how much of a regional change-percent is
market mix rather than price.sort_changed, §6).Product), built from the static mirror (§2) at build time.doPost is reserved).MIT.
doGet?action=backfill&token=… — one-time seeding of the price history
via range queries (force=1 jumps a one-hour lock); also a one-off
trigger, same reason. Operator only. A force sent while a crawl is still
running starts a second run a second later, alongside it — not after it:
each run's cleanup deletes only the trigger it fired from, so the one
in flight can no longer cancel the one just started (#90). warm&force
works the same way.doGet?action=diag — board freshness, installed triggers, the last refresh
outcome, history coverage and alert state, so a stalled pipeline is
diagnosable without the GAS console. Public; the last failure's raw reason
is reduced to a category unless the token is supplied.doGet?action=alerttest&token=… — sends one probe mail (rate-limited to
one per hour) so the alerting channel can be verified without waiting for
an outage; it never touches incident state. Operator only.Operator token. The Web App is anonymous by necessity — browsers call it
— but warm&force, backfill and alerttest each start a crawl or a mail on
demand. Left open, force=1 alone lets anyone bypass the lock and burn a few
hundred UrlFetchApp calls per hit until the daily quota is gone and the
board stops updating. Those actions therefore require &token= to equal the
ADMIN_TOKEN script property (isAdmin, a constant-time comparison that
fails closed when the property is unset). A refusal is a plain
{ "error": "unauthorized" } that writes nothing, so the refusal path cannot
itself be made expensive. Nothing a shopper uses needs the token.
Frontend (frontend/): React + Vite + TypeScript + Tailwind + shadcn/ui.
Paints the cached board immediately and revalidates in the background, so a
revisit renders in ~250 ms instead of waiting out the ~2 s GAS round trip.
Every request carries a deadline (board 12 s, search 15 s, trend 8 s) because
an over-quota Apps Script queues requests rather than failing fast, and a
queued request would otherwise hold the loading skeleton for a minute. With
VITE_API_BASE_URL unset it uses a bundled sample board and runs fully offline.
recharts is code-split: it was roughly half the initial JS while serving
one element inside the detail drawer, so the board — which most visits never
leave — no longer pays for it (initial JS 560 → 274 kB, gzip 174 → 88 kB).
The drawer warms the chunk on open, in parallel with the trend request, so
the split costs no perceived latency. The board schema §3 validates against
is the only other runtime addition to that chunk, at 5.7 kB gzip — which is
why it uses zod/mini rather than the classic API (295 kB / 94 kB today).
Installable, and openable with no signal. vite-plugin-pwa (1.3.0, which
does support Vite 8) emits a Workbox service worker plus the manifest, so the
app installs to a home screen and launches standalone. The worker precaches
the shell only — index.html, the hashed JS/CSS, the icons and the
manifest, ~655 KiB — because that is what was missing offline: the last board
was already in localStorage, but the app that renders it came off the network,
so airplane mode meant a white page. The prices still come from localStorage;
the worker adds nothing to that path.
What is deliberately not cached is as important. The GAS /exec endpoint
is NetworkOnly: Apps Script answers platform errors with an HTML page and
HTTP 200, so a single bad moment stored under that URL would be served as
"the board" until the cache was evicted — and the localStorage fallback is a
strictly better answer anyway. gtag is NetworkOnly too (analytics must
never be replayed from a cache, and never block a load), and the 78 kB
social-card image is excluded, since only LINE and Facebook ever fetch it.
Two things do get a runtime cache: the static board mirror
(data/board.json, stale-while-revalidate, one entry) and Google Fonts
(cache-first, 10 entries / a year). Navigations fall back to the precached
shell, except under data/ — answering a JSON request with HTML would hand
the fetch a document to parse as a board.
Updates are offered, never taken. registerType: 'prompt', so a new
deploy installs and waits; one line appears at the bottom
(「已更新,重新整理看新版」) and the worker takes over only when the user taps
重新整理. An automatic reload would tear the drawer out of the hands of
someone reading a price in front of a stall, and hashed filenames plus the
prompt bound the risk of a stale worker to "at most one version behind".
稍後 only hides the line; the update lands on the next natural load. The
worker is its own file, so the initial JS carries only the registration and
that one line (+0.8 kB gzip); workbox-window is a lazily imported chunk.
While navigator.onLine is false the degraded board's 重試 reads 離線中 —
the button stays tappable, since that flag describes the interface, not the
internet.
Next: true; the oldest day left can be missing
markets, and an average built from it is simply wrong. The window is halved
until each piece is whole, and a single day that still truncates leaves that
crop out of that day — missing is honest, wrong would be permanent.
If the probe root loses a day that way it still counts as trading, so the
next day is not judged against the one before it; any crop left out of a
day is withheld from the next one too, which has nothing to judge it by.
(calibrate has always refetched. The rolling history's seed crawls every
window in one execution, where open-ended refetches could push it past the
limit, so a cut root gets exactly one more request for what was cut; the
trend runs on the public path, where one request is the budget, and leaves
a cut oldest point out. Both used to average whichever markets MOA left
in.)failed. A
link is counted before it works and arms a watchdog trigger for after the
execution limit, because one the 6-minute limit kills never reaches its
catch or its finally — so the watchdog retries it, and a window that is
always too slow ends as failed too, rather than stalling for ever. Asking
again with the same months= resumes a failed job, or a running one that
has not moved for 15 minutes, from its cursor; a different reach replaces
it (once its last link cannot still be running and write it back);
cancel=1 stops it after the current window, and is kept in a property of
its own so the chain's next write cannot undo it. Requests are serialised
under the history lock, so two at once cannot start two chains.holes), which a
later job crawls again; a job with more holes than it can keep (40) claims
no coverage of its own rather than forget one; and only on the same spreadsheet;
pointed at a new one, nothing is skipped, and a running job whose
HISTORY_SHEET_ID changes under it ends itself as failed rather than
carry its cursor into another sheet (cancel=1 works with the id cleared). Re-running a year would crawl ~40
windows to write nothing; a new months= steps over the range the jobs
before it covered without a request, so extending 12 months to 24
crawls only the new year. months must be a whole number (1–24; more is
clamped) — months=0 is refused rather than read as a year. To redo a range
on purpose — after deleting rows by hand — delete the veggie_sheet_backfill
property.trend_result | outcome (ok / empty / failed), reason | Whether the trend deadline is right; memo hits are not reported |
chunk_failed | chunk | Cost of the code split |
board_schema_mismatch | path | Whether the backend's payload has drifted from the contract in §3 — a nonzero rate means some field is quietly missing from the UI while the board still renders |
?action=backfill&sheet=1&months=12&token=…diag.sheet_history.backfillinstallDailyTrigger() once in the editor — it installs the refresh
trigger on REFRESH_INTERVAL_HOURS and warms the board so the first visitor
never hits a cold crawl. Confirm with ?action=diag: triggers must list
refreshBoardCache. Running it also grants the mail scope the alerting
needs; ?action=alerttest&token=… confirms a mail actually arrives.?action=backfill&token=… once to seed the baseline history (the crawl
takes a few minutes), then confirm diag.history.items is non-zero. Until it
is, the board simply ships without baseline fields and the UI hides the badge
and the 划算優先 sort./exec URL in frontend/.env as VITE_API_BASE_URL.scripts/gas-retry.mjsstaticmirror: fresh | reused (stale) | none
reason: board ok: 94 items, traded 2026-09-03, crawled 3.3 h ago (ok after 2 attempts)
.github/workflows/deploy-gas.yml (optional):
otherwise deploy manually with clasp (§7). The workflow runs lint and the
backend regression tests, pushes, redeploys the pinned DEPLOYMENT_ID —
without that step /exec keeps serving old code — and then queues a board
refresh via ?action=warm&force=1&token=…, reading the token from the
GAS_ADMIN_TOKEN secret (the same value as the ADMIN_TOKEN script
property, §7). Apps Script always answers HTTP 200, so the step checks the
body: forced: false with the secret set means the secret and the script
property have drifted apart, and the job fails there rather than let the
new deploy serve the old board. Without the secret it still queues a
refresh, just subject to the 15-minute lock. It authenticates to Apps Script
with one of two repo
secrets (Settings → Secrets and variables → Actions), and skips the deploy
with a notice when neither is set:
GCP_SA_KEY (recommended): a GCP service-account JSON key, base64-encoded,
for an account that has edit access to the Apps Script project. The
workflow decodes it into gcp-sa-key.json inside the checkout, which the
root .gitignore covers for exactly that reason.CLASP_TOKEN (simpler, less safe): the refresh_token from your local
~/.clasprc.json after clasp login. It is your personal OAuth grant, so
rotate it if the secret ever leaks.generated_atBOARD_HEALTHY_ITEMS. A throttled MOA batch is normally caught by
gas_board's count guard, which during an outage never gets a body to
measure.gas_error.ok, derived from
the run rather than assumed — and a degraded check is not ok. So a run
with a silent backend closes an alert naming only pages_down or
mirror_stale, while one with a late mirror holds mirror_stale open and
closes pages_down. Whatever a run did not measure stays open with a
「still degraded」 comment: gas_board got no body, the three checks behind
diag are skipped, and an over-quota backend moves between answering
wrongly and not answering at all.
The same rule catches a quieter case — a deploy that published no mirror
leaves mirror at skipped, which is no evidence that a mirror_stale
alert recovered, so it is held open as 「not verified」. A title that does
not name known categories is read as covering everything, and closes only on
a run that measured the lot.