/amlich converts duong lich to am lich (defaults to today, Asia/Saigon);
/duonglich converts am lich to duong lich with a nhuan flag for leap
months. Dates accept d, d/m, or d/m/yyyy - missing parts fill from today
in the input's calendar. Conversion is a dependency-free port of Ho Ngoc
Duc's algorithm at UTC+7, with rejection of impossible lunar inputs,
anchored by known Tet/leap-month dates and a 1950-2050 round-trip test.
Swap the lol module upstream from the lolesports.com gql persisted-query
client to PandaScore REST (/lol/matches, Bearer LOL_PANDASCORE_TOKEN,
free tier 1000 req/h). The gql transport broke whenever Riot redeployed
their frontend; PandaScore is a stable versioned contract.
ScheduleEvent, formatters, cron, and the bson cache shape are unchanged:
only the transport and response mapping moved. PandaScore league slugs
canonicalize to the existing major-league allowlist; results join to
opponents by team_id so reversed arrays cannot swap scores; outcomes are
declared only once upstream commits a winner, preserving the
score-pending rendering. A still-full final page now logs
lol_page_budget_exhausted and the live page budget covers 500 raw
matches per window.
Missing token short-circuits with lol_token_missing before any upstream
call; the 60-minute stale cache still covers outages. Document the new
env var and cancel the superseded Leaguepedia score-enrichment plan.
Riot retired esports-api.lolesports.com and revoked its public x-api-key
(403 for everyone), so fetch schedules from the lolesports.com /api/gql
gateway instead using the web client's registered homeEvents operation.
The persisted-query ID comes from the frontend's operations manifest;
the package doc records how to refresh it when Riot rotates it, and a
rotated ID logs lol_persisted_query_rotated instead of caching an empty
schedule. Date windows are padded a day each side and filtered to exact
instants locally because the gateway's date params have unspecified
timezone semantics.
Repeated /stock_portfolio calls stacked a random-token pending key per
call, and applying the dividend cleaned up only the pressed one — the
rest kept live buttons until the TTL sweep. Keys are now deterministic
(pending-dividend:<userID>:<eventID>) so re-suggesting overwrites the
previous action, retires the old message's button, and applying leaves
exactly one key to delete. Callback data carries <userID>:<eventID>
instead of a token; presses are still validated against the stored
owner, chat, and message binding.
Cash dividends are a return of capital: the payout still credits the
VND balance, and now also lowers the position's remaining cost basis
(floored at zero) so the ticker's unrealized P&L reflects dividends
already received. Zero basis is now a valid open-position state;
negative basis remains invalid. Share dividends are unchanged.
Replies with a hashtag line led by #MonkeyD, then a blank line and the novel
URL, sent as a code block so it can be copied in one tap. Each genre label
becomes one hashtag with spaces and punctuation stripped and every word
capitalised, since Telegram ends a hashtag at the first character that is not a
letter, digit, or underscore. Diacritics survive; a label with no letters is
dropped rather than emitted as a bare hash.
The command costs one request and runs inline under a short timeout rather than
in the background: handlers are dispatched one at a time, so a stalled fetch
would block every other command. It shares the export page cache.
Advance the submodule to the tag parser, which matches itemprop="genre"
microdata so the site-wide genre navigation stays out of the result.
/monkeyd_crawl <url> [font_size] sets the body text size in points, half points
included, bounded to 6-24. Omitting it sends no size at all so the crawler's
default applies, rather than defining a second default here that could drift.
The document caption reports the size used.
Also advance the submodule to the lower 10pt default: on the 90x160mm phone
page that fits about 43 characters per line instead of 36.
Advance the crawler submodule to the fix for the production failure "stat
usr/share/fonts/...: no such file or directory": the PDF writer was handing
fpdf a font path, and fpdf rewrote the absolute path into a
working-directory-relative one. Font data is now passed as bytes, with a
fallback font compiled into the binary when the host has none.
The runtime image therefore no longer installs DejaVuSans, which also removes
the font layer from the builder stage.
/monkeyd_crawl becomes public. The host allowlist and the single in-flight
export were already the controls that bound its cost; they now carry that job
alone, so both are load-bearing.
Add the monkeyd module, which crawls a novel and sends the rendered PDF back
as a Telegram document. Crawling and rendering come from the monkeyd-crawler
submodule, resolved through a go.mod replace directive.
The command is admin-only and restricted to monkeydd.com: one run makes
hundreds of outbound requests over minutes, and the extractor only understands
that site. Exports run one at a time and on a detached goroutine, because
handlers are dispatched synchronously and an inline crawl would block every
other command.
The runtime image gains DejaVuSans; font discovery probes system paths and the
distroless base ships none, so PDF rendering would otherwise fail in
production. CI checks out submodules and the builder copies the submodule
go.mod before go mod download, which needs it to resolve the build list.
Draw the winner outline as an unfilled pie after every slice: slices paint
in index order, so the next one covered the shared radial edge and the
outline stopped short of wrapping the wedge.
Derive the announcement colors from the winning slice - its own color as
the chip, a deepened version as the frame and the wedge outline - instead
of a fixed dark chip that read as unrelated to the wheel.
Raise the confetti above the announcement so the burst is no longer hidden
behind it, and steepen the launch fan so the field arcs around the name
rather than settling on it: horizontal reach is vx / drag, which parked the
flat fan at mid-canvas. Measured over the hold, particle samples inside the
pill drop from 22.4% to 6.2% while disc coverage holds at 85.5%.
Research: after lolesports began marking matches completed without
publishing results, evaluated independent score sources. Established
that wrappers of lolesports (lolesportsapi.com, Pupix, Apify) inherit
the gap, and that getCompletedEvents/getStandings are equally stale, so
only a source with its own ingestion helps. Leaguepedia is the only free
candidate; commercial providers run $2k-10k/mo.
Plan: 4 phases for filling missing gameWins/outcome from Leaguepedia's
Cargo API. lolesports stays authoritative for schedule and for any score
it does publish. Additive throughout - every failure path degrades to the
existing "score pending" line.
Joins on team name plus date rather than team codes: Riot already sends
Team.Name matching Leaguepedia's Team1/Team2, which removes the code
mapping table and the per-split OverviewPage handling that the research
had flagged as the main maintenance burden.
Phase 1 is a hard gate. Fandom rate-limited and then refused the
connection during research, so it was never confirmed that Leaguepedia
actually holds the scores Riot is missing. That check can cancel the plan.
Plan is pre-validation. A verification pass found three items not yet
recorded in the files: slices.ContainsFunc in phase 4 has no precedent in
this repo (explicit loops are the house style), the TTL index citation
should read startup.go:35-52, and the Bo2 tie handling in phase 3 is
unnecessary - all 26 allowlisted-league events are Bo3 with zero ties.
lolesports drives event.state off the broadcast timeline but fills
result.outcome and result.gameWins from a separate per-game ingestion
path. A match therefore reads as "completed" for hours before gaining a
score, and in that window every team carries {"outcome": null,
"gameWins": 0}.
The renderer used `Result != nil` as its has-a-score test, which cannot
catch this: the result object is present, only its contents are empty.
The absent gameWins fell through to Go's zero value and printed as a
literal 0, so an unscored series was reported as a definitive draw
("MKOI 0-0 KC") with neither side bolded.
Gate the score on a declared outcome instead, and render unscored
finished matches as the matchup alone with a pending marker. An outcome
on either side is enough to trust the score - it proves the ingestion
ran. inProgress keeps rendering 0-0, which is truthful for a live series
that has not resolved its first game.
Also collapses the score extraction both branches duplicated into
shared helpers.
Three advisory reviews with measured evidence: the confetti celebration, the
wheel and theme design, and the API/operator experience. The API findings are
untouched work - broken render:local CLI examples, 400/413/415 conditions
surfacing as 500s, an unenforced timeout guarantee, and a production token
default in compose.yml.
The refinement plan covers the second pass and records what was deliberately
excluded, including the bezel, the font-size cap, a night theme, and narrowing
the request schema.
README described confetti as reusing theme slice colors and made no mention of
the winner callout or the reveal timing.
Fill in the benchmark rows that were TBD, and record where the GIF growth
actually came from. Isolating each change shows the celebration rework, pill,
spin retune, palette reorder and pointer together account for +3.3%, while
grayscale label antialiasing accounts for +22.4% on its own because it affects
every spin frame rather than only the celebration. That trade is deliberate:
it removes visible color fringing and stops the palette being exhausted.
Also correct the size levers. Particle count is not one - cutting 43% of the
particles changes the file by 1.1%. Note that render times on this machine vary
more than 2x across repeat runs of identical input, so single timings are
indicative only.
The animation's weakest moment was its final frame, which is also the frame
most chat clients show as the GIF's poster image.
Celebration:
- Confetti launches from two cannons framing the wheel instead of bursting
inside the hub, using closed-form drag ballistics so chips decelerate and
flutter rather than flying a clean parabola. The field previously drained
from 70 particles to 3 before the GIF ended; it now stays populated, and a
2500ms hold ends with 68 on screen instead of 0.
- The opening frame rendered at opacity 0, throwing away the one frame where
particles are still clustered. Removed the fade-in and made the tail
frame-relative and quantized to bound its palette cost.
- Chips streak along their direction of travel in proportion to speed, faking
the motion blur a GIF cannot carry. A second volley, sized to the
celebration window, keeps long holds from thinning out.
- Each theme carries its own confetti palette. Slice colors are tuned to hold
dark text over a large area, which left five of six mono colors under 2.4:1
against the background and invisible at particle size.
- The winner's name appears over the hub during the hold. At a 200px chat
display the pointer is ~17px and a slice label ~9px, so the poster frame did
not say who won.
- Winner emphasis is an opaque stroke, not a drop-shadow. A filter on a Pie
paints outside the shape, so later siblings overpainted it and only one
neighbour ever showed the halo.
Motion:
- Turn count scales with the frame budget and slice width. A fixed 5 turns put
peak speed at ~40deg per frame regardless of option count, so 65 of 98
frames advanced more than a full slice at 32 options and the wheel appeared
to run backwards. Turn counts floor rather than round, since rounding up
reintroduces the aliasing, and stay integral so the winner still lands on
the detent.
- The spin launch now blends into the deceleration at a matched slope. The
previous curve started at maximum speed, and a naive ease-in jumped from 18
to 41deg per frame in a single frame.
- A damped recoil rocks the wheel into its detent, resolving to exactly zero
so the landing and the flapper's rest position are unaffected.
- The pointer is drawn as SVG with a real outline and a tick as slices pass.
clip-path had been discarding its border and shadow entirely.
Legibility:
- Labels flip by their rest-frame angle so every name reads upright in the
final frame. This reverses an earlier decision to leave them slice-aligned;
three of eight names rendered mirrored on a default wheel.
- Label text renders inside a promoted compositing layer, which switches
Chromium from LCD sub-pixel to grayscale antialiasing. Sub-pixel fringes put
2178 colored pixels into an all-grayscale theme and consumed 255 of 256
palette entries. Promoting the one rotating container rather than each label
keeps render time flat on dense wheels.
- Line boxes leave room for stacked Vietnamese diacritics and shrink to fit
rather than clipping.
- Slice colors are ordered for colorblind separation, and the last slice
avoids reusing the first slice's color when the option count leaves a
remainder of one.
Update three transitive dependencies to their patched releases (fast-uri 3.1.4, find-my-way 9.7.0, postcss 8.5.22), clearing 3 high-severity Dependabot alerts. All were within existing parent semver ranges. Verified with pnpm audit (clean), lint, typecheck, 58 tests, and the API render smoke test.
Route the CLI through the shared winner picker instead of crypto.randomInt so the positive-integer guard and selection intent stay consistent with the API.
Drop prettier (no config, script, or eslint integration), the unused render-semaphore state() method and CreateRenderSemaphore typedef, and ignore .claude/settings.local.json.
The guard compared import.meta.url against 'file://'+argv[1], which never matched on Windows (mismatched slashes and drive prefix), so the process started without ever calling app.listen(). Compare import.meta.filename to argv[1] instead.
Deterministic frame-based confetti fired from the wheel center during the winner hold, seeded by winner index and colored from the theme palette. Uses Remotion's random() so each GIF frame renders identically.