406 Commits
Author SHA1 Message Date
tiennm99 f2a1012981 fix(misc): make /xlt1 public
Filing the petition is the group joke, so it should not be admin-gated
the way /ff is. Its denial test becomes a non-admin allow test.
2026-09-02 16:16:20 +07:00
tiennm99 dfcec430b7 docs: add đơn xin lỗi research behind the /xlt1 template 2026-09-02 16:03:36 +07:00
tiennm99 317088ae92 feat(misc): add /xlt1 đơn xin lỗi T1 petition template
Sequel to /ff: the same fan who panicked and surrendered mid-series now
files an administrative petition because the team came back. Punchlines
are quoted back as the sender's own words and retracted rather than
asserted fresh.

Shaped as a Vietnamese đơn từ parody — quốc hiệu, Kính gửi, Nội dung sự
việc, Tôi xin cam kết, signature block — since the joke is the
bureaucratic form, not the wording. Carries no scoreline or title count
so it does not go stale next split.

Reuses senderMention for the Tôi tên là field and the signature, which
made the trongTruongHopUpdate test helper name wrong; renamed it
messageFrom now that two command families share it.
2026-09-02 16:03:20 +07:00
tiennm99 51b2082799 feat(misc): hold the wheel spin with a placeholder message
A remote wheel render takes several seconds, during which /wheelofnames
looked unresponsive. Post "Spinning..." first, then let the result take
its place: the GIF replaces it (send-then-delete, since Telegram cannot
edit text into media) and any render or upload failure edits the same
message into the plain text winner. A rejected edit still falls back to a
fresh reply so the chat never stays stuck on "Spinning...".

No placeholder when no renderer is configured — the winner reply is
already immediate there and would only flash.

Adds SendText/EditText/DeleteMessage to chathelper; Reply now delegates
to SendText.
2026-08-27 14:42:07 +07:00
tiennm99andGitHub d6d4ec7bbe Merge pull request #4 from tiennm99/feature/sticker-pack-module
feat(sticker): sticker pack module
2026-08-25 17:20:14 +07:00
tiennm99 20c6913f7c docs(plans): add round 6-7 verification reports 2026-08-25 17:19:04 +07:00
tiennm99 562e43974f test(sticker): pin the delpack authority guard, keep the stored set name
The previous commit's regression test never reached the guard it was
named for. It broke the pack record with dropPackRecord, which now also
clears the confirmation, so the callback returned at the pending.Get miss
long before the allowlist. The test passed with the entire guard
reverted - shipping the fix with its own detector inoperative, which is
the defect that let five earlier rounds report a false clean.

Replace it with a table that leaves the confirmation intact and breaks
the record three ways, one per disjunct: record gone, record unconfirmed,
record moved on. Reverting the guard now fails two cases; each disjunct
was mutated individually.

The !found disjunct is an equivalent mutant: ownsSet already returns
false for a zero-value record's empty Name, so no test can kill it. Kept
and commented, because that redundancy is an accident of ownsSet's
empty-string guard rather than something this check should rely on.

Also revert the set-name half of the previous commit's resume change.
Carrying the retyped title is right; re-deriving Pack.Name was not. The
name comes from the bot username, which can change at BotFather, and the
stored one identifies the set the interrupted attempt may already have
created - refreshing it orphaned that set and aimed later commands at a
different name, contradicting ownsSet's own documented rule. Pinned.
2026-08-25 17:09:19 +07:00
tiennm99 c83bcfaebf fix(sticker): prove authority before a confirmed pack delete
A /delpack confirmation outlived the record that authorised it. The
under-lock re-check listed the states it would refuse - a pending record
still naming this set - and fell through on the two that mattered: no
record at all, and a record that had moved on to a different pack.

Reachable with ordinary commands and no attacker: run /delpack without
pressing, let the pack disappear from Telegram's side so a self-heal
frees the name, let another user claim it, then press. DeleteStickerSet
is keyed by set name, which Telegram authorises for every set this bot
created, so the press destroys whoever holds the name at that moment.

Invert the guard: delete only when a confirmed record still names this
exact set. A check phrased as "which states do I refuse" cannot fail
closed against a state nobody enumerated. Dropping a pack record now also
clears any outstanding confirmation, so a dead prompt stops existing
rather than merely being refused on use.

This also stops the reservation leaking when a confirmed delete lands on
a record that has moved on, since that case no longer reaches Telegram.

Alongside:

- Resuming an interrupted /newpack discarded a retyped title and reported
  success quoting the old one.
- TestNewPack_DifferentSlugReplacesDeadIntent was named for releasing a
  dead name and never asserted it.
- lockUser's comment justified the lock with cron and stats-hook
  contention that does not exist: the map is state-local and this module
  registers neither. The lock stays for the read-modify-write pattern; a
  wrong reason for a right guard misleads the next reader.
2026-08-25 16:49:37 +07:00
tiennm99 c4c8c3088e fix(sticker): never adopt an existing pack
Two ordinary /newpack commands could take over a stranger's pack. The
first probe returns an inconclusive error, which correctly keeps the
reservation so the user can retry - but that turned a fresh claim into a
resumed one and defeated the guard that made adoption conditional.
resolveStaleIntent had a second adopt path that never consulted the
guard at all. Both are reproduced by tests added here.

This is the fourth failure of the same mechanism, and it is structural.
Adoption must prove "this set is mine to finish" from local state, and
local state is what a restart on the in-memory backend erases while the
packs at Telegram survive. With the proof gone, a genuine interrupted
attempt and a stranger naming a public share link are indistinguishable.

Remove adoption entirely. /newpack refuses any name a set already
occupies, and leaves no intent or reservation behind when it does.

A pending record is not evidence of ownership either: anyone can make one
naming any set, and DeleteStickerSet is keyed by set name, which Telegram
authorises for every set this bot created. /delpack therefore clears a
pending record locally and contacts Telegram only for a confirmed one.

The cost is that a crash between creating a set and recording it strands
that set. That is documented rather than mitigated - every mitigation
available is the mechanism that just failed.

Also drop a test whose name claimed to pin the resumed-reservation
distinction but bailed past the code that implements it, rename a delpack
test after the guard that actually stops a foreign presser, and pin
releaseSlug's ownership check and detached read - the latter needed a
context-honouring store, since the in-memory one ignores cancellation and
made the first version of that test vacuous.
2026-08-25 16:28:11 +07:00
tiennm99 3dd0c6ccd4 docs(plans): record sticker module delivery and review findings
Mark phases 1-5 done and phase 6 partial: the code and docs are
complete, but the live-token smoke checks and the deployed MODULES
change are not, and three questions about Telegram's own behaviour stay
open (file_id reuse across sets, the literal STICKERSET_INVALID string
the self-heal paths match on, and whether a deleted short name is
reclaimable).

Add the three-lens review pass to the revision log, and correct two
phase-03 checkboxes that shipped code contradicts - one of them ticked
against text the file's own superseding note already retracted.
2026-08-25 15:54:48 +07:00
tiennm99 4e805f0a7f feat(sticker): add sticker pack module
Nine commands mirroring the names @Stickers uses: /newpack, /mypack,
/addsticker, /delsticker, /editsticker, /ordersticker, /setpackicon,
/renamepack and /delpack, plus a confirm callback for the destructive
one. Sources are replied stickers, photos or image documents; photos are
downloaded, resampled to 512px and re-uploaded.

One pack per user, keyed by owner id. Creating a pack is the only
operation here that makes a durable, publicly linkable object on a user's
behalf, so it is built around proving ownership rather than assuming it:

- A name is claimed globally and create-only before Telegram is called.
  A pending record alone proves only that a caller *asked* for a name,
  which is exactly what someone naming a victim's public slug also does.
- Adopting an existing set additionally requires that the claim predates
  this invocation. The claim lives in our store and the pack lives at
  Telegram, so a wiped store would otherwise make every pack adoptable.
- Names are released only on positive evidence that no pack stands behind
  them, never on a generic failure, so a transient error cannot hand a
  live name to the next caller.
- Ownership refusals are byte-identical across failure modes, so they
  cannot be used to probe which sets exist.

Error classification is positive-only throughout: "the set is gone" and
"nothing was created" are each proven from a specific Telegram response,
never inferred from an error. Post-action commits run on a context
detached from the request so a shutdown mid-handler cannot lose the
record of something Telegram already did.

Enabled explicitly via MODULES rather than by default.
2026-08-25 15:54:28 +07:00
tiennm99 6baa2bfe72 style(wordle): gofmt lookup test table 2026-08-25 15:54:12 +07:00
tiennm99 12d7b4f5ee test(testutil): stub struct results and coded failures in the recording bot
Three gaps made parts of the Telegram API untestable:

- Methods that decode into a struct (getStickerSet, getFile, getMe,
  uploadStickerFile) only ever saw `{"ok":true,"result":true}`, so they
  could return nothing but unmarshal errors. StubMethod supplies a real
  result payload.
- The library classifies errors from the error_code in the response
  body, not the HTTP status, so a codeless failure never took a sentinel
  shape. FailMethodCode emits the code, letting handlers that branch on
  errors.Is be tested at all.
- Parameterless calls send no body, and the unconditional form parse
  rejected them before any stub applied.

The parse tolerance is scoped to an empty body rather than to any parse
failure: multipart reports "no parts" for both an absent body and a
corrupt one, and answering a corrupt request 200 with an empty form
would quietly satisfy tests elsewhere that assert a field is absent.
2026-08-25 15:54:12 +07:00
tiennm99 24f0cde1b3 feat(modules): recover panics in command and callback dispatch
The bot runs with WithNotAsyncHandlers and a single worker, so handlers
execute inline on the polling goroutine. A panic in any handler therefore
killed the process and took every user's bot down with it.

Wrap the command closure, the callback closure and the detached command
hook in a recover barrier. The callback path also answers the pending
query so the client stops spinning rather than waiting out its timeout.

The barrier is a backstop, not a licence to skip nil checks: handlers
still guard their own inputs.
2026-08-25 15:54:01 +07:00
tiennm99 e56f5c5d7d docs(plans): revise sticker packs plan to one pack per user
Every command except /newpack drops its <pack> argument; the caller's
single pack is resolved implicitly. /packlist becomes /mypack, and the
store key is the user ID alone.

Resolves rather than mitigates the worst red-team finding: /packlist's
N+1 plus ten GetStickerSet calls under a 60s per-call ceiling is gone.
/mypack is one Get and makes no API calls. resolveOwned collapses to a
single Get. Both prior open questions are answered.

The slug survives on /newpack alone, where it fixes the permanent share
URL. Derived-from-user-id and opaque-id schemes were rejected: the first
publishes the owner's Telegram ID forever, the second is unbrandable.

/delpack reframed as the only way to change a pack URL, since Telegram
exposes no rename-short-name method:

- /renamepack's reply names the delete-and-recreate route instead of
  only stating the link cannot change
- /delpack's confirm must state the title, the sticker count being
  destroyed, the link being surrendered, and that both are permanent
- /repack migration rejected for this plan: up to ~121 sequential API
  calls exceeds handlerTimeout and stalls the bot for all users under
  C1. Viable only after the Phase 5 offload; recorded as a follow-up

Fixes a bug in the write-ahead intent machinery: the different-slug
pending branch overwrote unconditionally, permanently orphaning a set
created before an interruption. It now probes GetStickerSet first and
adopts when the old set exists.

Adds R11 — whether a deleted slug can be reclaimed is undocumented and
unresolvable without a live bot. Does not block the URL-change path,
which needs a different name. Settled by a new Phase 6 smoke step.

Phase 1 unchanged.
2026-08-25 11:08:22 +07:00
tiennm99 9c3c5457f9 docs(plans): add sticker packs module plan
Plan for internal/modules/sticker: public, multi-pack-per-user Telegram
sticker set management using @Stickers command names, single-shot
reply+args instead of a conversational flow.

Six phases, 117 tasks. Phase 1 covers two shared-code prerequisites the
module would otherwise expose: no panic barrier on the update path, and
a test harness that cannot return structured API results.

Researched against the live Bot API and red-teamed by three adversarial
reviewers; 16 findings accepted, recorded in plan.md. Notable
corrections:

- MODULES is not opt-in; an empty value loads every module, so the
  factories() entry is itself the enablement
- getStickerSet exposing no owner does not force "orphans cannot be
  adopted"; a write-ahead intent record makes recovery sound
- the file-download URL embeds the bot token and reaches the dispatcher
  log through url.Error, which logging file_id does not prevent
- /packlist ran ten API calls under a 60s per-call ceiling, a worse
  stall than the photo pipeline the plan had been guarding
- the /delsticker probe deleted a live pack's record on any transient
  error
- /help has 884 runes of headroom for nine new commands
2026-08-25 09:32:22 +07:00
tiennm99 9fa2954123 docs: add amlich improvement research, brainstorm decision, and completed plan 2026-08-18 23:00:39 +07:00
tiennm99 dd1d1a2727 feat(amlich): hint ambiguous leap-month input and flag disputed month boundaries
- /duonglich appends a nhuan hint when the queried month is also that
  lunar year's leap month and the exact leap date exists
- both commands append a caveat when the result falls in a lunar month
  starting or ending on one of the seven razor-edge boundaries from
  2072 on (new moon within ~2 minutes of UTC+7 midnight)
- freeze the verified 1800-2199 month structure as golden testdata so
  a self-consistent engine change cannot silently shift boundaries
- close known-issues open questions 1 and 2
2026-08-18 23:00:31 +07:00
tiennm99 65495a1f32 docs: remove completed plans, superseded reports, and journals
All eight plans are completed or cancelled and their behavior is now
described in README. Three reports contradicted shipped code: one
recommended keeping the lolesports gql client over PandaScore, two
analyzed the transport that migration removed. The rest is
pre-implementation research whose conclusions live in the code.

Drop the conventions reference to the deleted schema research.
2026-08-16 22:47:32 +07:00
tiennm99 12d8eeb981 docs: correct stale module claims and document amlich edge cases
README omitted /lol_subscribe and /lol_unsubscribe, called gold opt-in
though an empty MODULES loads every catalog module, and left out both
the amlich 1800-2199 bound and the lol module's PandaScore token. The
gold factory comment repeated the same opt-in claim.

Promote the lunar algorithm decision record and known-issue list into
docs/ so they survive cleanups of plans/.
2026-08-16 22:47:24 +07:00
tiennm99 a94b783772 docs: add lol schedule research, tgs feasibility reports, and pandascore journal 2026-08-10 10:00:53 +07:00
tiennm99 1d398ef730 fix(amlich): extract continuity condition to satisfy staticcheck QF1001 2026-08-08 23:29:42 +07:00
tiennm99 70f914b2cb docs(amlich): add algorithm comparison, calendar rules research, and known-issues reports
Research and A/B evaluation of the truncated-Meeus port vs a from-the-rules
Meeus ch.49 implementation: 9 disputed lunations in 1800-2199, both
historically verifiable ones side with the current implementation. Decision:
keep truncated-Meeus. Known-issues report scoped to dates from 1970 onward.
2026-08-08 23:27:14 +07:00
tiennm99 8415191e1b fix(amlich): correct lunation overshoot returning lunar day 0 for 4 dates
The mean-cycle lunation estimate in solarToLunar can overshoot by one when
the target day falls just before a new moon whose UTC+7 calendar day rounds
forward; the reference implementation steps back only once and returns day 0
for 13/4/1877, 16/3/1885, 7/5/2054 and 9/4/2062. Loop the step-back until the
month start is on or before the target day.

Tests: extend round-trip to the full 1800-2199 range with day-continuity
checks, pin the 4 overshoot dates, anchor leap-month placement to published
tables (15 leap + 5 no-leap years), and pin the two razor-edge lunations
(20/6/1944, 7/7/1967) verified against published Vietnamese calendars.
2026-08-08 23:27:14 +07:00
tiennm99 0c30910547 feat(amlich): add Vietnamese lunar calendar conversion module
/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.
2026-08-08 22:40:03 +07:00
tiennm99 2b44395936 fix(wheelofnames): center spoiler winner between underscores instead of blank padding 2026-08-06 12:11:00 +07:00
tiennm99 068d34017b fix(wheelofnames): pad spoiler with figure space to match letter width in proportional font 2026-08-06 11:18:13 +07:00
tiennm99 3b61d61770 fix(wheelofnames): left-pad spoiler winner instead of centering 2026-08-06 11:09:26 +07:00
tiennm99 95574e0f1a fix(wheelofnames): center spoiler winner with nbsp padding instead of leading dots 2026-08-06 11:05:52 +07:00
tiennm99 6a41f4c845 fix(wheelofnames): pad spoiler result to longest option so length can't leak winner 2026-08-06 10:54:04 +07:00
tiennm99 277e5e1654 feat(lol): replace schedule source with PandaScore API
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.
2026-08-05 17:39:20 +07:00
tiennm99 cb0586d74c fix(lol): switch schedule fetch to lolesports.com gql persisted queries
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.
2026-08-05 16:29:25 +07:00
tiennm99 319c73ce82 fix(stock): key pending dividends by user and event to stop duplicate buttons
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.
2026-08-05 13:59:48 +07:00
tiennm99 0609889461 feat(stock): reduce cost basis when applying cash dividends
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.
2026-08-05 11:43:21 +07:00
tiennm99 81303e5fe6 feat(monkeyd): add /monkeyd_tags to report a novel's tags as hashtags
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.
2026-07-30 00:52:10 +07:00
tiennm99 345044f7de feat(monkeyd): accept an optional font size argument
/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.
2026-07-30 00:16:57 +07:00
tiennm99 85e07c7113 fix(monkeyd): make /monkeyd_crawl public and fix PDF font resolution
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.
2026-07-29 23:46:33 +07:00
tiennm99 d62e8a72b3 feat(monkeyd): export monkeydd.com novels as PDF via /monkeyd_crawl
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.
2026-07-29 23:07:23 +07:00
tiennm99 dbdbceda30 docs(lol): add leaguepedia score enrichment research and plan
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.
2026-07-26 12:57:56 +07:00
tiennm99 f151e68c0a fix(lol): omit score when a finished match has none published
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.
2026-07-26 09:16:39 +07:00
tiennm99 c34199c08b docs(stock): record stock info delivery 2026-07-23 12:27:12 +07:00
tiennm99 cba943d74d feat(stock): add detailed stock info command 2026-07-23 12:27:12 +07:00
tiennm99 5d1e676d72 docs(stock): finalize stock-events delivery 2026-07-23 10:08:47 +07:00
tiennm99 73ee9a1fa8 docs(stock): add stock-events plan artifacts 2026-07-23 10:05:44 +07:00
tiennm99 3d1a3d88bb feat(stock): add stock events lookup 2026-07-23 10:03:41 +07:00
tiennm99 26a6e7444b docs(stock): record dividend retirement workflow 2026-07-22 18:56:07 +07:00
tiennm99 02bef11e1c fix(stock): retire dividend command and migrate stats 2026-07-22 18:55:57 +07:00
tiennm99 9c0cdde83b docs(portfolio): document mobile column layout 2026-07-22 17:36:29 +07:00
tiennm99 2da872f237 fix(portfolio): tighten mobile column formatting 2026-07-22 17:36:29 +07:00
tiennm99 fda0bbd0b1 feat(stock): persist per-user dividend history 2026-07-22 16:17:29 +07:00