Compare commits

...
Author SHA1 Message Date
Prad Nukala 6f9e4ecb3b chore: clean up content phase organization 2026-09-25 14:00:22 -04:00
Prad Nukala 5dc2265ee5 chore(config): add GitHub repository metadata to docs config 2026-09-25 13:36:50 -04:00
Prad Nukala 0ca130156d chore: clean up content phase organization 2026-09-25 13:30:06 -04:00
Prad Nukala 0d64df58f1 chore: clean up content phase organization 2026-09-25 13:27:06 -04:00
Prad Nukala 88d1767184 docs: replace legacy line‑dot‑left‑horizontal icons with unified dot icon across docs 2026-09-25 13:17:35 -04:00
Prad Nukala 93381f2b69 style(cli): change default page icon to "dot" 2026-09-25 13:17:31 -04:00
Prad Nukala 58ab9db1d7 docs(docs): add page icon frontmatter to problem pages 2026-09-25 13:14:25 -04:00
Prad Nukala e189bb876e feat(cli): add page icon frontmatter support 2026-09-25 13:14:22 -04:00
Prad Nukala 314f2bff10 docs(docs): rename progress_1.mdx to keywords.mdx 2026-09-25 13:11:13 -04:00
Prad Nukala 082b67c44d docs(docs): add Keywords reference page 2026-09-25 13:10:59 -04:00
Prad Nukala ad7c0a2972 docs(readme): update documentation URL to lc.prd.lk 2026-09-24 16:26:18 -04:00
Prad Nukala 931c2c64be docs(docs): remove /leetcode prefix from homepage tile URLs 2026-09-24 16:26:15 -04:00
Prad Nukala 0a99557aac feat(config): update deployment site URL and reformat font variant definitions 2026-09-24 16:26:12 -04:00
Prad Nukala 7875b43981 docs(api): update docs URL and origin endpoints 2026-09-24 16:26:10 -04:00
Prad Nukala f34a9bb56d ci(git): sync redirects in pre-commit hook 2026-09-24 16:15:59 -04:00
Prad Nukala bfeadc0cdf docs(docs): add generated short URL redirects for LeetCode problem pages 2026-09-24 16:15:55 -04:00
Prad Nukala ca05c32f6e feat(config): add custom redirects to Blume config 2026-09-24 16:15:52 -04:00
Prad Nukala ad129b99fd feat(cli): generate short numeric redirects for problem pages 2026-09-24 16:15:49 -04:00
Prad Nukala 717feb7f70 build(config): switch to local Fontsource fonts for offline Blume builds 2026-09-24 16:07:17 -04:00
Prad Nukala bf0ac34a43 chore: clean up content phase organization 2026-09-24 15:57:38 -04:00
Prad Nukala 403a2d7f23 chore: clean up obsolete examples 2026-09-24 15:32:21 -04:00
Prad Nukala 3445c47ad2 docs(docs): change homepage icon to play 2026-09-24 15:17:19 -04:00
Prad Nukala 24a9c52260 docs(docs): add hidden index pages for algorithm topics and progress dashboard component 2026-09-24 15:17:08 -04:00
Prad Nukala 9d535c6826 feat(cli): preserve leading numeric prefixes in Blume group slugs 2026-09-24 15:17:04 -04:00
Prad Nukala a2d32c5f06 docs: add icons to topic meta definitions 2026-09-24 14:45:12 -04:00
Prad Nukala c69582552b feat(cli): add sidebar icons mapping for topics 2026-09-24 14:45:09 -04:00
Prad Nukala f0f479d6cf feat: add lc 46, 39, 78 2026-09-24 14:36:19 -04:00
Prad Nukala ac4dc654fc feat(work): add solution for LeetCode 323 number of connected components problem 2026-09-23 15:40:39 -04:00
Prad Nukala 273148c08b feat: add lc 684 2026-09-23 15:22:26 -04:00
Prad Nukala 7960dee431 docs(docs): adjust default zoom to 1.6× for improved label readability 2026-09-22 15:10:30 -04:00
Prad Nukala 0009075138 feat(work): implement Course Schedule II solution 2026-09-22 15:02:31 -04:00
Prad Nukala ca73d3235c feat(work): add Course Schedule II solution and clean up Course Schedule I implementation 2026-09-22 14:24:38 -04:00
Prad Nukala 97b62f95c9 docs(docs): detail custom MDX components and islands in docs overview 2026-09-22 14:11:37 -04:00
Prad Nukala 1a440e55ce docs(docs): add Carousel component and embed in topological‑sort page, replace static spine image with interactive component 2026-09-22 14:11:33 -04:00
Prad Nukala fd3b1952b1 docs(docs): add Topological Sort documentation page with example code and images 2026-09-22 13:28:23 -04:00
Prad Nukala 82c26e2b6c docs: update README URLs and fix sync bullet formatting 2026-09-22 13:06:27 -04:00
Prad Nukala f78ecb2bd4 docs(docs): add default order 0 to Array meta 2026-09-22 13:06:23 -04:00
Prad Nukala b3d3c5fbc9 refactor(cli): default group order to 0 and clarify meta comment 2026-09-22 13:06:20 -04:00
Prad Nukala 99a9248368 chore(githooks): add pre-commit hook to sync docs and stage generated pages 2026-09-22 13:01:32 -04:00
Prad Nukala 2a925f6a9a docs(githooks): replace pre-push hook with pre-commit and update documentation 2026-09-22 13:01:28 -04:00
Prad Nukala 7d57faa958 feat(work): add solution stub for Course Schedule (207) 2026-09-22 13:01:22 -04:00
Prad Nukala 26085cc027 docs(docs): add Word Ladder and Rotting Oranges documentation pages 2026-09-22 12:59:21 -04:00
Prad Nukala 216152d946 init: hooks 2026-09-22 12:59:13 -04:00
Prad Nukala d0ace85bbc docs(agents): add .githooks entry describing git hook enforcement and pre‑push sync check 2026-09-22 12:59:00 -04:00
Prad Nukala 5b99a6a589 feat(work): add Word Ladder and Rotting Oranges solutions 2026-09-22 12:58:56 -04:00
Prad Nukala 8446623607 chore(config): add prepare script for git hooks 2026-09-22 12:58:54 -04:00
Prad Nukala 3a1df27d30 docs(readme): replace static chart images with live SRS API badges and update description. 2026-09-22 11:58:58 -04:00
Prad Nukala ae1d1ceede feat(work): implement Pacific Atlantic Water Flow solution 2026-09-21 18:05:35 -04:00
Prad Nukala f5f31ba1c3 docs(docs): remove array order meta and add solution implementation for Pacific Atlantic Water Flow 2026-09-21 18:05:30 -04:00
Prad Nukala d1c99c307e docs(docs): add order to array meta 2026-09-21 17:41:19 -04:00
Prad Nukala 021b1fd06e docs(agents): update docs for topic folder naming and sync behavior 2026-09-21 17:37:31 -04:00
Prad Nukala edd37ed0e6 docs: rename category folders to remove parentheses and update heap meta title 2026-09-21 17:37:26 -04:00
Prad Nukala c2a8a31ede chore(config): remove Reference tab from navigation 2026-09-21 17:37:22 -04:00
Prad Nukala 891a1f0c5a refactor(cli): clarify topic folder handling and remove unused groupFolder helper 2026-09-21 17:37:19 -04:00
Prad Nukala 2e5d991d47 chore(config): bump multiple package versions in bun.lock 2026-09-21 17:16:31 -04:00
Prad Nukala 73d63c2565 docs(docs): clarify apps/docs layout and bump blume to ^1.7.3 2026-09-21 17:16:18 -04:00
Prad Nukala cafc8a4c25 feat(work): add solutions for number-of-islands, pacific-atlantic-water-flow, and clone-graph problems 2026-09-21 17:16:13 -04:00
Prad Nukala 7c5061dcd1 docs(docs): add meta files and reorganize topics with updated badges and directory structure 2026-09-21 17:16:10 -04:00
Prad Nukala f9e69960c4 feat(cli): add topic grouping with meta.ts generation in sync script 2026-09-21 17:15:56 -04:00
Prad Nukala 0c12df3eba docs(readme): update LeetCode profile link to progress page 2026-09-21 15:55:59 -04:00
Prad Nukala bfb53c14e8 docs(readme): wrap LeetCode badge in profile link 2026-09-21 15:54:29 -04:00
Prad Nukala 2c30b3d017 feat(problem-approach): add skill for generating approach diagrams 2026-09-21 15:53:32 -04:00
Prad Nukala 1084410832 docs(readme): add videos command and update schedule dates and badges 2026-09-21 15:53:28 -04:00
Prad Nukala 76c9c77307 feat(work): add Number of Islands solution 2026-09-21 15:53:24 -04:00
Prad Nukala 23eb71d387 docs(docs): add Approach diagrams and Explanation sections to LeetCode problem documentation 2026-09-21 15:53:21 -04:00
Prad Nukala b8c779903f feat(config): add videos script to package.json 2026-09-21 15:53:18 -04:00
Prad Nukala 5059eb72cb feat(cli): add videos script for NeetCode embed lookup and CLI command 2026-09-21 15:53:15 -04:00
Prad Nukala 204e178fa4 feat(work): add solution for LeetCode 211 design add and search words data structure 2026-09-21 12:50:53 -04:00
Prad Nukala 61dc19fb29 docs(docs): add MDX page for LeetCode 211 design problem 2026-09-21 12:50:51 -04:00
Prad Nukala 5c644ca050 docs(schedule): clarify schedule JSON format, gate problem handling, and update week dates in README 2026-09-21 12:14:37 -04:00
Prad Nukala fb2d4c88c2 feat(work): add solution for LeetCode 208 Implement Trie (Prefix Tree) 2026-09-21 12:14:33 -04:00
Prad Nukala 1fa0731ea7 docs(docs): add MDX page for LeetCode 208 Implement Trie (Prefix Tree) 2026-09-21 12:14:29 -04:00
Prad Nukala 86107d5ef7 feat(api): add structured schedule plan with topics, problems, gate, sweep and reserved problem handling 2026-09-21 12:14:25 -04:00
Prad Nukala 00f44a1c47 docs(docs): add MDX page for Merge k Sorted Lists problem 2026-09-14 12:50:08 -04:00
Prad Nukala 24312437eb feat(work): add solution for LeetCode 23 Merge k Sorted Lists 2026-09-14 12:49:55 -04:00
Prad Nukala 4c9c8f39ae feat(work): add solution for LeetCode 973 K Closest Points to Origin 2026-09-14 12:37:15 -04:00
Prad Nukala 88ff63f5c4 docs(docs): add MDX page for LeetCode 973 K Closest Points to Origin 2026-09-14 12:37:11 -04:00
Prad Nukala 2066856d15 feat(work): add solution for LeetCode 215 Kth Largest Element in an Array 2026-09-14 12:21:55 -04:00
Prad Nukala 393d72c9fe docs(docs): add MDX page for LeetCode 215 Kth Largest Element in an Array 2026-09-14 12:21:51 -04:00
Prad Nukala 6c18301aa6 feat(work): add Lowest Common Ancestor of BST solution 2026-09-05 16:33:03 -04:00
Prad Nukala 02f6a20cc1 docs(docs): add documentation for 235 Lowest Common Ancestor of a Binary Search Tree 2026-09-05 16:32:59 -04:00
Prad Nukala 9288aaa152 feat(work): add Kth Smallest Element in a BST solution 2026-09-05 16:28:21 -04:00
Prad Nukala d5ea71dbc1 docs(docs): add MDX page for “230. Kth Smallest Element in a BST” 2026-09-05 16:28:18 -04:00
Prad Nukala 3e1a66eb37 feat(work): add LeetCode solutions for 217, 271, and 98 2026-09-05 13:37:53 -04:00
Prad Nukala 9a2b80949b docs(docs): add 271 Encode/Decode Strings and 98 Validate BST pages 2026-09-05 13:37:42 -04:00
Prad Nukala 883dc69dba docs(docs): update index title from “Progress” to “Overview” 2026-09-04 16:49:23 -04:00
Prad Nukala e3f0b5420a docs(docs): replace index with live progress dashboard and delete redundant progress page 2026-09-04 16:46:50 -04:00
Prad Nukala 987eb7ab5b feat(work): add solution for LeetCode 1448 Count Good Nodes in Binary Tree 2026-09-04 16:44:07 -04:00
Prad Nukala e4118fccd3 docs(docs): add MDX page for LeetCode 1448 Count Good Nodes in Binary Tree problem 2026-09-04 16:44:04 -04:00
Prad Nukala 95db4ca46c feat(work): add solution for Binary Tree Right Side View problem 2026-09-04 11:48:03 -04:00
Prad Nukala 0cc7ca607e docs(docs): add documentation for Binary Tree Right Side View problem 2026-09-04 11:47:59 -04:00
Prad Nukala 71ec39fe34 feat(work): add solution for Binary Tree Level Order Traversal (LC 102) 2026-09-04 11:33:20 -04:00
Prad Nukala 51decd23bd docs(docs): add documentation for Binary Tree Level Order Traversal (LeetCode 102) 2026-09-04 11:33:15 -04:00
Prad Nukala 90082518a6 feat(work): add solution for Lowest Common Ancestor of a Binary Tree 2026-09-03 14:17:54 -04:00
Prad Nukala e10a913546 docs(docs): add 236 Lowest Common Ancestor of a Binary Tree documentation 2026-09-03 14:17:51 -04:00
Prad Nukala fec94ed636 feat(work): add solution for Invert Binary Tree (LC 226) 2026-09-03 14:01:06 -04:00
Prad Nukala 30273d0030 docs(docs): add Invert Binary Tree documentation 2026-09-03 14:01:02 -04:00
Prad Nukala 50299defef feat(work): add Maximum Depth of Binary Tree solution 2026-09-03 13:52:57 -04:00
Prad Nukala c843be855e feat(docs): add Maximum Depth of Binary Tree documentation 2026-09-03 13:52:54 -04:00
Prad Nukala 1e0e03daaa feat(work): add Set Matrix Zeroes solution 2026-09-02 16:52:29 -04:00
Prad Nukala 7b182206dd docs(docs): add documentation for Set Matrix Zeroes problem 2026-09-02 16:52:26 -04:00
Prad Nukala faaaf52f85 feat(work): add Rotate Image and Spiral Matrix solutions 2026-09-02 16:49:41 -04:00
Prad Nukala 090f3d6719 docs(docs): add new problem pages for Rotate Image, Spiral Matrix, and Add Two Numbers 2026-09-02 16:49:39 -04:00
Prad Nukala a3ae16ad96 feat(work): add Group Anagrams solution 2026-09-01 15:56:02 -04:00
Prad Nukala 9faa0b0337 feat(work): implement correct addTwoNumbers algorithm for linked list addition 2026-09-01 14:39:45 -04:00
Prad Nukala cc29304903 docs(agents): clarify sync.ts contract description for solution section ownership 2026-09-01 14:22:01 -04:00
Prad Nukala c50b600103 feat(work): add Add Two Numbers solution 2026-09-01 14:21:57 -04:00
Prad Nukala a86d72c7db docs(docs): add Approach and Explanation sections with mermaid diagrams to problem pages 2026-09-01 14:21:54 -04:00
Prad Nukala e59d78e571 feat(cli): preserve custom sections after solution during docs sync 2026-09-01 14:21:50 -04:00
Prad Nukala a93bb068b6 fix(work): correct node insertion and eviction order in LRUCache 2026-09-01 13:59:03 -04:00
Prad Nukala 53b2d5ec8c docs(docs): clarify LRUCache insertion order in example MDX 2026-09-01 13:59:01 -04:00
Prad Nukala 5dda64e41e feat(work): add LRU Cache solution 2026-09-01 13:31:34 -04:00
Prad Nukala d798871fbc docs(docs): add LRU Cache problem documentation 2026-09-01 13:31:31 -04:00
Prad Nukala bb7324fe8c docs(AGENTS): add terminal dashboard info and expand documentation for stats, work layout, and tooling preferences 2026-08-31 18:56:57 -04:00
Prad Nukala d13859d31e refactor(docs): rename recent stats to heat and update dashboard rendering 2026-08-31 18:56:52 -04:00
Prad Nukala 664ea534ed feat(config): add stats script to package.json 2026-08-31 18:56:46 -04:00
Prad Nukala b86f36daf1 feat(cli): add terminal stats dashboard and solution detection utilities 2026-08-31 18:56:42 -04:00
Prad Nukala 7bae7b2920 feat(api): add concept temperature bands and expose in stats 2026-08-31 18:56:40 -04:00
Prad Nukala 64173857e7 feat(work): add new LeetCode solution files for 1426, 1832, 344, and 15 2026-08-31 16:50:30 -04:00
Prad Nukala 002c2c9c97 feat(work): add solutions for LeetCode 1365 and 268 problems 2026-08-31 16:36:29 -04:00
Prad Nukala fa8deac963 feat(work): add solution for LeetCode 387 First Unique Character in a String 2026-08-31 16:21:47 -04:00
Prad Nukala ef8d2b41e1 docs(todos): add review backlog planning markdown 2026-08-31 14:57:38 -04:00
Prad Nukala 51ce47a49a feat(work): add Reverse Linked List solution 2026-08-31 12:12:03 -04:00
Prad Nukala 4650b33364 docs(docs): add Reverse Linked List documentation 2026-08-31 12:12:00 -04:00
Prad Nukala 804ad1379e feat(work): add solution for LeetCode 141 Linked List Cycle 2026-08-31 12:08:29 -04:00
Prad Nukala 0d845838c2 docs(docs): add documentation for LeetCode 141 Linked List Cycle 2026-08-31 12:08:26 -04:00
Prad Nukala b7041b1440 docs(linked-list): add Merge Two Sorted Lists documentation page 2026-08-31 11:45:48 -04:00
Prad Nukala 5655cede21 feat(work): add solution for LeetCode 21 Merge Two Sorted Lists 2026-08-31 11:45:44 -04:00
Prad Nukala 025ce82041 docs(api): clarify /admin/solved payload and add /admin/due endpoint documentation 2026-08-31 10:49:55 -04:00
Prad Nukala a3657732f6 feat(cli): send bucket info to SRS API and render due list from worker 2026-08-31 10:49:50 -04:00
Prad Nukala 1622bf39b7 ci(ci): add SRS_ADMIN_KEY env var to spaced-repetition workflow 2026-08-31 10:48:36 -04:00
Prad Nukala 5ea123e46d feat(api): add dueToday endpoint, shared DUE_WHERE constant, and commit rung handling 2026-08-31 10:48:24 -04:00
Prad Nukala 38fbb18f32 feat(srs): switch ladder to +3/+7 and enforce rest‑day logic 2026-08-31 10:41:45 -04:00
Prad Nukala 7e46fceb1c refactor(work): restructure solution directories with bucket level and add .gitkeep placeholders 2026-08-31 10:41:40 -04:00
Prad Nukala b21ab75e0e test(tests): add comprehensive rest day and ladder tests 2026-08-31 10:41:36 -04:00
Prad Nukala d784575c87 feat(migrations): rebuild ladder stages and shift Sunday dates 2026-08-31 10:41:33 -04:00
Prad Nukala 70f88122e0 docs(docs): align stage ladder with API ladder constants 2026-08-31 10:41:29 -04:00
Prad Nukala 47c2b20646 chore(config): ignore *.log and .crush files in .gitignore 2026-08-31 10:41:25 -04:00
Prad Nukala e8b0d4dc30 feat(api): migrate SRS ladder to +3/+7 and adjust rest‑day logic 2026-08-31 10:41:21 -04:00
Prad Nukala bd9afc6ea1 docs(agents): add spaced-repetition workflow details and CLI usage to documentation 2026-08-29 17:26:11 -04:00
Prad Nukala 8396c4a7ed feat(config): add spaced-repetition script 2026-08-29 17:26:07 -04:00
Prad Nukala bf4a1f4c7a feat(cli): add spaced-repetition script 2026-08-29 17:26:05 -04:00
Prad Nukala e23a68c91c ci(ci): add spaced‑repetition GitHub Actions workflow 2026-08-29 17:26:01 -04:00
Prad Nukala 7384a33f3a feat: Add car fleet 2026-08-28 15:52:39 -04:00
Prad Nukala 0064d15844 feat(work): add Daily Temperatures solution 2026-08-28 15:05:53 -04:00
Prad Nukala e061aca54c docs(docs): add Daily Temperatures solution documentation 2026-08-28 15:05:50 -04:00
Prad Nukala 1eb85ee2fb docs(campaign-issues): remove difficulty/set from issue titles and update documentation accordingly 2026-08-27 16:04:18 -04:00
Prad Nukala cac2f744e4 feat(api): extract difficulty and set from issue labels in catalog reconciliation 2026-08-27 16:04:14 -04:00
Prad Nukala 9e90cfb729 feat(skills): add campaign-issues and project-fields skill docs 2026-08-27 15:46:34 -04:00
Prad Nukala dc15237381 docs(agents): detail Project field sync and Target Date mirroring in workflow documentation 2026-08-27 15:46:28 -04:00
Prad Nukala af7063b71f chore(config): add sync-project-fields script 2026-08-27 15:46:20 -04:00
Prad Nukala c5f3c95fcc feat(cli): add GraphQL support and sync-project-fields script for Project field reconciliation 2026-08-27 15:46:17 -04:00
Prad Nukala fe2a7bb4e9 ci(ci): add project set/difficulty reconciliation step to close-solved workflow 2026-08-27 15:46:14 -04:00
Prad Nukala 8689075ac9 refactor(api): limit project mirror to Target Date only 2026-08-27 15:46:12 -04:00
Prad Nukala 9b5879b6eb feat(work): add solutions for 150.evaluate-reverse-polish-notation and 155.min-stack, remove 42.trapping-rain-water 2026-08-27 15:21:16 -04:00
Prad Nukala 53136b076c docs(docs): delete obsolete array problem pages 2026-08-27 15:21:13 -04:00
Prad Nukala 95f10980d4 docs(docs): add MDX documentation pages for problems 150 (Evaluate Reverse Polish Notation) and 155 (Min Stack) 2026-08-27 15:19:48 -04:00
Prad Nukala c6ecb85507 feat(work): add valid parentheses solution 2026-08-27 14:31:36 -04:00
Prad Nukala 2a69f36e6a docs(docs): add Valid Parentheses solution page 2026-08-27 14:31:32 -04:00
Prad Nukala 5e4628b20f docs(docs): revamp progress dashboard UI and add week field comment 2026-08-27 11:05:05 -04:00
Prad Nukala 72a8399e6d fix(api): adjust gate week handling to use campaign weeks 2026-08-27 11:05:03 -04:00
Prad Nukala 6ebca68ff8 docs(agents): clarify test and submit command behavior in AGENTS.md 2026-08-26 15:51:07 -04:00
Prad Nukala 957ddf9849 feat(work): add solution for problem 35 search-insert-position 2026-08-26 15:51:04 -04:00
Prad Nukala e67cc79f39 docs(docs): add MDX documentation for problems 153 and 35 2026-08-26 15:51:01 -04:00
Prad Nukala f086c2b39a feat(cli): add solution activity tracking and ordering for test/submit commands 2026-08-26 15:50:58 -04:00
Prad Nukala 1b5bdc4ceb chore(logs): add backend shutdown debug log file 2026-08-26 15:25:48 -04:00
Prad Nukala 22ebb82e55 docs(agents): enhance bun run pick description and add bun run submit command 2026-08-26 15:25:44 -04:00
Prad Nukala 180134597d feat(work): add solution for Find Minimum in Rotated Sorted Array 2026-08-26 15:25:41 -04:00
Prad Nukala b669e97c3f feat(config): add submit script command 2026-08-26 15:25:38 -04:00
Prad Nukala d57d75efcc feat(cli): add SQLite cache, shared solution runner, and submit command 2026-08-26 15:25:35 -04:00
Prad Nukala f1445a071f feat(work): add solution for LeetCode 74 Search a 2D Matrix problem 2026-08-26 14:29:14 -04:00
Prad Nukala bb8aa00175 docs(docs): add documentation page for 74. Search a 2D Matrix problem 2026-08-26 14:29:11 -04:00
Prad Nukala 6df1e0e620 docs(agents): add overload‑leveling details to SRS description in AGENTS.md 2026-08-26 10:34:29 -04:00
Prad Nukala 1b4ad8ca5b feat(api): enforce REVIEW_CAP, level overflow reviews, and display difficulty in digest 2026-08-26 10:34:24 -04:00
Prad Nukala f697a986b8 docs(readme): clarify close-solved solved posting and live chart updates 2026-08-25 15:45:20 -04:00
Prad Nukala de5101b4d1 feat(cli): report solved set to SRS worker and log sync status 2026-08-25 15:44:46 -04:00
Prad Nukala 3068b4e4aa ci(ci): add SRS_ADMIN_KEY env var and documentation to CI workflows 2026-08-25 15:44:43 -04:00
Prad Nukala e4de74c935 mise ~/.config/mise/config.toml tools: crush@0.91.0
feat(api): add /admin/solved endpoint and commit source handling
2026-08-25 15:43:21 -04:00
Prad Nukala aba3d9b18b docs(docs): add Permutation in String documentation 2026-08-25 15:00:56 -04:00
Prad Nukala 554145072d feat(work): add Permutation in String solution 2026-08-25 15:00:46 -04:00
Prad Nukala 5cac46a0bf feat(work): add longest‑repeating‑character‑replacement solution and rename variable in longest‑substring solution 2026-08-25 14:24:39 -04:00
Prad Nukala 31f89dd4f5 docs(docs): rename variable to window and add Longest Repeating Character Replacement page 2026-08-25 14:24:35 -04:00
Prad Nukala 217e899281 feat(work): add longest‑substring‑without‑repeating‑characters solution 2026-08-25 13:18:35 -04:00
Prad Nukala bc2269d9dd docs(docs): add longest‑substring problem page and sliding‑window reference with SVG illustrations 2026-08-25 13:18:32 -04:00
Prad Nukala fd0d6c6ea5 mise ~/.config/mise/config.toml tools: crush@0.91.0
feat(config): add Reference tab to navigationfeat(config): add Reference tab to navigation
2026-08-25 13:18:28 -04:00
Prad Nukala 9fbe800fed feat: migrate docs 2026-08-25 11:20:52 -04:00
Prad Nukala e64285f201 docs(scripts): remove sync‑solutions command documentation 2026-08-25 11:19:43 -04:00
Prad Nukala 94b1e1af55 docs(docs): add LeetCode problem markdown pages and docs site assets 2026-08-25 11:19:35 -04:00
Prad Nukala cc6c2da859 chore(config): restructure workspaces and scripts for apps layout 2026-08-25 11:19:31 -04:00
Prad Nukala 10d133f005 feat(cli): add issue reconciliation scripts and GitHub helper 2026-08-25 11:19:26 -04:00
Prad Nukala 8f62875fa7 ci(ci): update workflow script paths and add API deployment job 2026-08-25 11:19:22 -04:00
Prad Nukala a3431da300 feat(api): add SRS Cloudflare Worker with email digest, charts, and D1 integration 2026-08-25 11:19:19 -04:00
Prad Nukala f551878961 docs(agents): add docs for daily digest, PNG encoder and update runtime dependencies note 2026-08-25 10:33:08 -04:00
Prad Nukala 881530a808 mise ~/.config/mise/config.toml tools: crush@0.91.0
feat(api): add React Email rendering and PNG heatmap endpoint
2026-08-25 10:33:04 -04:00
Prad Nukala 6b9a259ec7 Add sync-d1 workflow: issue edits reconcile the catalog into D1 2026-08-24 19:26:55 -04:00
Prad Nukala 3aa8d50644 api: document learned-topic drill rule 2026-08-24 19:24:28 -04:00
Prad Nukala ddf249c884 README restructure + drills only from learned topics 2026-08-24 19:23:44 -04:00
Prad Nukala 226fd2a6a8 docs(readme): redesign campaign overview layout and add linked week info in README.md 2026-08-24 19:22:21 -04:00
Prad Nukala a41ff59406 srs-api: dark-mode charts matching shieldcn zinc style 2026-08-24 19:16:02 -04:00
Prad Nukala ab5c251952 srs-api: allowlist both docs origins for /api/stats CORS 2026-08-24 19:12:11 -04:00
Prad Nukala 7213cc1c8a Migrate SRS to Cloudflare Worker: D1 state, email digest, review issues, live charts 2026-08-24 19:11:07 -04:00
Prad Nukala 6be5d55366 docs(repo): add repository guidelines documentation 2026-08-24 18:50:24 -04:00
Prad Nukala 5bdc0d9089 srs: drop synthetic 704 test attempts from acceptance run [skip ci] 2026-08-24 17:26:24 -04:00
github-actions[bot] c5e5e26d9d srs: log results from #209 [skip ci] 2026-08-24 21:25:35 +00:00
github-actions[bot] 43c9b099fa srs: log results from #209 [skip ci] 2026-08-24 21:24:37 +00:00
github-actions[bot] 0f509c5296 srs: create weekly gate [skip ci] 2026-08-24 21:22:55 +00:00
Prad Nukala e5dbef9ee9 Add SRS automation: scheduler, logger, weekly gates 2026-08-24 17:21:47 -04:00
Prad Nukala 4c7a366bfd Add close-topics reconciler; extract shared GitHub/report plumbing 2026-08-24 17:21:47 -04:00
Prad Nukala c3265f5672 feat(scripts): add GitHub helper module and close-topics script with reporting 2026-08-24 16:47:03 -04:00
Prad Nukala 48452fb72c feat(config): add close-topics script command 2026-08-24 16:47:00 -04:00
Prad Nukala d4b9c5b4fa ci(ci): add Close Topics GitHub Actions workflow 2026-08-24 16:46:57 -04:00
Prad Nukala 2d37d6bc4d feat(work): add Python solutions for Is Subsequence and Container With Most Water 2026-08-24 16:22:00 -04:00
Prad Nukala 8661e10832 docs(docs): add solution documentation for Container With Most Water and Is Subsequence 2026-08-24 16:21:57 -04:00
Prad Nukala af03ecb9a4 feat(work): add Python solutions for Two Sum, 3Sum, Two Sum II and remove unused JavaScript stubs 2026-08-24 15:32:33 -04:00
Prad Nukala fe7556bf1e docs(docs): add Python solutions and new Two Sum documentation 2026-08-24 15:32:30 -04:00
Prad Nukala 382475b7f1 feat(ci): auto-close problem issues when a solution lands in work/
Reconciles work/ against open `problem` issues instead of diffing the push,
so re-runs are no-ops and backfills need no special casing.

Skips scaffolded stubs: `bun run pick` writes a statement header plus an
empty body, so file existence alone must not close an issue.
2026-08-24 14:31:36 -04:00
Prad Nukala 7743ca18bd docs(readme): add campaign index linking phase milestones and topic issues 2026-08-24 14:31:36 -04:00
343 changed files with 21413 additions and 3906 deletions

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
---
name: campaign-issues
description: Create GitHub issues for this LeetCode campaign — individual problem issues and the topic issues that own them — using the repo's exact title, label, milestone, body, and sub-issue conventions. Use when asked to add/create an issue for a LeetCode problem or a topic, attach a problem to its topic, or backfill the issue catalog.
---
# Campaign issues
The GitHub issue tracker **is the campaign catalog**: ~24 `topic` issues each own a
set of `problem` sub-issues. The Worker reconciles this catalog into D1 nightly
(`/admin/reconcile`), so a correctly-labelled issue is all that's needed — the SRS,
digest, charts, and Project mirror pick it up automatically. Get the labels/body shape
right; everything downstream is reconciliation.
All commands use `gh` against `prdlk/leetcode` and fall back to `gh auth token`.
## Problem issue
One issue per LeetCode problem, attached as a sub-issue of its topic.
- **Title:** `LC <num> · <Title>` — the LC number and the problem's name, nothing
else. Separator is a middle dot `·` (U+00B7) with single spaces. Difficulty and
set are **labels only**; never repeat them in the title (they used to be a `·
Hard · core` suffix — that is gone, and `catalog.ts` no longer parses it).
- **Labels** (four, all required — these are what the Worker reads):
- `problem` — marks the sub-issue as curriculum; without it the reconcile skips it.
- `diff:easy` | `diff:medium` | `diff:hard` — LeetCode's own rating, do not invent.
- `set:core` | `set:optional` | `set:deferred` — see set semantics below.
- `phase:N` — the **topic's** phase (same phase label the topic issue carries).
- **Milestone:**
- `core` / `optional` → the topic's phase milestone.
- `deferred` → `Review — Transfer & Mocks` (deferred Hards are parked until week 8).
- Pass the milestone **title**, not its number, to `--milestone`.
- **Body** (exact shape; `<slug>` is the LeetCode URL slug, `<topic-issue>` the owning topic's number):
```
https://leetcode.com/problems/<slug>/
- **Topic:** #<topic-issue>
- **Set:** <core|optional|deferred>
- **Language:** Python
### Close-out (paste before closing this issue)
- Time: O(?)
- Space: O(?)
- Edge case traced:
- First attempt passed: yes / no
- Miss note:
```
Create it:
```sh
gh issue create --repo prdlk/leetcode \
--title "LC 42 · Trapping Rain Water" \
--label problem --label set:core --label diff:hard --label phase:1 \
--milestone "Phase I — Linear Structures" \
--body "https://leetcode.com/problems/trapping-rain-water/
- **Topic:** #9
- **Set:** core
- **Language:** Python
### Close-out (paste before closing this issue)
- Time: O(?)
- Space: O(?)
- Edge case traced:
- First attempt passed: yes / no
- Miss note:"
```
Then **attach it as a sub-issue of the topic** (this is a separate API call — creating
the issue does not link it). The sub-issues API wants the child's internal **id**, not
its issue number, and the field is integer-typed, so use `-F` (not `-f`):
```sh
child_id=$(gh api repos/prdlk/leetcode/issues/211 --jq '.id')
gh api --method POST repos/prdlk/leetcode/issues/9/sub_issues -F sub_issue_id=$child_id
# verify
gh api repos/prdlk/leetcode/issues/9/sub_issues --jq '.[] | "\(.number) \(.title)"'
```
## Topic issue
One issue per curriculum topic; owns the problem sub-issues.
- **Title:** `Topic NN — <Name>` (zero-padded number, em dash, e.g. `Topic 07 — Monotonic Stack`).
- **Labels:** `topic`, `phase:N`.
- **Milestone:** that phase's milestone (title, not number).
- **Body:**
```
**Scheduled:** <Weekday Mon DD, YYYY>
**Phase:** <Roman> — <Phase name> (Week N)
**Builds on:** Topic <n>
### Trigger
TODO
### Invariant
TODO
### Trap
TODO
### Close-out ritual
Before every submit, out loud:
1. State time and space complexity.
2. Trace one edge case.
### Day rules
- 90-minute coding cap. Stop at the cap, even mid-problem.
- Core problems first. Optional only if the core is done.
- Finished early? Do this topic's optional set. Do not start tomorrow.
```
`Scheduled` should come from the calendar of record — `apps/api/data/schedule.json`
(`date → { topics: [issue, …] }`; a doubled day lists two) — not guessed.
Gate problems (`gate: [...]` in the same file) must exist in the catalog before their
Saturday, or the Worker refuses to create the review issue: create each as a
`set:optional` problem sub-issue of its natural topic. Being named in the plan keeps it
out of the drill pool, so it stays unseen until the gate.
## Reference tables
Set semantics (from the label descriptions):
| set | meaning | milestone |
|---|---|---|
| `core` | must solve on the topic day | topic's phase milestone |
| `optional` | solve only if the clock allows | topic's phase milestone |
| `deferred` | a Hard, parked until week 8 | `Review — Transfer & Mocks` |
Milestones (`--milestone` takes the **title**):
| # | title |
|---|---|
| 1 | Phase I — Linear Structures |
| 2 | Phase II — Nodal & Grid |
| 3 | Phase III — Hierarchical |
| 4 | Phase IV — Relational |
| 5 | Phase V — Decision Space |
| 6 | Review — Transfer & Mocks |
Find a topic issue and confirm its phase before creating problems under it:
```sh
gh issue list --repo prdlk/leetcode --label topic --state all --limit 30 \
--json number,title,labels,milestone
```
## Gotchas
- **Check the LC number is not already in the curriculum BEFORE creating.** D1 keys
`problems` on `lc_number` (`ON CONFLICT(lc_number)`), so two issues for one LC number
silently fight over a single row — whichever the reconcile walks last wins, and the
loser's issue number ends up orphaned in D1. A problem can legitimately move topic or
set; that is a relabel/re-parent of the *existing* issue, never a second issue:
```sh
gh issue list --repo prdlk/leetcode --label problem --state all --limit 300 \
--search "LC 42" --json number,title,labels,state
```
If a row already exists, change the existing issue instead: swap its `set:*` label,
move the sub-issue to the new topic, and fix its milestone. Then reconcile the board
(`project-fields` skill). If a duplicate did get created and one is deleted, the
surviving issue number and set only reach D1 on the next catalog reconcile — SRS
columns (`stage`, `next_review`) are preserved, since they are the real attempt history.
- **`-F` vs `-f`:** `sub_issue_id` must be an integer; `-f` sends a string and the API
rejects it with `is not of type integer`.
- **id, not number:** the sub-issue payload wants the child's `.id` (a large opaque
integer), never its display `#number`.
- **`--milestone` wants the title string**, not the numeric id (`'1' not found` otherwise).
- **Reconcile, don't react:** don't edit D1 or the Project by hand — a correct issue is
reconciled into SRS state on the nightly cron. Solutions landing in `work/` are what
close problem issues (via `close-solved.yml`), which then roll up to close topics.
- **Difficulty is LeetCode's**, `set` is our scheduling choice — they're independent. A
Hard can be `core` if we deliberately schedule it (vs the usual `deferred`).
+150
View File
@@ -0,0 +1,150 @@
---
name: problem-approach
description: Write the `## Approach` section — a Mermaid flowchart that traces the submitted solution — for every docs solution page under apps/docs/content/ that lacks one, using the repo's exact placement and node conventions. Use when asked to add/implement/fill in missing approaches, draw the approach diagram for a problem page, or when `/problem-approach` is run.
---
# Problem approach diagrams
Every solution page (`apps/docs/content/(<category>)/<num>-<slug>.mdx`) has three
hand-owned sections around the script-owned one:
```
frontmatter → <Badge> → ::::warning (maybe) → ### Example N / ### Constraints
## Approach ← this skill (Mermaid flowchart of the code below)
## Solution ← bun run sync owns this; never edit inside it
## Explanation ← bun run videos --write (NeetCode embed); not this skill
```
`sync.ts` regenerates only `## Solution`, so an `## Approach` placed **before** it
survives every re-sync; anything placed inside `## Solution` is overwritten on the
next `work/` push.
## The command: fill every missing approach
1. **Find the pages** (numbered pages with no `## Approach` heading):
```sh
grep -L '^## Approach' apps/docs/content/*/*.mdx
```
2. **For each page**, read `## Solution` and write the diagram from *that code* — the
one on the page, not the textbook algorithm. The reader compares diagram and code
line by line; the diagram is a trace, not a lecture.
3. **Insert** the section between the `### Constraints:` list and `## Solution`,
blank-line separated on both sides:
````md
- `-100 <= Node.val <= 100`
## Approach
```mermaid
flowchart TD
…
```
## Solution
````
4. **Check** each diagram by hand against the syntax rules below (the docs build does
not parse Mermaid — a broken diagram fails only in the browser), then run the
docs build once at the end and review the pages in `bun run dev`:
```sh
cd apps/docs && bunx blume build --isolated && rm -rf .blume-verify
```
Pages with two languages (`<CodeGroup>`) get **one** diagram, traced from the
Python version. Pages with only JavaScript trace the JavaScript.
## Diagram conventions
Reference page: `apps/docs/content/(array)/15-3sum.mdx`. Every existing diagram
follows this shape:
```mermaid
flowchart TD
S(["threeSum(nums)"]) --> O["nums.sort() — duplicates become adjacent"]
O --> F{"more i in 0..n-1?"}
F -- no --> E(["return result"])
F -- yes --> D{"nums[i] == nums[i-1]?"}
D -- yes --> F
D -- no --> P["left = i+1, right = n-1, target = -nums[i]"]
P --> W{"left < right?"}
W -- no --> F
W -- yes --> C["current = nums[left] + nums[right]"]
C --> Q{"current vs target"}
Q -- equal --> A["append triplet, skip equal neighbours, left += 1, right -= 1"]
A --> W
Q -- "current < target" --> L["left += 1 — need a bigger sum"]
L --> W
Q -- "current > target" --> R["right -= 1 — need a smaller sum"]
R --> W
```
- `flowchart TD`, two-space indent, one edge per line, edges listed in execution
order (entry first, exits as soon as they are reachable).
- **Entry** is a stadium holding the signature: `S(["threeSum(nums)"])`. **Every
exit** is a stadium: `E(["return result"])`, `R(["return true"])`.
- **Statements** are rectangles `["…"]`, in the code's own identifiers and operators
(`left += 1`, `freq[n] = (freq[n] or 0) + 1`). Several trivial assignments may share
one node, comma-separated.
- **Decisions** are diamonds `{"…?"}` with `-- yes -->` / `-- no -->` edges. A
three-way branch names the outcomes: `-- equal -->`, `-- "current < target" -->`
(quote an edge label whenever it holds `<`, `>`, or spaces around operators).
- **Loops** are a decision: `for` → `{"more n in nums?"}`, `while` → the condition
verbatim `{"left < right?"}`. `-- no -->` goes to whatever follows the loop; the
body's last node edges back to the decision. `continue` is an edge back to the loop
decision (`D -- yes --> F` above); `break` is an edge to the node after the loop.
- **Recursion** is a statement, not an expanded subgraph: `A["left = invertTree(root.left)"]`.
The base case is the first decision after entry.
- **Classes** (design problems) use one `subgraph name["method(args)"]` per public
method, plus a `state["…"]` subgraph describing the fields, with `state --> method`
edges — see `(hash-table)/146-lru-cache.mdx`. Private helpers are named inside the
method nodes (`"_remove_node then _add_node — move it beside the tail"`), not drawn.
- **Annotate the why with an em dash** inside the node, sparingly (one clause, a few
per diagram): `"nums.sort() — duplicates become adjacent"`, `"map = {0: 1} — the
empty prefix"`. The dash explains the invariant that makes the step correct — the
one thing the code does not say.
- **Node ids**: one or two capitals, unique per diagram (`S I L Q C E R W A B`). Class
diagrams prefix per method (`G1`, `P1`).
### Syntax rules (a diagram that breaks the page is worse than none)
- Wrap **every** label in double quotes — brackets, parentheses, `<`, `>` and `|` in an
unquoted label break the parser.
- Never put `"` inside a label; use `'` for string literals (`"s == ''?"`).
- Never use `|` anywhere — not in node labels, not as `-->|text|` edge syntax; edge
labels are always `-- text -->`. Write `or` for a logical or.
- Every id an edge references must be declared with a shape the first time it appears.
- Balanced: each `subgraph` has an `end`; each fence opens ```` ```mermaid ```` and closes ```` ``` ````.
### Code that does not run
Trace it anyway — the diagram documents what was submitted — and put a `::::warning`
callout **before** the fence naming exactly why it fails (`(array)/121-best-time-to-buy-and-sell-stock.mdx`):
````md
## Approach
::::warning
The diagram traces the code below literally. That code does not run: `curr` and `ans` are never initialised, so the first `curr > left` test raises `NameError`.
::::
```mermaid
…
```
````
Do not fix the code on the page: `## Solution` mirrors `work/1`, and the fix belongs
there (then `bun run sync`).
## Do not
- Do not write prose approaches, complexity tables or alternative algorithms — the
section is one flowchart (plus the warning callout when the code is broken).
- Do not touch `## Solution` or anything after it; `## Explanation` is filled by
`bun run videos --write`.
- Do not add an approach to `reference/` pages or `index.mdx` — only numbered
solution pages (`title: 'NN. …'`) carry one.
+108
View File
@@ -0,0 +1,108 @@
---
name: project-fields
description: Reconcile the "Interview Prep" GitHub Project (user project #2) fields — Set and Difficulty projected from issue labels — and understand which fields the Worker mirrors vs which live only in D1. Use when asked to sync/fix/backfill Project fields, when Project columns look empty or stale, or when adding a field to the project.
---
# Interview Prep Project fields
The user-level Project **"Interview Prep"** (`prdlk` project #2, id `PVT_kwHOADoNjs4BhV1a`)
is a *board over* the issue catalog, never a source of truth. Every field is a projection
of something owned elsewhere.
## Field ownership — the rule that matters
| Field | Owner | Written by |
|---|---|---|
| `Set` (Core/Optional/Deferred) | issue label `set:*` | `sync-project-fields`, last step of `close-solved.yml` |
| `Difficulty` (Easy/Medium/Hard) | issue label `diff:*` | `sync-project-fields`, last step of `close-solved.yml` |
| `Target Date` | D1 `next_review` | Worker mirror, per log (`apps/api/src/mirror.ts`) |
| `Status`, `Labels`, `Milestone`, `Parent issue`, … | GitHub built-ins | GitHub |
**SRS stage and first-attempt result are deliberately NOT Project fields.** They live only
in D1; the charts, digest and `/api/stats` read them from there. Duplicating them into
Project single-selects bought nothing but drift and two extra writes per log. Do not re-add
them to the project, and do not re-add `setStage`/`setFirstAttempt` to `mirror.ts`.
## Reconciling Set / Difficulty
`apps/cli/sync-project-fields.ts` recomputes every desired value from the issue labels and
writes only what differs — same reconcile-don't-react shape as `close-solved`.
**In CI it runs itself.** It is the final step of `.github/workflows/close-solved.yml`, so
any push under `work/` reconciles the board along with the issues and D1. The step is
`continue-on-error: true` and skips cleanly when `PROJECT_PAT` is unset — D1 and the issues
are the truth, so a Project failure must never fail a run that already closed issues and
logged the solve. It honours the workflow's `dry_run` input via `DRY_RUN=true`.
Run it by hand when issues changed without a `work/` push (e.g. you just filed a problem
issue, or relabelled one):
```sh
# needs a token with the `project` scope — neither `gh auth token` nor GITHUB_TOKEN has it.
export GH_TOKEN=$(grep '^GH_PAT=' apps/api/.dev.vars | cut -d= -f2-)
bun run sync-project-fields -- --dry-run # or DRY_RUN=1|true; prints the table, writes nothing
bun run sync-project-fields # apply
bun run sync-project-fields # re-run: must report 0 writes
```
Invariants the script holds — preserve them in any edit:
- **Only `problem`-labelled items are touched.** Topic, cadence and review issues have no
set/difficulty; they are skipped, never written and never cleared.
- **Idempotent.** A second run reports `0 field value(s)`. That re-run *is* the verification.
- **Labels are truth.** Never "fix" a field by hand in the UI — fix the label, then reconcile.
A hand-edited field is silently reverted on the next run.
- **Fields and options resolve by NAME**, so re-creating a field in the UI doesn't strand a
dead id. A missing field or option is a hard error, not a silent skip.
- **A problem missing a `set:`/`diff:` label** warns and is left untouched (it means the
issue is mislabelled — fix it with the `campaign-issues` skill).
## Auth gotcha
Project v2 needs the `project` scope. The local `gh` login has only
`gist, read:org, repo, workflow`, so **any** `gh project`/GraphQL project call fails with
the default token. Use the Worker's `GH_PAT` from `apps/api/.dev.vars` via `GH_TOKEN`
(never echo it). Symptom of the wrong token: `Resource not accessible by personal access
token` or an empty `projectsV2.nodes`.
## Inspecting the project
```sh
export GH_TOKEN=$(grep '^GH_PAT=' apps/api/.dev.vars | cut -d= -f2-)
# fields + single-select options
gh api graphql -f owner=prdlk -f title="Interview Prep" -f query='
query($owner: String!, $title: String!) {
user(login: $owner) { projectsV2(first: 10, query: $title) { nodes { id title number
items(first: 1) { totalCount }
fields(first: 30) { nodes {
... on ProjectV2FieldCommon { id name dataType }
... on ProjectV2SingleSelectField { options { id name } } } } } } }
}' --jq '.data.user.projectsV2.nodes[] | select(.title=="Interview Prep")
| .fields.nodes[] | "\(.name)\t\(.dataType)\t\((.options // []) | map(.name) | join(","))"'
```
Writes use `updateProjectV2ItemFieldValue` with
`value: { singleSelectOptionId: … }` (dates use `{ date: "YYYY-MM-DD" }`); clearing uses
`clearProjectV2ItemFieldValue`. An item id is *not* an issue number — resolve it from the
project's `items` connection (or the issue's `projectItems`), as `mirror.ts` does.
## Adding a new projected field
1. Create the field + options in the Project UI.
2. Add it to the `FIELDS` table in `apps/cli/sync-project-fields.ts` — one entry mapping
label → option name. Nothing else needs to change; resolution, diffing, dry-run and
reporting are generic over that table.
3. Dry-run, apply, re-run to prove idempotency.
If the new field's truth lives in **D1** rather than a label, it belongs in the Worker
mirror instead (`apps/api/src/mirror.ts`) — but read the ownership rule above first and
default to *not* mirroring.
## Known trap
`resolve()` in `mirror.ts` throws if a field it asks for is absent, *before* caching
`info` — so one missing field breaks **every** mirrored write, including `Target Date`,
and only as a `console.warn`. If Target Date silently stops updating, check that every
field `resolve()` looks up still exists in the project.
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
# Pre-commit: the docs pages under apps/docs/content (and the `/<num>` short
# links in apps/docs/redirects.ts) are generated from work/1 by `bun run sync`,
# and deploy.yml publishes whatever main holds. Regenerate them now and stage
# what sync changed, so a solution, its page and its redirect land in the same
# commit and a stale `## Solution` never reaches main.
#
# Sync reads the working tree, not the index, so the pages it writes only
# match this commit when work/1 has nothing unstaged: an untracked or
# half-staged solution would ship a page whose source is not in the commit.
# In that case refuse and say what to stage.
#
# Only paths that became dirty during sync are staged. Anything already dirty
# beforehand (hand-written `## Approach` prose in progress, a new index.mdx)
# stays where the author left it.
#
# Bypass for a deliberate partial commit: git commit --no-verify
set -eu
cd "$(git rev-parse --show-toplevel)"
unstaged=$(git status --porcelain -- work/1 | grep -v '^[MADR] ' || true)
if [ -n "$unstaged" ]; then
printf 'pre-commit: work/1 has unstaged changes; stage or stash them so the generated pages match this commit:\n%s\n' "$unstaged" >&2
exit 1
fi
before=$(mktemp)
trap 'rm -f "$before"' EXIT
git status --porcelain -- apps/docs/content apps/docs/redirects.ts | sort > "$before"
report=$(bun run --silent sync)
generated=$(git status --porcelain -- apps/docs/content apps/docs/redirects.ts | sort | comm -13 "$before" - | cut -c4-)
if [ -n "$generated" ]; then
printf '%s\n' "$report" | grep -v 'skipped (in sync)' >&2
printf '%s\n' "$generated" | xargs git add --
fi
+99
View File
@@ -0,0 +1,99 @@
name: Close Solved
on:
push:
branches: [main]
paths: ["work/**"]
workflow_dispatch:
inputs:
dry_run:
description: Report matches and would-be releases without closing or releasing anything
type: boolean
default: false
permissions:
contents: read
issues: write
concurrency:
group: close-solved
cancel-in-progress: false
jobs:
close:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
# No install step: the script only uses Bun builtins + fetch.
#
# SRS_ADMIN_KEY lets the script report the solved set to the Worker
# (POST /admin/solved). Closing an issue is invisible to D1 — the
# catalog reconcile never reads issue state, and only logAttempt() moves
# the ladder — so without this a pushed solution would never reach the
# charts, the digest's solved ticks, or the drill/gate pools.
- run: bun apps/cli/close-solved.ts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ inputs.dry_run }}
SRS_ADMIN_KEY: ${{ secrets.SRS_ADMIN_KEY }}
# Project fields are a projection of the issue labels, so the same push
# that adds a solution (and any issue filed since the last run) can leave
# the board's Set/Difficulty empty. Reconciling here keeps the one
# "solution landed" entry point responsible for the whole fan-out; the
# script is idempotent, so a run with nothing to do writes nothing.
#
# Needs the `project` scope, which GITHUB_TOKEN does not have — hence
# PROJECT_PAT. Unset = skip, matching SRS_ADMIN_KEY above. Best-effort by
# design: D1 and the issues are the truth, so a Project failure must not
# fail a run that already closed issues and logged the solve.
- name: Reconcile Project Set/Difficulty
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.PROJECT_PAT }}
DRY_RUN: ${{ inputs.dry_run }}
run: |
if [ -z "$GH_TOKEN" ]; then
echo "PROJECT_PAT unset — skipping the Project field reconcile"
exit 0
fi
bun apps/cli/sync-project-fields.ts
# One timever release per finished topic (every core sub-issue closed),
# tagged at the instant the last core problem closed. It reads the same rule
# close-topics uses (apps/cli/topics.ts) straight off the sub-issues, so the
# push that finishes a topic releases it in this run — no waiting for Close
# Topics, which chains after this workflow. Reconciling: a run releases
# every finished topic still missing one, so a manual dispatch backfills and
# a problem closed by hand in the UI is picked up by the next run.
release:
needs: close
# Like close-topics: a partially failed close still closed some issues,
# and the script only reads state, so run unless the run was cancelled.
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
permissions:
contents: write # push tags, create releases
issues: read
steps:
# Full history: each tag targets the last main commit before its topic
# finished, and backfilled tags reach far behind HEAD.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
# The tagger of the annotated, backdated tags.
- run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# No install step: the script only uses Bun builtins + fetch.
- run: bun apps/cli/release-topics.ts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ inputs.dry_run }}
+49
View File
@@ -0,0 +1,49 @@
name: Close Topics
# No D1 push here, unlike Close Solved: D1 stores no topic completion column.
# Phase progress, the gate pools and the charts all derive from the problem
# rows, which close-solved already logs — a topic is finished exactly when its
# core problems are, so there is nothing left to reconcile.
on:
# Close Solved closes problem issues with GITHUB_TOKEN, and token-driven
# closes never fire `issues` events — so chain off its run instead.
# Conclusion is not filtered: the script reconciles state, so running after a
# partially successful run is safe and catches whatever it did close.
workflow_run:
workflows: [Close Solved]
types: [completed]
# Covers problem issues closed by hand in the UI.
issues:
types: [closed]
workflow_dispatch:
inputs:
dry_run:
description: Report finished topics without closing anything
type: boolean
default: false
permissions:
contents: read
issues: write
concurrency:
group: close-topics
cancel-in-progress: false
jobs:
close:
# The `issues` trigger fires for every issue in the repo; only closing a
# problem sub-issue can ever complete a topic's core set.
if: github.event_name != 'issues' || contains(github.event.issue.labels.*.name, 'problem')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
# No install step: the script only uses Bun builtins + fetch.
- run: bun apps/cli/close-topics.ts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ inputs.dry_run }}
+33 -3
View File
@@ -1,4 +1,4 @@
name: Deploy Docs
name: Deploy
on:
push:
@@ -10,8 +10,11 @@ permissions:
pages: write
id-token: write
# The Worker deploy is idempotent (wrangler uploads the whole bundle), so on a
# burst of pushes only the last one matters: cancel-in-progress is correct for
# both the Pages build and the Worker job.
concurrency:
group: pages
group: deploy
cancel-in-progress: true
jobs:
@@ -27,11 +30,17 @@ jobs:
- uses: oven-sh/setup-bun@v2 # skip if you use npm/pnpm
- run: bun install --frozen-lockfile
# blume.config.ts pulls the /changelog pages from this repo's GitHub
# releases (githubReleases source) at build time. The token authenticates
# that fetch — required if the repo is private, and it lifts the shared
# runner's unauthenticated API rate limit. `contents: read` covers it.
- run: bun run build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/upload-pages-artifact@v3
with:
path: dist
path: apps/docs/dist
deploy:
needs: build
@@ -42,3 +51,24 @@ jobs:
steps:
- id: deployment
uses: actions/deploy-pages@v4
# Deploys on every main push (not path-filtered): apps/api/data/schedule.json
# is bundled at deploy time, and re-deploying an unchanged Worker is a no-op.
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit) and
# CLOUDFLARE_ACCOUNT_ID.
deploy-api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
# DST cron guards must hold before the Worker ships.
- run: bun run api:test
- run: bun run api:deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+54
View File
@@ -0,0 +1,54 @@
name: Spaced Repetition
# Opens (or refreshes) today's `Spaced Repetition — <Month D, YYYY>` issue from
# whatever the SRS ladder has due (`POST /admin/due`, the same rows the 8 AM
# digest mails — so the two cannot disagree). The script stores nothing and
# schedules nothing: it asks D1 what is standing today, keyed by the ET date in
# the title, so a re-run rewrites one body instead of stacking duplicates.
# Sunday is the rest day and the ladder never books it, so nothing is written.
on:
schedule:
# 10:00 UTC = 06:00 ET while the campaign runs (EDT through Nov 1), 05:00 ET
# after. No DST guard and no second cron: the issue is keyed by ET calendar
# date, not by the hour it was created, so the drift is cosmetic.
- cron: "0 10 * * *"
workflow_dispatch:
inputs:
dry_run:
description: Print the issue body without creating or updating anything
type: boolean
default: false
date:
description: ET date to treat as today (YYYY-MM-DD) — backfill a missed day
type: string
default: ""
permissions:
contents: read
issues: write
concurrency:
group: spaced-repetition
cancel-in-progress: false
jobs:
drill:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
# No install step: the script only uses Bun builtins + fetch.
#
# SRS_ADMIN_KEY reads today's due list from the Worker. Unset is fatal
# here, unlike in close-solved: without D1 there is no schedule to render,
# and inventing one from issue close dates is exactly the second source of
# truth this replaced.
- run: bun apps/cli/spaced-repetition.ts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DRY_RUN: ${{ inputs.dry_run }}
SRS_TODAY: ${{ inputs.date }}
SRS_ADMIN_KEY: ${{ secrets.SRS_ADMIN_KEY }}
+36
View File
@@ -0,0 +1,36 @@
name: Sync D1 Catalog
# GitHub issues own the SRS catalog (topics, problems, set/diff labels,
# milestones); the Worker's D1 holds a reconciled copy. The 8 AM ET cron is
# the backstop — this workflow pushes curriculum edits into D1 immediately.
#
# The reconcile is a total recompute (never touches SRS-owned columns), so
# bursts can safely collapse to the last run: cancel-in-progress is correct.
on:
issues:
types:
[opened, edited, labeled, unlabeled, milestoned, demilestoned, reopened, transferred, deleted]
workflow_dispatch:
permissions: {}
concurrency:
group: sync-d1
cancel-in-progress: true
jobs:
reconcile:
# Only curriculum issues shape the catalog.
if: >-
github.event_name == 'workflow_dispatch' ||
contains(github.event.issue.labels.*.name, 'problem') ||
contains(github.event.issue.labels.*.name, 'topic')
runs-on: ubuntu-latest
steps:
- name: Reconcile catalog into D1
run: |
curl -sf -X POST -H "Authorization: Bearer $SRS_ADMIN_KEY" \
https://srs-api.prdlk.workers.dev/admin/reconcile
env:
SRS_ADMIN_KEY: ${{ secrets.SRS_ADMIN_KEY }}
+8 -1
View File
@@ -1,6 +1,8 @@
# dependencies (bun install)
node_modules
tmp/
*.log
.crush
# output
out
@@ -35,5 +37,10 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.DS_Store
/CLAUDE.md
.blume/
/docs/.wrangler/state/v3
/apps/docs/docs/.wrangler/state/v3
.blume-verify/
# api worker
apps/api/.dev.vars
apps/api/.wrangler/
apps/api/worker-configuration.d.ts
-35
View File
@@ -1,35 +0,0 @@
---
description: Sync work/ leetcode solutions into docs/solutions/ pages (gold standard format)
---
Sync every leetcode solution under `work/` into a docs page under `docs/solutions/`, formatted like the gold standard `docs/solutions/(two-pointers)/344-reverse-string.mdx`. Extra focus (optional): $@
## Run the script
The port is fully scripted — do not hand-write pages:
```
bun run sync
```
`scripts/sync.ts` does the whole pipeline:
- Globs `work/**/*.{js,py}` (`<number>.<kebab-slug>.{js,py}` under `work/<Difficulty>/<Category>/`) and groups the two language variants of a problem by leetcode number.
- Parses each file's header comment (js block comment / py docstring) into title, difficulty, statement, examples, constraints, and follow-up.
- New problems get a full page at `docs/solutions/(<category>)/<leetcode-number>-<kebab-slug>.mdx`: frontmatter, `<Badge>` topic (README curriculum table, falling back to the `work/` subdirectory), `::::warning` for follow-ups/special requirements, examples, constraints, and the solution code verbatim.
- `(<category>)` is the kebab-cased `work/` subdirectory (`(array)`, `(hash-table)`, `(two-pointers)`, …) and becomes the sidebar group; the leetcode-number filename prefix orders pages numerically. Both are stripped from the URL (`(two-pointers)/344-reverse-string.mdx` → `/solutions/reverse-string`). The script moves any page whose path drifts from this scheme.
- Frontmatter `title` keeps the number (`'344. Reverse String'`); `sidebar.label` is the number-free problem name. The script backfills a missing `sidebar.label` on existing pages but never overwrites one.
- Already-ported pages (frontmatter title starts with the leetcode number) only get their `## Solution` section regenerated — curated prose is never touched.
- Problems solved in both languages render as a `<CodeGroup>` (Python first, then JavaScript); single-language solutions render as a plain fence.
The script prints the report table: work file → docs page → created / moved / updated / skipped (already in sync).
## Review
Skim any **created** pages — the prose transforms (backticking literals in example explanations, warning detection) are heuristic. Fix wording by editing the page prose directly; it won't be overwritten on the next sync. Never edit inside `## Solution` — that section is owned by the script and synced from `work/`.
## Verify
1. `bunx blume build --isolated` must succeed with no new warnings; confirm each new route generated (`/solutions/<slug>`).
2. Spot-check one new page's built HTML for the badge, callout (when present), and solution code.
3. `rm -rf .blume-verify` when done.
+130
View File
@@ -0,0 +1,130 @@
# Repository Guidelines
## Project Overview
LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
## Architecture & Data Flow
Three owners, one direction of truth:
```mermaid
flowchart LR
work[work/ solutions] -->|bun run sync| docs[apps/docs/content/*.mdx]
work -->|close-solved.yml| PI[problem issues]
work -->|close-solved.yml POST /admin/solved| D1[(D1 = SRS state)]
PI -->|close-topics.yml| TI[topic issues]
PI -->|close-solved.yml release job| Rel[timever release per finished topic]
GH[GitHub issues = catalog] -->|nightly reconcile| D1
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
Mail -->|GET /log| D1
PI -->|/done webhook| D1
D1 -->|best-effort mirror: Target Date only| Proj[GitHub Project fields]
GH -->|close-solved.yml sync-project-fields| Proj
D1 -->|Sat midnight ET| Review[Review — Week N issue]
PI -->|"spaced-repetition.yml (6 AM ET cron, closed +3d/+7d)"| SR[Spaced Repetition — Month D issue]
D1 --> Charts[SVG charts + /api/stats]
Charts -->|bun run stats| TUI[terminal dashboard]
```
- **Reconcile, don't react.** `close-solved.ts`, `close-topics.ts`, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional. `close-solved` posts its *whole* implemented set to `/admin/solved`, not just the issues it closed this run.
- **Solving has three doors, one write path.** A digest one-tap, a `/done` comment, and a solution landing in `work/` all end in `logAttempt()`. Closing an issue is *not* one of them: the catalog reconcile never reads issue state, so a `work/` push that skipped the email would otherwise leave `stage='new'` — invisible to the charts, the digest's solved ticks, and the drill/gate pools. Hence `/admin/solved`, which takes `{lc, bucket}` pairs (`source='commit'`): the bucket names the rung the file settles — `work/1` settles `new`, `work/3` settles `+3`, `work/7` settles `+7` — so a pushed re-solve advances the ladder instead of vanishing, and re-posting the whole implemented set writes nothing because the rung named has already been left behind. That stage check is the entire idempotency mechanism; no index, no dedupe table, no state file. `close-topics` needs no such call — D1 stores no topic completion, only problem rows.
- **The rest day is structural, not cosmetic.** Sunday is never booked: topics run Mon–Fri (`apps/api/data/schedule.json`), the gate is Saturday, and the ladder's windows are 3 and 7 precisely because those return a solve to a working day. Every scheduled date — ladder review, levelled overflow, deferred-Hard release, review-issue window — comes out of `workingDay()` in `apps/api/src/srs.ts`, which slides the one exception (a Thursday solve's `+3`) forward to Monday. Forward, never back: a review may slip later than its interval, never shorten it. Never mint a scheduled date with bare `addDays()`, and never "fix" a Sunday landing downstream — the digest's Sunday rest branch is a courtesy, not the mechanism. `apps/api/src/srs.test.ts` sweeps the campaign calendar to keep this honest.
- **One schedule, one answer to "what is open today".** D1's ladder IS the schedule; `dueToday()` (`POST /admin/due`) is its read side and `DUE_WHERE` is the single SQL definition of "due", shared by the digest, the review issue and the docs queue chart. `spaced-repetition.yml` renders that answer into today's `Spaced Repetition — <Month D, YYYY>` issue — grouped by rung, `WINDOW_CAP` (3) required per rung with the overflow listed as optional, keyed by the ET date in the title so a re-run rewrites one body. It schedules nothing, stores nothing, and writes nothing on Sunday. Without `SRS_ADMIN_KEY` it FAILS rather than inventing a second schedule from issue close dates — that reconstruction was the drift, and it is gone. NEVER add a JSON state file (`.github/problems.json` and friends) or a second "when is this due" rule: D1 answers it, with one writer (`logAttempt`) and git-free history in `attempts`.
- **The picker owns the local review loop; the buckets are its state.** `work/` holds three buckets (`1` = first solve, `3`/`7` = blind re-solves, named after the ladder rungs) and `bun run pick` has three sections — Tab cycles them. Pools are reconciled from the filesystem plus one `git log` (`apps/cli/work.ts`): a first solve dates from the ET date of the commit that added its `work/1` file — the same push that closes the problem issue and starts the +3/+7 windows — and a window stops being owed the moment its bucket holds a file. Uncapped, unlike the review issue's `WINDOW_CAP`: that issue is one day's assignment, the picker is everything still owed. An empty scaffold under `work/1` owes nothing (it is unsolved), which is why `isImplemented()` lives in `apps/cli/source.ts` and is shared with `close-solved.ts`. leetcode-cli reads its output directory from `~/.leetcode/workspaces/<active>/config.json` and nowhere else — no flag, no env var — so `pick.ts` MERGES `workDir` into that file around the child (never replays saved bytes: the CLI owns the file too) and leaves it pointed at `work/1`, then treats the scaffold's existence as the acceptance test, since leetcode-cli exits 0 even when an expired session made it write nothing. The picker itself writes no state: pushing the re-solve is what moves the ladder, via `close-solved` → `/admin/solved` with that file's bucket.
- **The README charts are live, never committed.** They are Worker endpoints reading D1 per request (`Cache-Control: max-age=300`, honored by GitHub Camo), so a solved push moves them within ~5 minutes with no commit and no workflow of their own.
- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). `Target Date` is the *only* Project field the Worker mirrors (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written. SRS stage and first-attempt result live **only** in D1 — do not re-add them as Project fields. The Project's `Set`/`Difficulty` single-selects are a projection of the issue labels, reconciled by `bun run sync-project-fields` — a best-effort final step of `close-solved.yml`, never the Worker.
- **The plan names days; the samplers fill the gaps.** `apps/api/data/schedule.json` maps a date to a `ScheduledDay` (`apps/api/src/srs.ts`): `topics` (one or more topic issues — a doubled day lists two, and the digest renders each with its core list then the optional "if time" list), `problems` (a set day: fixed numbers, rendered like a topic), `gate` (Saturday's blind set, verbatim — `createReviewIssue` refuses to create the issue if any number is not a catalog row, so gate problems must exist as `set:optional` sub-issues of their topic first), or `sweep` (every problem whose last attempt failed, taps to re-log, no drills). Every number a `gate` or `problems` entry names is in `RESERVED`, and the drill/gate samplers never draw it — that is what keeps a planned gate problem unseen until its Saturday. Dates absent from the plan still get the ladder's reviews and drills; a Saturday without a `gate` entry falls back to sampling this week's optional pools.
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first — Blume's changelog makes it the page's meta description — then closed on the finish day / earlier / open at release, with solution links at the tag). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and only has its body rewritten when the render changes. The docs `/changelog` is these releases (`githubReleases` in `apps/docs/blume.config.ts`, fetched at build with `GITHUB_TOKEN`), dated by `published_at` — which the API cannot backdate, so the Aug 24 – Sep 24 backfill reads Sep 25 there, and its true finish time lives in the version and the summary.
## Key Directories
| Path | Purpose |
|---|---|
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/<num>-<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync: the phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment (`/tree-dfs/invert-binary-tree`), the `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order with each topic a collapsible group, and the page badge is the `work/` data-structure category. A hand-written `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: `islands/<Name>.tsx` is hydrated React (auto-discovered; `ProgressDashboard`, `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigation.json` reports for the topic folder, inert until a topic has pages), `components/<Name>.astro` registered in `components.ts` is static markup plus a custom element for behaviour, the way Blume's own `Frame` is built (`Carousel`/`CarouselImage`: `src` takes a URL, `/file.png`, or the page-relative `../../public/file.png`) |
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
| `.github/workflows/` | deploy (docs → Pages with `GITHUB_TOKEN` for the releases-backed changelog, Worker → Cloudflare, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
| `.githooks/` | Tracked git hooks; `bun install` points `core.hooksPath` here (root `prepare` script, no husky). `pre-commit` runs `bun run sync` and stages only what sync changed (pages, `apps/docs/redirects.ts`), so a solution, its page and its `/<num>` short link land in one commit; it refuses to run while `work/1` has anything unstaged (sync reads the tree, so the page would not match the commit), and leaves pre-existing dirt under `apps/docs/content` (in-progress `## Approach` prose) unstaged. `git commit --no-verify` for a deliberate partial commit |
## Development Commands
All from the repo root (a Bun workspace over `apps/*`):
```sh
bun run pick # scaffold a solution via leetcode-cli (fuzzy picker; Tab cycles the
# sections: new problems → work/1, then the owed 3-day and 7-day
# re-solves → work/3, work/7. Each section hides what its own bucket
# already holds; index+descriptions cached in
# ~/.local/share/leetcode/data.db — see apps/cli/db.ts)
bun run stats # terminal dashboard over GET /api/stats — four panes (Overview,
# Ladder, Concepts, Activity); Tab/1-4 switches, ↑↓ scrolls,
# r refetches, q quits. Outside a TTY it prints every pane once.
# SRS_API points it at a local Worker like close-solved does.
bun run test # run ONE solution against LeetCode's judge (not a test suite;
# most recently tested first, stamped in the db on exit 0)
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
# hidden; last_submitted stamped in the db on exit 0)
bun run sync # work/1 → apps/docs/content/ pages + apps/docs/redirects.ts (/<num> short
# links; re-solves are never published)
bun run videos # docs pages with no <YouTube> embed → NeetCode video per page
# (channel search scraped, no API key; --write appends the
# `## Explanation` embed; "no NeetCode match" pages stay untouched)
bun run dev|build # Blume docs site (runs in apps/docs/)
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
bun run close-topics -- --dry-run
bun run release-topics -- --dry-run # prints every would-be topic release body
# close-solved also pushes the solved set to the Worker; point it at a local
# one and nothing production is touched:
SRS_API=http://localhost:8787 SRS_ADMIN_KEY=$LINK_KEY bun run close-solved -- --dry-run
# Project Set/Difficulty from the issue labels (needs a `project`-scoped token;
# runs as the last step of close-solved.yml in CI):
GH_TOKEN=$PROJECT_PAT bun run sync-project-fields -- --dry-run
# today's review issue from the +3d/+7d closes (--date backfills a missed day;
# a Sunday date writes nothing — the rest day has no assignment):
bun run spaced-repetition -- --dry-run --date=2026-08-31
bun run api:dev # wrangler dev on :8787 (local D1)
bun run api:test # DST guards + the rest-day calendar sweep
bun run api:deploy # deploy the Worker by hand (CI also deploys on main pushes)
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
"localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing
```
## Code Conventions & Common Patterns
- **Scripts are either entries or libraries.** Entries (`apps/cli/close-*.ts`) use shebang + top-level `await`. Libraries (`apps/cli/github.ts`, `apps/cli/report.ts`, everything in `apps/api/src/`) are side-effect-free on import — nothing touches network/credentials until a factory (`github()`, `projectMirror()`) is called.
- **Shared plumbing:** `github()` gives `repo`, `api()` (throws `METHOD path -> status body`), `list()` (paginating async generator), `closeIssue()` (comment **first**, then close — a failed PATCH still leaves a trace). `report.ts` gives `printTable`/`markdownTable`/`writeStepSummary`.
- **Narrow API payloads with guards**, not inline casts: `if (!("number" in value) || typeof value.number !== "number") return;` (see `readProblemIssue` in `close-solved.ts`). Named-const casts only with a reason.
- **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; Worker admin routes take `?dry=1&force=1&date=` (the `SRS_TODAY` equivalent) and `wrangler dev` runs against local D1 — exercise logic there before touching production state.
- **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `apps/api/src/srs.ts`) to dodge DST. Never `new Date()` math directly.
- **Style:** section-divider comments (`// ── name ───`), file-top doc comments explaining the *why* and invariants, 2-space JSON with trailing newline, `Map` for dynamic keys / `Record` for static tables, no tiny one-expression wrapper functions.
- **Known intentional duplication:** `sync.ts` keeps its own header split instead of sharing `apps/cli/source.ts`, because `sync.ts` runs its whole pipeline on import and so cannot be imported from. Don't "deduplicate" it. `close-solved.ts` and `work.ts` do share it — the stub test decides both "close the issue" and "owes a review", and the two must never disagree.
## Important Files
- `apps/api/src/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/<n>` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`.
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/56) LeetCode Daily Digest`.
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the `<Badge>` line and the `## Solution` section are script-owned, the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the badge is the `work/1/<Difficulty>/<Category>/` category. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`, both `meta.ts` script-owned and rewritten every run (phase: `Phase N: Name`, `order: N`; topic: label, curriculum order, `TOPIC_ICONS` icon, `display: "group"`). The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The two hand-owned sections around the solution have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); `## Explanation` (the NeetCode `<YouTube>` embed, after `## Solution`) is appended by `bun run videos --write` (`apps/cli/videos.ts`).
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, per-topic temperature, gates, the 14-day due queue, and `heat` (every campaign day, zeros and future days included — the docs page windows the tail off it rather than asking a second question). Two readers consume it, the docs island and `bun run stats`, so its shape is both their contract: change all three together. `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`apps/docs/islands/ProgressDashboard.tsx`).
- `apps/docs/blume.config.ts` — site base `/leetcode`; imports `apps/docs/redirects.ts`, sync-generated (`/<num>` → `/<topic>/<slug>`, one row per page, exact root-relative paths — Blume adds the base and, for the static Pages build, emits a meta-refresh page per rule plus `blume-redirects.json`); `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`).
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
## Runtime/Tooling Preferences
- **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`. Node 22 appears only in `deploy.yml` because Blume requires it.
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. The **one** exception is the digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. Do not extend this exception to any other module.
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
## Testing & QA
- Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `apps/api/src/srs.test.ts` (`bun:test`) proves the DST cron guards with fixed dates; run via `bun run api:test`.
- QA is dry-runs + local state: Worker logic against `wrangler dev` + local D1 with `?dry=1&date=`; reconcilers with `--dry-run`; idempotency check = run twice, `cmp` output.
- Docs changes: `bunx blume build --isolated` (from `apps/docs/`) must pass with no new warnings (then `rm -rf apps/docs/.blume-verify`); after sync, review only *created* pages (prose transforms are heuristic).
+52 -36
View File
@@ -1,14 +1,31 @@
<p align="center">
<img alt="header" src="public/dependency-spine.svg" />
<img alt="header" src="https://shieldcn.dev/header/gradient.svg?title=prdlk%2Fleetcode&amp;subtitle=My+Personal+LeetCode+Solutions&amp;logo=leetcode&amp;logoColor=eab308&amp;mode=dark" />
</p>
<p align="center">
<a href="https://x.com/basedprad"><img alt="follow" src="https://shieldcn.dev/x/follow/basedprad.svg" /></a>
<a href="https://lc.prad.nu"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
<a href="https://leetcode.com/progress"><img alt="Custom badge" src="https://shieldcn.dev/badge/view-@prdlk.svg?logo=leetcode" /></a>
<a href="https://lc.prd.lk"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
</p>
<p align="center">
<sub>8-week interview-prep fight camp. Each phase is a milestone; each topic an issue; each problem a sub-issue.<br>
New topic Mon–Fri, gate on Saturday, Sunday off — every solve comes back at <strong>+3</strong> and <strong>+7</strong> days,
the two windows that keep the rest day clear (a Thursday solve's +3 slides to Monday).<br>
Charts are live from the SRS Worker, never committed: pushing a solution or tapping the daily digest moves them within ~5 min.</sub>
</p>
## Phase I — Linear Structures (Week 1)
<table align="center">
<tr>
<td colspan="2" align="center"><img alt="phase progress" width="100%" src="https://srs-api.prdlk.workers.dev/chart/progress.svg" /></td>
</tr>
<tr>
<td align="center" width="53%"><img alt="SRS ladder — new / +3 / +7 / retired" width="100%" src="https://srs-api.prdlk.workers.dev/chart/ladder.svg" /></td>
<td align="center" width="47%"><img alt="attempt heatmap" width="100%" src="https://srs-api.prdlk.workers.dev/chart/heatmap.svg" /></td>
</tr>
</table>
## Phase I — Linear Structures · Weeks 1–2
<table>
<thead>
@@ -21,7 +38,7 @@
</thead>
<tbody>
<tr>
<td rowspan="9"><strong>1. Hash-Based Lookup</strong><br><sub>builds on nothing</sub></td>
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/3"><strong>1. Hash-Based Lookup</strong></a><br><sub>Wk 1 · Mon Aug 17</sub><br><sub>builds on nothing</sub></td>
<td align="center"><code>A</code></td><td align="center">217</td><td><a href="https://leetcode.com/problems/contains-duplicate/">Contains Duplicate</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">242</td><td><a href="https://leetcode.com/problems/valid-anagram/">Valid Anagram</a></td></tr>
@@ -33,7 +50,7 @@
<tr><td align="center"><code>B</code></td><td align="center">128</td><td><a href="https://leetcode.com/problems/longest-consecutive-sequence/">Longest Consecutive Sequence</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">290</td><td><a href="https://leetcode.com/problems/word-pattern/">Word Pattern</a></td></tr>
<tr>
<td rowspan="5"><strong>2. Prefix Sum</strong><br><sub>builds on 1</sub></td>
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/4"><strong>2. Prefix Sum</strong></a><br><sub>Wk 1 · Tue Aug 18</sub><br><sub>builds on 1</sub></td>
<td align="center"><code>A</code></td><td align="center">303</td><td><a href="https://leetcode.com/problems/range-sum-query-immutable/">Range Sum Query</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">238</td><td><a href="https://leetcode.com/problems/product-of-array-except-self/">Product of Array Except Self</a></td></tr>
@@ -41,7 +58,7 @@
<tr><td align="center"><code>B</code></td><td align="center">525</td><td><a href="https://leetcode.com/problems/contiguous-array/">Contiguous Array</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">974</td><td><a href="https://leetcode.com/problems/subarray-sums-divisible-by-k/">Subarray Sums Divisible by K</a></td></tr>
<tr>
<td rowspan="9"><strong>3. Two Pointers</strong><br><sub>builds on ordered iteration</sub></td>
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/5"><strong>3. Two Pointers</strong></a><br><sub>Wk 2 · Mon Aug 24</sub><br><sub>builds on ordered iteration</sub></td>
<td align="center"><code>A</code></td><td align="center">125</td><td><a href="https://leetcode.com/problems/valid-palindrome/">Valid Palindrome</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">167</td><td><a href="https://leetcode.com/problems/two-sum-ii-input-array-is-sorted/">Two Sum II</a></td></tr>
@@ -53,7 +70,7 @@
<tr><td align="center"><code>B</code></td><td align="center">189</td><td><a href="https://leetcode.com/problems/rotate-array/">Rotate Array</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">80</td><td><a href="https://leetcode.com/problems/remove-duplicates-from-sorted-array-ii/">Remove Duplicates II</a></td></tr>
<tr>
<td rowspan="8"><strong>4. Sliding Window</strong><br><sub>builds on 3</sub></td>
<td rowspan="8"><a href="https://github.com/prdlk/leetcode/issues/6"><strong>4. Sliding Window</strong></a><br><sub>Wk 2 · Tue Aug 25</sub><br><sub>builds on 3</sub></td>
<td align="center"><code>A</code></td><td align="center">121</td><td><a href="https://leetcode.com/problems/best-time-to-buy-and-sell-stock/">Best Time to Buy and Sell Stock</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">3</td><td><a href="https://leetcode.com/problems/longest-substring-without-repeating-characters/">Longest Substring Without Repeating Characters</a></td></tr>
@@ -64,7 +81,7 @@
<tr><td align="center"><code>B</code></td><td align="center">1004</td><td><a href="https://leetcode.com/problems/max-consecutive-ones-iii/">Max Consecutive Ones III</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">643</td><td><a href="https://leetcode.com/problems/maximum-average-subarray-i/">Maximum Average Subarray</a></td></tr>
<tr>
<td rowspan="9"><strong>5. Binary Search</strong><br><sub>builds on ordered / monotonic invariants</sub></td>
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/7"><strong>5. Binary Search</strong></a><br><sub>Wk 2 · Wed Aug 26</sub><br><sub>builds on ordered / monotonic invariants</sub></td>
<td align="center"><code>A</code></td><td align="center">704</td><td><a href="https://leetcode.com/problems/binary-search/">Binary Search</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">74</td><td><a href="https://leetcode.com/problems/search-a-2d-matrix/">Search a 2D Matrix</a></td></tr>
@@ -76,7 +93,7 @@
<tr><td align="center"><code>B</code></td><td align="center">34</td><td><a href="https://leetcode.com/problems/find-first-and-last-position-of-element-in-sorted-array/">First &amp; Last Position</a></td></tr>
<tr><td align="center"><code>B</code> ⭐</td><td align="center">4</td><td><a href="https://leetcode.com/problems/median-of-two-sorted-arrays/">Median of Two Sorted Arrays</a></td></tr>
<tr>
<td rowspan="5"><strong>6. Stack</strong><br><sub>independent primitive</sub></td>
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/8"><strong>6. Stack</strong></a><br><sub>Wk 2 · Thu Aug 27</sub><br><sub>independent primitive</sub></td>
<td align="center"><code>A</code></td><td align="center">20</td><td><a href="https://leetcode.com/problems/valid-parentheses/">Valid Parentheses</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">155</td><td><a href="https://leetcode.com/problems/min-stack/">Min Stack</a></td></tr>
@@ -84,7 +101,7 @@
<tr><td align="center"><code>B</code></td><td align="center">71</td><td><a href="https://leetcode.com/problems/simplify-path/">Simplify Path</a></td></tr>
<tr><td align="center"><code>B</code> ⭐</td><td align="center">224</td><td><a href="https://leetcode.com/problems/basic-calculator/">Basic Calculator</a></td></tr>
<tr>
<td rowspan="5"><strong>7. Monotonic Stack</strong><br><sub>builds on 6</sub></td>
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/9"><strong>7. Monotonic Stack</strong></a><br><sub>Wk 2 · Fri Aug 28</sub><br><sub>builds on 6</sub></td>
<td align="center"><code>A</code></td><td align="center">739</td><td><a href="https://leetcode.com/problems/daily-temperatures/">Daily Temperatures</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">853</td><td><a href="https://leetcode.com/problems/car-fleet/">Car Fleet</a></td></tr>
@@ -93,9 +110,9 @@
<tr><td align="center"><code>B</code> ⭐</td><td align="center">84</td><td><a href="https://leetcode.com/problems/largest-rectangle-in-histogram/">Largest Rectangle in Histogram</a></td></tr>
</tbody>
</table>
---
## Phase II — Nodal &amp; Grid Structures (Week 2)
## Phase II — Nodal &amp; Grid Structures · Week 3
<table>
<thead>
@@ -108,7 +125,7 @@
</thead>
<tbody>
<tr>
<td rowspan="10"><strong>8. Linked List</strong><br><sub>independent primitive</sub></td>
<td rowspan="10"><a href="https://github.com/prdlk/leetcode/issues/10"><strong>8. Linked List</strong></a><br><sub>Wk 3 · Mon Aug 31</sub><br><sub>independent primitive</sub></td>
<td align="center"><code>A</code></td><td align="center">206</td><td><a href="https://leetcode.com/problems/reverse-linked-list/">Reverse Linked List</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">21</td><td><a href="https://leetcode.com/problems/merge-two-sorted-lists/">Merge Two Sorted Lists</a></td></tr>
@@ -121,14 +138,14 @@
<tr><td align="center"><code>B</code></td><td align="center">61</td><td><a href="https://leetcode.com/problems/rotate-list/">Rotate List</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">86</td><td><a href="https://leetcode.com/problems/partition-list/">Partition List</a></td></tr>
<tr>
<td rowspan="4"><strong>9. Hybrid Structures</strong><br><sub>builds on 1 + 8</sub></td>
<td rowspan="4"><a href="https://github.com/prdlk/leetcode/issues/11"><strong>9. Hybrid Structures</strong></a><br><sub>Wk 3 · Tue Sep 1</sub><br><sub>builds on 1 + 8</sub></td>
<td align="center"><code>A</code></td><td align="center">146</td><td><a href="https://leetcode.com/problems/lru-cache/">LRU Cache</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">2</td><td><a href="https://leetcode.com/problems/add-two-numbers/">Add Two Numbers</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">138</td><td><a href="https://leetcode.com/problems/copy-list-with-random-pointer/">Copy List with Random Pointer</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">380</td><td><a href="https://leetcode.com/problems/insert-delete-getrandom-o1/">Insert Delete GetRandom O(1)</a></td></tr>
<tr>
<td rowspan="5"><strong>10. Matrix Index Math</strong><br><sub>builds on 3 — NOT graph traversal</sub></td>
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/12"><strong>10. Matrix Index Math</strong></a><br><sub>Wk 3 · Wed Sep 2</sub><br><sub>builds on 3 — NOT graph traversal</sub></td>
<td align="center"><code>A</code></td><td align="center">48</td><td><a href="https://leetcode.com/problems/rotate-image/">Rotate Image</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">54</td><td><a href="https://leetcode.com/problems/spiral-matrix/">Spiral Matrix</a></td></tr>
@@ -137,9 +154,9 @@
<tr><td align="center"><code>B</code></td><td align="center">289</td><td><a href="https://leetcode.com/problems/game-of-life/">Game of Life</a></td></tr>
</tbody>
</table>
---
## Phase III — Hierarchical Structures (Week 3)
## Phase III — Hierarchical Structures · Weeks 3–4
<table>
<thead>
@@ -152,7 +169,7 @@
</thead>
<tbody>
<tr>
<td rowspan="10"><strong>11. Tree DFS</strong><br><sub>builds on recursion</sub></td>
<td rowspan="10"><a href="https://github.com/prdlk/leetcode/issues/13"><strong>11. Tree DFS</strong></a><br><sub>Wk 3 · Thu Sep 3</sub><br><sub>builds on recursion</sub></td>
<td align="center"><code>A</code></td><td align="center">226</td><td><a href="https://leetcode.com/problems/invert-binary-tree/">Invert Binary Tree</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">104</td><td><a href="https://leetcode.com/problems/maximum-depth-of-binary-tree/">Maximum Depth of Binary Tree</a></td></tr>
@@ -165,7 +182,7 @@
<tr><td align="center"><code>B</code></td><td align="center">129</td><td><a href="https://leetcode.com/problems/sum-root-to-leaf-numbers/">Sum Root to Leaf Numbers</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">543</td><td><a href="https://leetcode.com/problems/diameter-of-binary-tree/">Diameter of Binary Tree</a></td></tr>
<tr>
<td rowspan="5"><strong>12. Tree BFS</strong><br><sub>builds on 6's sibling (queue) + 11</sub></td>
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/14"><strong>12. Tree BFS</strong></a><br><sub>Wk 3 · Fri Sep 4</sub><br><sub>builds on 6's sibling (queue) + 11</sub></td>
<td align="center"><code>A</code></td><td align="center">102</td><td><a href="https://leetcode.com/problems/binary-tree-level-order-traversal/">Level Order Traversal</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">199</td><td><a href="https://leetcode.com/problems/binary-tree-right-side-view/">Right Side View</a></td></tr>
@@ -173,7 +190,7 @@
<tr><td align="center"><code>B</code></td><td align="center">103</td><td><a href="https://leetcode.com/problems/binary-tree-zigzag-level-order-traversal/">Zigzag Level Order</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">637</td><td><a href="https://leetcode.com/problems/average-of-levels-in-binary-tree/">Average of Levels</a></td></tr>
<tr>
<td rowspan="7"><strong>13. Binary Search Tree</strong><br><sub>builds on 5 + 11</sub></td>
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/15"><strong>13. Binary Search Tree</strong></a><br><sub>Wk 4 · Mon Sep 7</sub><br><sub>builds on 5 + 11</sub></td>
<td align="center"><code>A</code></td><td align="center">98</td><td><a href="https://leetcode.com/problems/validate-binary-search-tree/">Validate BST</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">230</td><td><a href="https://leetcode.com/problems/kth-smallest-element-in-a-bst/">Kth Smallest in BST</a></td></tr>
@@ -183,7 +200,7 @@
<tr><td align="center"><code>B</code></td><td align="center">173</td><td><a href="https://leetcode.com/problems/binary-search-tree-iterator/">BST Iterator</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">108</td><td><a href="https://leetcode.com/problems/convert-sorted-array-to-binary-search-tree/">Sorted Array to BST</a></td></tr>
<tr>
<td rowspan="9"><strong>14. Heap / Priority Queue</strong><br><sub>implicit tree over array</sub></td>
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/16"><strong>14. Heap / Priority Queue</strong></a><br><sub>Wk 4 · Tue Sep 8</sub><br><sub>implicit tree over array</sub></td>
<td align="center"><code>A</code></td><td align="center">215</td><td><a href="https://leetcode.com/problems/kth-largest-element-in-an-array/">Kth Largest in Array</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">703</td><td><a href="https://leetcode.com/problems/kth-largest-element-in-a-stream/">Kth Largest in Stream</a></td></tr>
@@ -195,16 +212,16 @@
<tr><td align="center"><code>B</code> ⭐</td><td align="center">295</td><td><a href="https://leetcode.com/problems/find-median-from-data-stream/">Find Median from Data Stream</a></td></tr>
<tr><td align="center"><code>B</code> ⭐</td><td align="center">502</td><td><a href="https://leetcode.com/problems/ipo/">IPO</a></td></tr>
<tr>
<td rowspan="3"><strong>15. Trie</strong><br><sub>builds on 11</sub></td>
<td rowspan="3"><a href="https://github.com/prdlk/leetcode/issues/17"><strong>15. Trie</strong></a><br><sub>Wk 4 · Wed Sep 9</sub><br><sub>builds on 11</sub></td>
<td align="center"><code>A</code></td><td align="center">208</td><td><a href="https://leetcode.com/problems/implement-trie-prefix-tree/">Implement Trie</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">211</td><td><a href="https://leetcode.com/problems/design-add-and-search-words-data-structure/">Add &amp; Search Words</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">212</td><td><a href="https://leetcode.com/problems/word-search-ii/">Word Search II</a> <sub>(needs topic 20 — schedule after)</sub></td></tr>
</tbody>
</table>
---
## Phase IV — Relational Structures (Week 4)
## Phase IV — Relational Structures · Weeks 4–5
<table>
<thead>
@@ -217,7 +234,7 @@
</thead>
<tbody>
<tr>
<td rowspan="7"><strong>16. Graph DFS</strong><br><sub>builds on 11 + visited set</sub></td>
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/18"><strong>16. Graph DFS</strong></a><br><sub>Wk 4 · Thu Sep 10</sub><br><sub>builds on 11 + visited set</sub></td>
<td align="center"><code>A</code></td><td align="center">200</td><td><a href="https://leetcode.com/problems/number-of-islands/">Number of Islands</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">695</td><td><a href="https://leetcode.com/problems/max-area-of-island/">Max Area of Island</a></td></tr>
@@ -227,7 +244,7 @@
<tr><td align="center"><code>B</code></td><td align="center">399</td><td><a href="https://leetcode.com/problems/evaluate-division/">Evaluate Division</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">261</td><td><a href="https://leetcode.com/problems/graph-valid-tree/">Graph Valid Tree</a></td></tr>
<tr>
<td rowspan="5"><strong>17. Graph BFS</strong><br><sub>builds on 12 + visited set — shortest path unweighted</sub></td>
<td rowspan="5"><a href="https://github.com/prdlk/leetcode/issues/19"><strong>17. Graph BFS</strong></a><br><sub>Wk 4 · Fri Sep 11</sub><br><sub>builds on 12 + visited set — shortest path unweighted</sub></td>
<td align="center"><code>A</code></td><td align="center">994</td><td><a href="https://leetcode.com/problems/rotting-oranges/">Rotting Oranges</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">127</td><td><a href="https://leetcode.com/problems/word-ladder/">Word Ladder</a></td></tr>
@@ -235,14 +252,14 @@
<tr><td align="center"><code>B</code></td><td align="center">433</td><td><a href="https://leetcode.com/problems/minimum-genetic-mutation/">Minimum Genetic Mutation</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">286</td><td><a href="https://leetcode.com/problems/walls-and-gates/">Walls and Gates</a></td></tr>
<tr>
<td rowspan="4"><strong>18. Topological Sort</strong><br><sub>builds on 16</sub></td>
<td rowspan="4"><a href="https://github.com/prdlk/leetcode/issues/20"><strong>18. Topological Sort</strong></a><br><sub>Wk 5 · Mon Sep 14</sub><br><sub>builds on 16</sub></td>
<td align="center"><code>A</code></td><td align="center">207</td><td><a href="https://leetcode.com/problems/course-schedule/">Course Schedule</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">210</td><td><a href="https://leetcode.com/problems/course-schedule-ii/">Course Schedule II</a></td></tr>
<tr><td align="center"><code>B</code> ⭐</td><td align="center">269</td><td><a href="https://leetcode.com/problems/alien-dictionary/">Alien Dictionary</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">1462</td><td><a href="https://leetcode.com/problems/course-schedule-iv/">Course Schedule IV</a></td></tr>
<tr>
<td rowspan="4"><strong>19. Union-Find</strong><br><sub>builds on 16's problem class</sub></td>
<td rowspan="4"><a href="https://github.com/prdlk/leetcode/issues/21"><strong>19. Union-Find</strong></a><br><sub>Wk 5 · Tue Sep 15</sub><br><sub>builds on 16's problem class</sub></td>
<td align="center"><code>A</code></td><td align="center">684</td><td><a href="https://leetcode.com/problems/redundant-connection/">Redundant Connection</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">323</td><td><a href="https://leetcode.com/problems/number-of-connected-components-in-an-undirected-graph/">Number of Connected Components</a></td></tr>
@@ -250,9 +267,9 @@
<tr><td align="center"><code>B</code></td><td align="center">721</td><td><a href="https://leetcode.com/problems/accounts-merge/">Accounts Merge</a></td></tr>
</tbody>
</table>
---
## Phase V — Decision-Space Exploration (Weeks 4–5)
## Phase V — Decision-Space Exploration · Weeks 5–6
<table>
<thead>
@@ -265,7 +282,7 @@
</thead>
<tbody>
<tr>
<td rowspan="8"><strong>20. Backtracking</strong><br><sub>builds on 16 — then unlock LC 212 from topic 15</sub></td>
<td rowspan="8"><a href="https://github.com/prdlk/leetcode/issues/22"><strong>20. Backtracking</strong></a><br><sub>Wk 5 · Wed Sep 16</sub><br><sub>builds on 16 — then unlock LC 212 from topic 15</sub></td>
<td align="center"><code>A</code></td><td align="center">78</td><td><a href="https://leetcode.com/problems/subsets/">Subsets</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">39</td><td><a href="https://leetcode.com/problems/combination-sum/">Combination Sum</a></td></tr>
@@ -276,7 +293,7 @@
<tr><td align="center"><code>B</code></td><td align="center">22</td><td><a href="https://leetcode.com/problems/generate-parentheses/">Generate Parentheses</a></td></tr>
<tr><td align="center"><code>B</code> ⭐</td><td align="center">52</td><td><a href="https://leetcode.com/problems/n-queens-ii/">N-Queens II</a></td></tr>
<tr>
<td rowspan="9"><strong>21. 1-D Dynamic Programming</strong><br><sub>builds on 20</sub></td>
<td rowspan="9"><a href="https://github.com/prdlk/leetcode/issues/23"><strong>21. 1-D Dynamic Programming</strong></a><br><sub>Wk 5 · Thu Sep 17</sub><br><sub>builds on 20</sub></td>
<td align="center"><code>A</code></td><td align="center">70</td><td><a href="https://leetcode.com/problems/climbing-stairs/">Climbing Stairs</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">746</td><td><a href="https://leetcode.com/problems/min-cost-climbing-stairs/">Min Cost Climbing Stairs</a></td></tr>
@@ -288,7 +305,7 @@
<tr><td align="center"><code>B</code></td><td align="center">91</td><td><a href="https://leetcode.com/problems/decode-ways/">Decode Ways</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">647</td><td><a href="https://leetcode.com/problems/palindromic-substrings/">Palindromic Substrings</a></td></tr>
<tr>
<td rowspan="8"><strong>22. Multi-D / Grid DP</strong><br><sub>builds on 21</sub></td>
<td rowspan="8"><a href="https://github.com/prdlk/leetcode/issues/24"><strong>22. Multi-D / Grid DP</strong></a><br><sub>Wk 5 · Fri Sep 18</sub><br><sub>builds on 21</sub></td>
<td align="center"><code>A</code></td><td align="center">62</td><td><a href="https://leetcode.com/problems/unique-paths/">Unique Paths</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">64</td><td><a href="https://leetcode.com/problems/minimum-path-sum/">Minimum Path Sum</a></td></tr>
@@ -299,7 +316,7 @@
<tr><td align="center"><code>B</code></td><td align="center">221</td><td><a href="https://leetcode.com/problems/maximal-square/">Maximal Square</a></td></tr>
<tr><td align="center"><code>B</code> ⭐</td><td align="center">97</td><td><a href="https://leetcode.com/problems/interleaving-string/">Interleaving String</a></td></tr>
<tr>
<td rowspan="7"><strong>23. Greedy</strong><br><sub>builds on 21 — includes Kadane's</sub></td>
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/25"><strong>23. Greedy</strong></a><br><sub>Wk 6 · Mon Sep 21</sub><br><sub>builds on 21 — includes Kadane's</sub></td>
<td align="center"><code>A</code></td><td align="center">53</td><td><a href="https://leetcode.com/problems/maximum-subarray/">Maximum Subarray</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">55</td><td><a href="https://leetcode.com/problems/jump-game/">Jump Game</a></td></tr>
@@ -309,7 +326,7 @@
<tr><td align="center"><code>B</code></td><td align="center">918</td><td><a href="https://leetcode.com/problems/maximum-sum-circular-subarray/">Maximum Sum Circular Subarray</a></td></tr>
<tr><td align="center"><code>B</code></td><td align="center">763</td><td><a href="https://leetcode.com/problems/partition-labels/">Partition Labels</a></td></tr>
<tr>
<td rowspan="7"><strong>24. Intervals</strong><br><sub>builds on 23</sub></td>
<td rowspan="7"><a href="https://github.com/prdlk/leetcode/issues/26"><strong>24. Intervals</strong></a><br><sub>Wk 6 · Tue Sep 22</sub><br><sub>builds on 23</sub></td>
<td align="center"><code>A</code></td><td align="center">57</td><td><a href="https://leetcode.com/problems/insert-interval/">Insert Interval</a></td>
</tr>
<tr><td align="center"><code>A</code></td><td align="center">56</td><td><a href="https://leetcode.com/problems/merge-intervals/">Merge Intervals</a></td></tr>
@@ -320,4 +337,3 @@
<tr><td align="center"><code>B</code></td><td align="center">252</td><td><a href="https://leetcode.com/problems/meeting-rooms/">Meeting Rooms I</a></td></tr>
</tbody>
</table>
+3
View File
@@ -0,0 +1,3 @@
GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw
WEBHOOK_SECRET=random_hex_matching_the_repo_webhook
LINK_KEY=random_hex_signing_one_tap_links
+133
View File
@@ -0,0 +1,133 @@
# srs-api
Cloudflare Worker running the spaced-repetition system: daily digest email,
one-tap logging, `/done` webhook, Saturday review issues, live SVG charts,
and the `/api/stats` feed for the docs progress page.
Live at `https://srs-api.prdlk.workers.dev`.
## Direction of truth
- **D1 owns SRS state** — stages, review dates, attempt history, gate
scores, boost flags. Nothing else is authoritative.
- **GitHub issues own the catalog** — topics, problems, `set:*`/`diff:*`
labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via
`/admin/reconcile`); it recomputes from scratch, never invents rows, and
never overwrites SRS-owned columns (`stage`, `next_review`).
- **The repo owns the plan** — `data/schedule.json`, human-edited,
bundled at deploy. Git history is its audit log. Each date is a
`ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day,
fixed numbers), `gate` (Saturday's blind set, verbatim), or `sweep` (redo
every miss, nothing new). Numbers a `gate` or `problems` entry names are
`RESERVED`: the drill and gate samplers never draw them.
- The GitHub Project mirror writes `Target Date` and nothing else: it is
best-effort, so failures log warnings and never block a D1 write, and topic
rows are never written. SRS stage and first-attempt result stay in D1 only —
the charts, digest and `/api/stats` read them from there, so mirroring them
into Project single-selects bought only drift. Do not re-add them.
`Set`/`Difficulty` are a projection of the issue labels, reconciled by
`bun run sync-project-fields` at the repo root, not by this Worker.
## The ladder
`new → +3 → +7 → retired`; pass advances, fail resets to `+3`. A problem's
first-ever log enters at `+3` regardless of result. Stage names the NEXT
review's interval, and the two rungs are the same numbers as the `work/3` and
`work/7` buckets `bun run pick` scaffolds re-solves into.
**Sunday is never booked.** Topics run Mon–Fri, the gate is Saturday, and 3/7
are chosen so a solve returns on a working day: only a Thursday solve's `+3`
would land on the rest day, and `workingDay()` in `src/srs.ts` slides it to
Monday. Every scheduled date in the Worker — ladder reviews, levelled
overflow, deferred-Hard release dates — is minted by that one function, so the
rest day cannot be booked and then swallowed by the digest's Sunday branch.
Reviews may slip later, never earlier: pulling one back to Saturday would
shorten the interval it exists to test. `src/srs.test.ts` sweeps the whole
campaign calendar to prove it.
All dates are ET calendar strings anchored at noon UTC (`src/srs.ts`) — never
raw `Date` math.
Blind drills draw only from topics **already learned**: scheduled in an
earlier week (the current week's optional pool is reserved for Saturday's
gate) and with at least one core problem on the ladder — a skipped learning
day never feeds drills just because its calendar week lapsed. A Saturday
whose plan fixes the `gate` set uses that list as-is; every number must be a
catalog row (a `set:optional` sub-issue of its topic), or the review issue is
not created — a `/done` on an uncatalogued number could never be logged.
## Routes
| Route | Auth | Purpose |
|---|---|---|
| `GET /log?p&r&d&sig` | HMAC (`LINK_KEY`) | one-tap pass/fail; idempotent per (problem, date, kind); links expire after 3 days |
| `POST /webhook/github` | HMAC (`WEBHOOK_SECRET`) | `/done <n> pass\|fail` comments (owner only, any issue); `review`-issue close → gate scoring |
| `GET /chart/{progress,ladder,heatmap}.svg`, `GET /badge/gate.svg` | public | hand-rolled SVGs, `max-age=300` (GitHub Camo's freshness floor) |
| `GET /api/stats` | public, CORS-pinned to the docs origin | one JSON document for `/progress` |
| `POST /admin/solved` | `Authorization: Bearer <LINK_KEY>` | `{"solved":[{"lc":1,"bucket":1},…]}` — solutions committed under `work/`; the bucket names the rung it settles (1 → `new`, 3 → `+3`, 7 → `+7`), so a first solve enters the ladder and a pushed re-solve advances it. Writes only when the problem stands on that rung, which is what makes re-posting the whole set a no-op. Takes `?dry=1&date=` |
| `POST /admin/due` | `Authorization: Bearer <LINK_KEY>` | read-only: what the ladder has due on `?date=` (default today), oldest first, with the rung/bucket, how late it is, and when it was last seen. The one answer behind the daily `Spaced Repetition` issue |
| `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer <LINK_KEY>` | manual triggers; `digest` takes `?dry=1&force=1&date=` |
## Crons (DST-proof)
Each event has two UTC crons; code fires only when the computed ET hour
matches (`etHour` in `src/srs.ts`, unit-checked in `src/srs.test.ts`):
- `0 12,13 * * *` → 8 AM ET: catalog reconcile, then the digest.
- `0 4,5 * * 6` → midnight ET Saturday: create `Review — Week N` (so the
8 AM digest can link to it).
## Secrets & bindings
`wrangler secret put` — `GH_PAT` (Issues + Projects RW), `WEBHOOK_SECRET`
(matches the repo webhook), `LINK_KEY` (signs one-tap links, gates admin
routes). Bindings in `wrangler.jsonc`: `DB` (D1 `srs`), `EMAIL`
(`send_email`, restricted to the verified destination). Sender domain
`prdlk.com` is onboarded to Email Sending.
Three Actions workflows talk to this Worker with the `SRS_ADMIN_KEY` repo
secret:
- `sync-d1.yml` sends curriculum issue edits to `POST /admin/reconcile` (the
morning cron is the backstop).
- `close-solved.yml` sends the whole implemented `work/` set — each entry
tagged with its bucket — to `POST /admin/solved` on every push to `main`.
That call is what keeps a committed solution from being invisible here:
closing an issue is not a state change the Worker can see (the catalog
reconcile ignores issue state, and only `logAttempt()` moves the ladder), and
the bucket is what lets a pushed re-solve advance `+3 → +7` instead of
being read as a stale first solve. `close-solved` still owns the issue close
itself (it knows the files and the commit), so this path only writes D1 and
mirrors Project fields.
- `spaced-repetition.yml` reads `POST /admin/due` at 06:00 ET and renders
today's `Spaced Repetition` issue. Read-only, and the only schedule involved
is this Worker's ladder.
## Local dev
```sh
cp .dev.vars.example .dev.vars # or fill GH_PAT/WEBHOOK_SECRET/LINK_KEY
bun install
bunx wrangler d1 migrations apply srs --local
bun run dev # wrangler dev on :8787, local D1
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
"localhost:8787/admin/digest?dry=1&date=2026-08-31" # prints HTML, sends nothing
bun test src # DST guards, date math, the rest-day sweep
```
`?date=` on admin routes is the `SRS_TODAY` equivalent. Migrations are
append-only and applied in order; `0002_ladder_3_7.sql` is the +2/+5/+10 →
`+3`/`+7` rebuild, which also lifts every date that was sitting on a Sunday.
The one-shot importer for the retired `.github/srs/srs.json` is gone — that
state no longer exists, and git history is its record.
## Deploy
```sh
bun run deploy # from apps/api/, or `bun run api:deploy` at the repo root
```
CI deploys on every push to `main` (`deploy.yml`, gated on `bun run api:test`,
using the `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_ACCOUNT_ID` repo secrets); the
commands above are for out-of-band deploys. After changing bindings, rerun
`bunx wrangler types`.
+28
View File
@@ -0,0 +1,28 @@
{
"2026-08-17": { "topics": [3] },
"2026-08-18": { "topics": [4] },
"2026-08-24": { "topics": [5] },
"2026-08-25": { "topics": [6] },
"2026-08-26": { "topics": [7] },
"2026-08-27": { "topics": [8] },
"2026-08-28": { "topics": [9] },
"2026-08-31": { "topics": [10] },
"2026-09-01": { "topics": [11] },
"2026-09-02": { "topics": [12] },
"2026-09-03": { "topics": [13] },
"2026-09-04": { "topics": [14] },
"2026-09-07": { "topics": [15] },
"2026-09-08": { "topics": [16] },
"2026-09-21": { "topics": [17, 18] },
"2026-09-22": { "topics": [19, 20] },
"2026-09-23": { "topics": [21] },
"2026-09-24": { "topics": [22] },
"2026-09-25": { "topics": [23] },
"2026-09-26": { "gate": [648, 1091, 802, 547, 90] },
"2026-09-28": { "topics": [24] },
"2026-09-29": { "topics": [25] },
"2026-09-30": { "topics": [26] },
"2026-10-01": { "problems": [399, 261, 909, 77, 52] },
"2026-10-02": { "sweep": true },
"2026-10-03": { "gate": [347, 1254, 1971, 131, 494, 986] }
}
+55
View File
@@ -0,0 +1,55 @@
-- SRS schema. D1 owns SRS state (stage, next_review, attempts, gates,
-- boosts); GitHub issues own the catalog columns, reconciled nightly.
CREATE TABLE problems (
lc_number INTEGER PRIMARY KEY,
issue INTEGER NOT NULL,
topic_issue INTEGER NOT NULL,
title TEXT NOT NULL,
difficulty TEXT NOT NULL, -- easy|medium|hard
set_label TEXT NOT NULL, -- core|optional|deferred
stage TEXT NOT NULL DEFAULT 'new', -- new|+2|+5|+10|retired
next_review TEXT, -- YYYY-MM-DD ET, NULL when retired/unsolved
defer_until TEXT -- deferred Hards: 2026-09-28+
);
CREATE TABLE attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lc_number INTEGER NOT NULL REFERENCES problems(lc_number),
date TEXT NOT NULL,
kind TEXT NOT NULL, -- first|review|drill|gate
result TEXT NOT NULL, -- pass|fail
source TEXT NOT NULL -- email|webhook|commit|import
);
-- One-tap email links must be idempotent (same link twice = no-op), but
-- /done webhook corrections (pass then fail, same day) must stay legal —
-- so uniqueness applies to email-sourced attempts only. Commit-sourced
-- attempts need no index: logAttempt() only accepts them for a stage-'new'
-- problem, so a re-push cannot insert a second one.
CREATE UNIQUE INDEX attempts_email_once
ON attempts (lc_number, date, kind) WHERE source = 'email';
CREATE INDEX attempts_by_date ON attempts (date);
CREATE TABLE topics (
issue INTEGER PRIMARY KEY,
name TEXT NOT NULL,
misses INTEGER NOT NULL DEFAULT 0,
boost INTEGER NOT NULL DEFAULT 0,
milestone INTEGER -- phase; from the topic issue
);
CREATE TABLE gates (
week INTEGER PRIMARY KEY, -- ISO week
issue INTEGER,
problems TEXT NOT NULL, -- JSON array of lc_numbers
pass_rate REAL,
closed_on TEXT
);
CREATE TABLE drill_pool_used (lc_number INTEGER PRIMARY KEY);
-- Digest idempotency: one email per ET date.
CREATE TABLE email_log (
date TEXT PRIMARY KEY, -- YYYY-MM-DD ET
sent_at TEXT NOT NULL
);
+31
View File
@@ -0,0 +1,31 @@
-- Ladder rebuild: +2/+5/+10 → +3/+7, and no scheduled date on a Sunday.
--
-- The old three rungs scattered reviews across every weekday, so roughly one
-- review in seven came due on the rest day: the digest hid it (Sunday builds
-- an empty body) and Monday inherited the pile. The campaign's windows are 3
-- and 7 days — the same numbers as the work/3 and work/7 buckets and the
-- Spaced Repetition issue windows — and srs.ts now mints every date through
-- workingDay(), which never returns a Sunday.
--
-- Stage remap keeps earned progress: a problem at +5 has passed one review, so
-- it owes the 7-day one; a problem at +10 has passed two and is done. Existing
-- next_review dates keep their day (recomputing them would move work the
-- reader has already been told about) — only Sunday landings shift, forward to
-- Monday, because a review may come later than its interval but never sooner.
--
-- problems.stage is now: new | +3 | +7 | retired
UPDATE problems SET stage = '+3' WHERE stage = '+2';
UPDATE problems SET stage = '+7' WHERE stage = '+5';
UPDATE problems SET stage = 'retired', next_review = NULL WHERE stage = '+10';
UPDATE problems
SET next_review = date(next_review, '+1 day')
WHERE next_review IS NOT NULL AND strftime('%w', next_review) = '0';
-- defer_until is the deferred Hards' release date; catalog.ts spreads them two
-- per working day, so the same rule applies. next_review tracks it for a row
-- that is still `new`, and the shift above kept the two in step.
UPDATE problems
SET defer_until = date(defer_until, '+1 day')
WHERE defer_until IS NOT NULL AND strftime('%w', defer_until) = '0';
+21
View File
@@ -0,0 +1,21 @@
{
"name": "srs-api",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler dev --test-scheduled",
"deploy": "wrangler deploy",
"types": "wrangler types",
"test": "bun test src"
},
"devDependencies": {
"@types/react": "^19.2.18",
"wrangler": "^4.125.0"
},
"dependencies": {
"@react-email/components": "^1.0.12",
"@react-email/render": "^2.1.0",
"react": "^19.2.8",
"react-dom": "^19.2.8"
}
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Nightly GitHub → D1 catalog reconcile. GitHub issues own the catalog
* (topics, problems, set/diff labels, milestones); this recomputes desired
* rows from scratch on every run — re-runs are no-ops. SRS-owned columns
* (stage, next_review) are NEVER overwritten; defer_until is set once, on
* first sight of a deferred problem. The Worker never invents catalog rows.
*
* Difficulty and set come from the `diff:*` / `set:*` LABELS, never from the
* issue title — the title carries only the LC number and the problem's name,
* so relabelling a problem is enough to move it and titles stay readable.
* The `problem` label is what marks a sub-issue as curriculum.
*/
import { DIFFICULTIES, SETS, workingDay } from "./srs.ts";
import type { GitHub } from "./github.ts";
const TITLE_RE = /^LC (\d+) · (.+)$/;
/** Deferred Hards enter the queue from this date, two per working day. */
const DEFER_FROM = "2026-09-28";
export interface ReconcileReport {
topics: number;
problems: number;
}
/** Label names on one issue payload, narrowed with guards rather than cast. */
function labelNames(value: object): string[] {
if (!("labels" in value) || !Array.isArray(value.labels)) return [];
const names: string[] = [];
for (const label of value.labels) {
if (label && typeof label === "object" && "name" in label && typeof label.name === "string") {
names.push(label.name);
}
}
return names;
}
export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<ReconcileReport> {
interface TopicIssue {
number: number;
title: string;
milestone: number | null;
}
const topics: TopicIssue[] = [];
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
if (!raw || typeof raw !== "object") continue;
if ("pull_request" in raw) continue;
if (!("number" in raw) || typeof raw.number !== "number") continue;
if (!("title" in raw) || typeof raw.title !== "string") continue;
let milestone: number | null = null;
if (
"milestone" in raw &&
raw.milestone &&
typeof raw.milestone === "object" &&
"number" in raw.milestone &&
typeof raw.milestone.number === "number"
) {
milestone = raw.milestone.number;
}
topics.push({ number: raw.number, title: raw.title, milestone });
}
const statements: D1PreparedStatement[] = [];
for (const t of topics) {
statements.push(
db
.prepare(
`INSERT INTO topics (issue, name, milestone) VALUES (?1, ?2, ?3)
ON CONFLICT(issue) DO UPDATE SET name = ?2, milestone = ?3`,
)
.bind(t.number, t.title.replace(/^Topic \d+ — /, ""), t.milestone),
);
}
let problems = 0;
let deferredSeen = 0;
for (const t of topics) {
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) {
if (!raw || typeof raw !== "object") continue;
if (!("number" in raw) || typeof raw.number !== "number") continue;
if (!("title" in raw) || typeof raw.title !== "string") continue;
const labels = labelNames(raw);
if (!labels.includes("problem")) continue; // non-curriculum sub-issue
const m = raw.title.match(TITLE_RE);
if (!m) {
console.warn(`catalog: #${raw.number} is a problem but not titled "LC <n> · <name>"`);
continue;
}
const difficulty = DIFFICULTIES.find((d) => labels.includes(`diff:${d}`));
const set = SETS.find((s) => labels.includes(`set:${s}`));
if (!difficulty || !set) {
console.warn(`catalog: #${raw.number} is missing a diff:/set: label — skipped`);
continue;
}
const lc = Number(m[1]);
// Two deferred Hards per WORKING day from DEFER_FROM, in catalog walk
// order — applied only when the row is first created (SRS owns it
// afterwards). Sunday is skipped like every other scheduled date.
const defer = set === "deferred" ? workingDay(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
if (set === "deferred") deferredSeen++;
statements.push(
db
.prepare(
`INSERT INTO problems (lc_number, issue, topic_issue, title, difficulty, set_label, defer_until, next_review)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
ON CONFLICT(lc_number) DO UPDATE SET
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
)
.bind(lc, raw.number, t.number, m[2]!, difficulty, set, defer),
);
problems++;
}
}
// D1 batches are transactional; chunk to stay under statement limits.
for (let i = 0; i < statements.length; i += 50) {
await db.batch(statements.slice(i, i + 50));
}
return { topics: topics.length, problems };
}
+408
View File
@@ -0,0 +1,408 @@
/**
* Hand-rolled SVG chart generation for the SRS Worker.
*
* Feeds five endpoints — /chart/progress.svg, /chart/ladder.svg,
* /chart/heatmap.svg, /chart/heatmap.png, /badge/gate.svg — each served with
* `Cache-Control: public, max-age=300`. The heatmap has a raster twin because
* every major email client blocks SVG, so the digest cannot embed the vector.
*
* The SVG is string-built: no chart library, no dependencies, and the one
* import is the hand-rolled PNG encoder next door. The only dynamic values
* entering the markup are numbers, percentages, and dates the Worker itself
* computes, plus the fixed phase/stage labels — so nothing here needs XML
* escaping. Styling is shadcn dark zinc to match the README's shieldcn
* badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode
* green ramp. Every function renders on a zero-row DB.
*/
import { STAGES } from "./srs.ts";
import { Canvas, textWidth } from "./png.ts";
// D1Database comes from the generated worker-configuration.d.ts runtime types.
const FONT = "Verdana,DejaVu Sans,sans-serif";
// shadcn dark-zinc palette, matching the README's shieldcn badges.
const BG = "#09090b"; // zinc-950 card
const FG = "#fafafa"; // zinc-50 text
const MUTED = "#a1a1aa"; // zinc-400 secondary text
const LINE = "#27272a"; // zinc-800 structure / empty
const GREEN = "#16a34a"; // green-600 (core / pass)
const BLUE = "#2563eb"; // blue-600 (optional)
const RED = "#f87171"; // red-400 (fail, on dark)
// Green ramp shared by the heatmap and the ladder (GitHub dark-mode
// contribution hues; level 0 is the empty zinc cell).
const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"];
// Labels are stored SVG-ready: phase II's "&" is pre-escaped since these
// strings go straight into markup and nothing else here needs escaping.
const PHASES = [
{ milestone: 1, name: "I — Linear" },
{ milestone: 2, name: "II — Nodal &amp; Grid" },
{ milestone: 3, name: "III — Hierarchical" },
{ milestone: 4, name: "IV — Relational" },
{ milestone: 5, name: "V — Decision Space" },
];
// ── svg helpers ──────────────────────────────────────────────────
/** Opening tag plus the rounded dark card every chart starts with. */
function svgOpen(width: number, height: number): string {
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
`viewBox="0 0 ${width} ${height}" font-family="${FONT}">` +
`<rect width="${width}" height="${height}" rx="6" fill="${BG}"/>`
);
}
/** A <text> element; charts place dozens of these with the same defaults. */
function text(
x: number,
y: number,
content: string,
attrs: { size?: number; fill?: string; anchor?: string; weight?: string } = {},
): string {
const { size = 12, fill = FG, anchor = "start", weight } = attrs;
const bold = weight ? ` font-weight="${weight}"` : "";
return `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" text-anchor="${anchor}"${bold}>${content}</text>`;
}
// ── progress: stacked bar per phase ──────────────────────────────
interface ProgressRow {
milestone: number;
set_label: string;
done: number;
total: number;
}
export async function progressChart(db: D1Database): Promise<string> {
const { results } = await db
.prepare(
`SELECT t.milestone AS milestone, p.set_label AS set_label,
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done,
COUNT(*) AS total
FROM problems p JOIN topics t ON p.topic_issue = t.issue
WHERE t.milestone IS NOT NULL
GROUP BY t.milestone, p.set_label`,
)
.all<ProgressRow>();
// Per phase: core done / optional done / remaining (all-set total − all done).
const phases = PHASES.map((phase) => {
const rows = results.filter((r) => r.milestone === phase.milestone);
const total = rows.reduce((n, r) => n + r.total, 0);
const done = rows.reduce((n, r) => n + r.done, 0);
return {
name: phase.name,
coreDone: rows.find((r) => r.set_label === "core")?.done ?? 0,
optionalDone: rows.find((r) => r.set_label === "optional")?.done ?? 0,
remaining: total - done,
done,
total,
};
});
const width = 640;
const height = 300;
const barX = 168;
const barMaxW = 400;
const barH = 22;
const rowStep = 48;
const scale = Math.max(1, ...phases.map((p) => p.total));
const parts = [svgOpen(width, height)];
// Legend on top.
const legend: [string, string][] = [
[GREEN, "core done"],
[BLUE, "optional done"],
[LINE, "remaining"],
];
let lx = barX;
for (const [color, label] of legend) {
parts.push(`<rect x="${lx}" y="12" width="12" height="12" rx="2" fill="${color}"/>`);
parts.push(text(lx + 17, 22, label, { size: 11, fill: MUTED }));
lx += 17 + label.length * 7 + 24;
}
phases.forEach((p, i) => {
const y = 52 + i * rowStep;
const midY = y + barH / 2 + 4;
parts.push(text(barX - 12, midY, p.name, { anchor: "end" }));
let x = barX;
const segments: [number, string][] = [
[p.coreDone, GREEN],
[p.optionalDone, BLUE],
[p.remaining, LINE],
];
for (const [count, color] of segments) {
const w = (count / scale) * barMaxW;
if (w > 0) {
parts.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="${barH}" fill="${color}"/>`);
// Count inside the segment when it fits; tiny slivers stay unlabeled.
if (w >= 20) {
const labelFill = color === LINE ? MUTED : FG;
parts.push(text(x + w / 2, midY, String(count), { size: 11, fill: labelFill, anchor: "middle" }));
}
x += w;
}
}
parts.push(text(x + 8, midY, `${p.done}/${p.total}`, { size: 11, fill: MUTED }));
});
parts.push("</svg>");
return parts.join("");
}
// ── ladder: bar per stage ────────────────────────────────────────
// One bar per rung, labelled and ordered by srs.ts's STAGES, so changing the
// ladder can never leave a stale bar here: the slot width is derived from the
// rung count, only the bar width inside a slot is fixed.
export async function ladderChart(db: D1Database): Promise<string> {
const { results } = await db
.prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`)
.all<{ stage: string; count: number }>();
const counts = STAGES.map((s) => results.find((r) => r.stage === s)?.count ?? 0);
const width = 640;
const height = 220;
const plotTop = 26;
const plotBottom = height - 32;
const plotH = plotBottom - plotTop;
const slotW = (width - 80) / STAGES.length;
const barW = 64;
const scale = Math.max(1, ...counts);
const parts = [svgOpen(width, height)];
counts.forEach((count, i) => {
const cx = 40 + slotW * i + slotW / 2;
const barH = (count / scale) * plotH;
const y = plotBottom - barH;
if (barH > 0) {
parts.push(
`<rect x="${(cx - barW / 2).toFixed(1)}" y="${y.toFixed(1)}" width="${barW}" height="${barH.toFixed(1)}" rx="3" fill="${GREENS[i]}"/>`,
);
}
parts.push(text(cx, Math.max(y - 6, 16), String(count), { size: 12, anchor: "middle", weight: "bold" }));
parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: MUTED, anchor: "middle" }));
});
parts.push(`<line x1="40" y1="${plotBottom}" x2="${width - 40}" y2="${plotBottom}" stroke="${LINE}"/>`);
parts.push("</svg>");
return parts.join("");
}
// ── heatmap: attempts per campaign day ───────────────────────────
const DAY_MS = 86_400_000;
// Layout in CSS pixels. heatmapPng multiplies every one of these by its device
// scale, so the vector and raster pictures cannot drift apart.
const HEAT_W = 560;
const HEAT_H = 160;
const HEAT_GRID_X = 34;
const HEAT_GRID_Y = 24;
const HEAT_CELL = 16;
const HEAT_STEP = 19; // cell + 3px gap
// Grid row → left-hand label; both renderers print only these three.
const WEEKDAYS: [number, string][] = [
[0, "Mon"],
[2, "Wed"],
[4, "Fri"],
];
/** One grid square: column, Mon-based row, and index into GREENS. */
interface HeatmapCell {
col: number;
row: number;
level: number;
}
/**
* The heatmap's whole data model — one cell per campaign day, plus the week
* count that positions the legend — shared by both renderers.
*
* Every per-day date derives from one UTC-midnight timestamp, so local-timezone
* drift never shifts a cell. The campaign starts on a Monday, so day i sits at
* column i/7; the row comes from the real weekday, so an off-Monday start still
* lands correctly.
*/
async function heatmapCells(
db: D1Database,
start: string,
days: number,
): Promise<{ cells: HeatmapCell[]; weeks: number }> {
const [sy, sm, sd] = start.split("-").map(Number);
const base = Date.UTC(sy!, sm! - 1, sd!);
const end = new Date(base + (days - 1) * DAY_MS).toISOString().slice(0, 10);
const { results } = await db
.prepare(`SELECT date, COUNT(*) AS attempts FROM attempts WHERE date >= ?1 AND date <= ?2 GROUP BY date`)
.bind(start, end)
.all<{ date: string; attempts: number }>();
const byDate = new Map(results.map((r) => [r.date, r.attempts]));
const cells: HeatmapCell[] = [];
for (let i = 0; i < days; i++) {
const day = new Date(base + i * DAY_MS);
const attempts = byDate.get(day.toISOString().slice(0, 10)) ?? 0;
cells.push({
col: Math.floor(i / 7),
row: (day.getUTCDay() + 6) % 7, // Mon = 0
level: Math.min(attempts, 4),
});
}
return { cells, weeks: Math.ceil(days / 7) };
}
export async function heatmapChart(db: D1Database, start: string, days: number): Promise<string> {
const { cells, weeks } = await heatmapCells(db, start, days);
const parts = [svgOpen(HEAT_W, HEAT_H)];
// Week numbers across the top, Mon/Wed/Fri down the left.
for (let w = 0; w < weeks; w++) {
parts.push(
text(HEAT_GRID_X + w * HEAT_STEP + HEAT_CELL / 2, HEAT_GRID_Y - 7, `W${w + 1}`, {
size: 10,
fill: MUTED,
anchor: "middle",
}),
);
}
for (const [row, label] of WEEKDAYS) {
parts.push(
text(HEAT_GRID_X - 6, HEAT_GRID_Y + row * HEAT_STEP + HEAT_CELL - 4, label, {
size: 10,
fill: MUTED,
anchor: "end",
}),
);
}
for (const { col, row, level } of cells) {
parts.push(
`<rect x="${HEAT_GRID_X + col * HEAT_STEP}" y="${HEAT_GRID_Y + row * HEAT_STEP}" width="${HEAT_CELL}" height="${HEAT_CELL}" rx="2" fill="${GREENS[level]}"/>`,
);
}
// Less → More ramp fills the space right of the grid.
const legendX = HEAT_GRID_X + weeks * HEAT_STEP + 40;
const legendY = HEAT_GRID_Y + 3 * HEAT_STEP;
parts.push(text(legendX - 6, legendY + HEAT_CELL - 4, "Less", { size: 10, fill: MUTED, anchor: "end" }));
GREENS.forEach((color, i) => {
parts.push(
`<rect x="${legendX + i * HEAT_STEP}" y="${legendY}" width="${HEAT_CELL}" height="${HEAT_CELL}" rx="2" fill="${color}"/>`,
);
});
parts.push(text(legendX + GREENS.length * HEAT_STEP + 3, legendY + HEAT_CELL - 4, "More", { size: 10, fill: MUTED }));
parts.push("</svg>");
return parts.join("");
}
// The PNG carries no alpha channel, so whatever the rounded card does not cover
// has to be painted with the colour sitting behind the image: the digest
// email's GitHub-dark card.
const EMAIL_CARD = "#0d1117";
// Device-pixel multiplier. The email hands the image the full 552px card
// width, so 3x lands a little over 2x density on a retina screen; the labels
// stay 10 CSS px, the size of the SVG's. Flat colour compresses to a few KB
// either way, so the extra resolution is close to free.
const HEAT_SCALE = 3;
/**
* The same picture as heatmapChart, rastered for email.
*
* Two deliberate departures from the SVG. The canvas is trimmed to the width
* the content actually occupies instead of HEAT_W: the README's SVG sits in a
* narrow table cell and scales up, whereas the email gives the image the full
* 552px card, and the SVG's slack right margin would otherwise shrink the
* grid to nothing. And the bitmap font hangs off a top-left origin rather than
* a baseline, so each SVG baseline becomes "baseline minus one cap height".
*/
export async function heatmapPng(db: D1Database, start: string, days: number): Promise<Uint8Array> {
const { cells, weeks } = await heatmapCells(db, start, days);
const s = HEAT_SCALE;
const gridX = HEAT_GRID_X * s;
const gridY = HEAT_GRID_Y * s;
const cell = HEAT_CELL * s;
const step = HEAT_STEP * s;
const capH = 7 * s;
const legendX = gridX + weeks * step + 40 * s;
const legendY = gridY + 3 * step;
const moreX = legendX + GREENS.length * step + 3 * s;
const width = moreX + textWidth("More", s) + gridX;
const height = HEAT_H * s;
const canvas = new Canvas(width, height, EMAIL_CARD);
canvas.rect(0, 0, width, height, BG, 6 * s);
for (let w = 0; w < weeks; w++) {
const label = `W${w + 1}`;
const middle = gridX + w * step + cell / 2;
canvas.text(middle - textWidth(label, s) / 2, gridY - 7 * s - capH, label, MUTED, s);
}
for (const [row, label] of WEEKDAYS) {
canvas.text(
gridX - 6 * s - textWidth(label, s),
gridY + row * step + cell - 4 * s - capH,
label,
MUTED,
s,
);
}
for (const { col, row, level } of cells) {
canvas.rect(gridX + col * step, gridY + row * step, cell, cell, GREENS[level]!, 2 * s);
}
const legendBase = legendY + cell - 4 * s - capH;
canvas.text(legendX - 6 * s - textWidth("Less", s), legendBase, "Less", MUTED, s);
GREENS.forEach((color, i) => {
canvas.rect(legendX + i * step, legendY, cell, cell, color, 2 * s);
});
canvas.text(moreX, legendBase, "More", MUTED, s);
return await canvas.encode();
}
// ── gate badge: shieldcn-style dark pill ─────────────────────────
export async function gateBadge(db: D1Database): Promise<string> {
const row = await db
.prepare(`SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1`)
.first<{ pass_rate: number }>();
const label = "gate";
const value = row ? `${Math.round(row.pass_rate * 100)}%` : "none yet";
const valueFill = row ? (row.pass_rate >= 0.7 ? "#4ade80" : RED) : MUTED;
// shieldcn geometry: height 32, rx 6, one flat zinc-900 pill. Verdana 13px
// ≈ 7.5px per char; 12px outer padding, 8px between label and value.
const labelW = Math.round(label.length * 7.5);
const valueW = Math.round(value.length * 7.5);
const total = 12 + labelW + 8 + valueW + 12;
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="32" ` +
`viewBox="0 0 ${total} 32" role="img" aria-label="${label}: ${value}" font-family="${FONT}">` +
`<rect width="${total}" height="32" rx="6" fill="#18181b"/>` +
`<g font-size="13">` +
`<text x="12" y="21" fill="${FG}" fill-opacity=".7">${label}</text>` +
`<text x="${12 + labelW + 8}" y="21" fill="${valueFill}" font-weight="bold">${value}</text>` +
`</g>` +
`</svg>`
);
}
+241
View File
@@ -0,0 +1,241 @@
/**
* The daily digest email — built from D1 + the bundled schedule, sent at
* 8 AM ET via the send_email binding. This module owns data only: it turns
* D1 rows into a `DigestData` and hands it to email.tsx, which renders both
* the HTML and the plain-text alternative from that single tree (React Email
* `render`), so the two can never drift.
*
* Retrieval rules: review, drill and sweep rows carry number + difficulty
* only — never the topic, never a solution link. `DigestRow` has no field for
* either, so the rule holds by construction. The plan's own sections — a
* learning day's topics (core, then the optional "if time" list) and a set
* day's problems — are the only labeled, linked ones. At most REVIEW_CAP (3)
* reviews per day; drills fill toward DAILY_CAP (6) only when the due queue
* is light, and never on a sweep day, which assigns every open miss and
* nothing new. Overflow is levelled, not stacked: sendDigest gives every
* review past the cap a concrete future date, ≤ REVIEW_CAP per day, oldest
* first. Sunday is a two-line rest note.
*
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
* no-op unless forced; the body itself is deterministic (drill picks are
* seeded by the date). Levelling keeps that: it runs once, before the send,
* and a re-run finds nothing left over the cap.
*/
import {
CAMPAIGN_DAYS,
type ProblemRow,
RESERVED,
REVIEW_CAP,
SCHEDULE,
type ScheduledDay,
addDays,
campaignDay,
campaignWeek,
dueReviews,
isoWeek,
levelReviews,
lookupProblems,
pickDrills,
prettyDate,
streak,
weekdayOf,
} from "./srs.ts";
import { type DigestData, type DigestRow, type TopicRow, renderDigest } from "./email.tsx";
import { signLink } from "./links.ts";
/** Total daily ceiling: drills only fill what the whole due queue leaves. */
const DAILY_CAP = 6;
export interface Digest {
subject: string;
html: string;
text: string;
}
/** A problem row plus its signed one-tap pass/fail URLs. */
async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): Promise<DigestRow> {
const [pass, fail] = await Promise.all([
signLink(env.LINK_KEY, p.lc_number, "pass", date),
signLink(env.LINK_KEY, p.lc_number, "fail", date),
]);
const base = `${env.PUBLIC_URL}/log?p=${p.lc_number}&d=${date}&r=`;
return {
lc: p.lc_number,
difficulty: p.difficulty,
...(staged ? { stage: p.stage } : {}),
passUrl: `${base}pass&sig=${pass}`,
failUrl: `${base}fail&sig=${fail}`,
};
}
/** A plan-named problem: linked to its issue, ticked once it has entered the ladder. */
function topicRow(env: Env, p: ProblemRow): TopicRow {
return {
lc: p.lc_number,
difficulty: p.difficulty,
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
solved: p.stage !== "new",
};
}
/** Problems whose most recent attempt failed — the miss log a sweep day works through. */
async function openMisses(db: D1Database): Promise<ProblemRow[]> {
const { results } = await db
.prepare(
`SELECT p.* FROM problems p
JOIN attempts a ON a.lc_number = p.lc_number
WHERE a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number)
AND a.result = 'fail' AND p.stage != 'retired'
ORDER BY a.date, p.lc_number`,
)
.all<ProblemRow>();
return results;
}
/** Everything the email needs, read straight out of D1 and the schedule. */
export async function collectDigest(env: Env, date: string): Promise<DigestData> {
const db = env.DB;
const rest = weekdayOf(date) === 0;
const plan: ScheduledDay = rest ? {} : (SCHEDULE[date] ?? {});
// Reviews surface at most REVIEW_CAP; drills fill toward DAILY_CAP only
// when the WHOLE due queue is light — a heavy backlog must never trade
// review slots for brand-new drill problems — and never on a sweep day.
const due = rest ? [] : await dueReviews(db, date);
const capped = due.slice(0, REVIEW_CAP);
const drills =
rest || plan.sweep ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
const data: DigestData = {
day: prettyDate(date),
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
streak: await streak(db, date),
rest,
topics: [],
reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))),
carried: due.length - capped.length,
drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))),
yesterday: { total: 0, failed: [] },
heatmapUrl: `${env.PUBLIC_URL}/chart/heatmap.png`,
progressUrl: `${env.DOCS_URL}/progress`,
};
// New topics — the plan's sections, the only ones allowed to name problems
// and link them. Core first; the optional set is "if time", minus anything
// the plan has reserved for a gate or a set day.
for (const topicIssue of plan.topics ?? []) {
const topic = await db
.prepare("SELECT name FROM topics WHERE issue = ?")
.bind(topicIssue)
.first<{ name: string }>();
const { results } = await db
.prepare(
"SELECT * FROM problems WHERE topic_issue = ? AND set_label IN ('core', 'optional') ORDER BY lc_number",
)
.bind(topicIssue)
.all<ProblemRow>();
data.topics.push({
name: topic?.name ?? `#${topicIssue}`,
core: results.filter((p) => p.set_label === "core").map((p) => topicRow(env, p)),
ifTime: results
.filter((p) => p.set_label === "optional" && !RESERVED.has(p.lc_number))
.map((p) => topicRow(env, p)),
});
}
// A set day assigns problems by number rather than by topic.
if (plan.problems) {
const { rows, missing } = await lookupProblems(db, plan.problems);
if (missing.length) console.warn(`digest ${date}: planned problems not in the catalog: ${missing.join(", ")}`);
data.assigned = rows.map((p) => topicRow(env, p));
}
// A sweep day works the miss log: every problem still standing on a
// failure, minus the ones today's review list already carries.
if (plan.sweep) {
const reviewed = new Set(capped.map((p) => p.lc_number));
const misses = (await openMisses(db)).filter((p) => !reviewed.has(p.lc_number));
data.sweep = await Promise.all(misses.map((p) => tapRow(env, p, date, true)));
}
// Saturday: the review issue already exists (created at midnight ET).
if (weekdayOf(date) === 6) {
const gate = await db
.prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL")
.bind(isoWeek(date))
.first<{ issue: number }>();
if (gate) {
data.gate = {
week: campaignWeek(date),
url: `https://github.com/${env.REPO}/issues/${gate.issue}`,
};
}
}
// Footer: yesterday's log summarised (failures named — they are the
// actionable part) and the gate rate to date.
const { results: logged } = await db
.prepare("SELECT lc_number, result FROM attempts WHERE date = ? ORDER BY id")
.bind(addDays(date, -1))
.all<{ lc_number: number; result: string }>();
data.yesterday = {
total: logged.length,
failed: logged.filter((a) => a.result !== "pass").map((a) => a.lc_number),
};
const lastGate = await db
.prepare("SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1")
.first<{ pass_rate: number }>();
if (lastGate) data.gateRate = lastGate.pass_rate;
return data;
}
export async function buildDigest(env: Env, date: string): Promise<Digest> {
const data = await collectDigest(env, date);
const { html, text } = await renderDigest(data);
return {
subject: `(Day ${campaignDay(date)}/${CAMPAIGN_DAYS}) LeetCode Daily Digest`,
html,
text,
};
}
export interface SendReport {
sent: boolean;
reason: string;
digest: Digest;
}
/** Send today's digest exactly once per ET date (unless forced). */
export async function sendDigest(
env: Env,
date: string,
opts: { force?: boolean; dry?: boolean } = {},
): Promise<SendReport> {
const digest = await buildDigest(env, date);
if (opts.dry) return { sent: false, reason: "dry run", digest };
const already = await env.DB.prepare("SELECT sent_at FROM email_log WHERE date = ?")
.bind(date)
.first();
if (already && !opts.force) return { sent: false, reason: "already sent today", digest };
// The digest above was built against the un-levelled queue (so `carried`
// reports the real backlog); now give everything past the cap its future
// date. Dry runs never reach here, and a forced re-send finds nothing left.
await levelReviews(env.DB, date);
await env.EMAIL.send({
to: env.TO_EMAIL,
from: { email: env.FROM_EMAIL, name: "SRS" },
subject: digest.subject,
html: digest.html,
text: digest.text,
});
await env.DB.prepare(
"INSERT INTO email_log (date, sent_at) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET sent_at = excluded.sent_at",
)
.bind(date, new Date().toISOString())
.run();
return { sent: true, reason: already ? "forced re-send" : "sent", digest };
}
+563
View File
@@ -0,0 +1,563 @@
/**
* Presentation layer for the daily digest — React Email components rendered
* to HTML (and to the plain-text alternative) inside the Worker.
*
* This file owns *only* layout and wording. Every number, URL and signature
* is computed in digest.ts and handed over as `DigestData`, so the retrieval
* rules (review/drill rows carry number + difficulty only — never the topic,
* never a solution link) are enforced by what the data model can express:
* `DigestRow` has no title and no issue field.
*
* Dark by design. The theme is hard-coded rather than left to the client's
* dark-mode heuristics: `color-scheme: dark` tells Apple Mail and Outlook not
* to re-invert it, and the canvas colour is painted by a full-width <Section>
* table because Gmail drops styles on <body>.
*
* Other email constraints that shape the markup: inline styles only (clients
* strip <style>), real <table> layout for the problem lists so Outlook and
* Gmail agree on column alignment, React Email's <Button> for the one-tap
* links (it emits the MSO padding conditionals a bare <a> lacks), and the
* heatmap arrives as PNG — every major client refuses remote SVG.
*/
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
} from "@react-email/components";
import { render } from "@react-email/render";
import type { CSSProperties } from "react";
import { REVIEW_CAP } from "./srs.ts";
// ── palette (GitHub dark) ────────────────────────────────────────
const CANVAS = "#010409";
const CARD = "#0d1117";
const BORDER = "#30363d";
const RULE = "#21262d";
const INK = "#e6edf3";
const MUTED = "#8b949e";
const LINK = "#58a6ff";
const GREEN = "#3fb950";
const AMBER = "#d29922";
const RED = "#f85149";
const PASS_BG = "#238636";
const FAIL_BG = "#da3633";
const GATE_BG = "#1f6feb";
const FONT = '-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif';
// Outlook's Word engine is unreliable with text-transform, so display labels
// are table lookups rather than a CSS trick.
const DIFFICULTY: Record<string, { label: string; color: string }> = {
easy: { label: "Easy", color: GREEN },
medium: { label: "Medium", color: AMBER },
hard: { label: "Hard", color: RED },
};
// The SRS ladder in plain English — stage "+3" means the last clean look was
// three days back, so that is what the reader is told.
const LAST_SEEN: Record<string, string> = {
new: "first look",
"+3": "3 days ago",
"+7": "7 days ago",
retired: "retired",
};
// ── data model ───────────────────────────────────────────────────
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
export interface DigestRow {
lc: number;
difficulty: string;
/** Reviews only — drills are unstaged by design. */
stage?: string;
passUrl: string;
failUrl: string;
}
/** A plan-named problem — the only rows allowed a link. */
export interface TopicRow {
lc: number;
difficulty: string;
url: string;
solved: boolean;
}
/** One learning-day topic: the core list, then the optional "if time" list. */
export interface TopicSection {
name: string;
core: TopicRow[];
ifTime: TopicRow[];
}
export interface DigestData {
/** "Tuesday, August 25" */
day: string;
/** "Day 9 of 56 · Week 2" */
progress: string;
streak: number;
rest: boolean;
/** Topics taught today, in plan order — empty on every other day. */
topics: TopicSection[];
/** A set day's problems, assigned by number rather than by topic. */
assigned?: TopicRow[];
reviews: DigestRow[];
/** Reviews past the daily cap; levelled onto the coming days. */
carried: number;
drills: DigestRow[];
/** A sweep day's miss log: present (even empty) only on a sweep day. */
sweep?: DigestRow[];
gate?: { week: number; url: string };
yesterday: { total: number; failed: number[] };
gateRate?: number;
/** PNG, not SVG — email clients refuse the latter. */
heatmapUrl: string;
progressUrl: string;
}
// ── styles ───────────────────────────────────────────────────────
const page: CSSProperties = { backgroundColor: CANVAS, padding: "28px 0" };
const card: CSSProperties = {
backgroundColor: CARD,
border: `1px solid ${BORDER}`,
borderRadius: "10px",
margin: "0 auto",
maxWidth: "600px",
padding: "28px 24px",
};
const h1: CSSProperties = { color: INK, fontSize: "20px", fontWeight: 600, lineHeight: "26px", margin: 0 };
const label: CSSProperties = {
color: MUTED,
fontSize: "11px",
fontWeight: 700,
letterSpacing: "0.8px",
margin: "0 0 10px",
textTransform: "uppercase",
};
const hint: CSSProperties = { color: MUTED, fontSize: "13px", lineHeight: "19px", margin: "0 0 12px" };
const th: CSSProperties = {
borderBottom: `1px solid ${BORDER}`,
color: MUTED,
fontSize: "12px",
fontWeight: 400,
padding: "0 0 8px",
textAlign: "left",
};
const td: CSSProperties = {
borderBottom: `1px solid ${RULE}`,
color: INK,
fontSize: "14px",
padding: "12px 0",
textAlign: "left",
};
const tap: CSSProperties = {
borderRadius: "6px",
color: "#ffffff",
display: "inline-block",
fontSize: "12px",
fontWeight: 600,
lineHeight: "12px",
padding: "9px 15px",
textDecoration: "none",
};
/**
* A problem list as a real table. The "last seen" column appears only for
* reviews, which keeps drills at three columns on a phone.
*/
function ProblemTable({ rows, staged }: { rows: DigestRow[]; staged: boolean }) {
return (
<table
width="100%"
border={0}
cellPadding={0}
cellSpacing={0}
style={{ borderCollapse: "collapse", width: "100%" }}
>
<thead>
<tr>
<th style={th}>Problem</th>
<th style={th}>Level</th>
{staged ? <th style={th}>Last seen</th> : null}
<th style={{ ...th, textAlign: "right" }}>How did it go?</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const difficulty = DIFFICULTY[row.difficulty];
return (
<tr key={row.lc}>
<td style={{ ...td, fontWeight: 600 }}>LC {row.lc}</td>
<td style={{ ...td, color: difficulty?.color ?? INK }}>{difficulty?.label ?? row.difficulty}</td>
{staged ? (
<td style={{ ...td, color: MUTED }}>{(row.stage && LAST_SEEN[row.stage]) ?? row.stage}</td>
) : null}
<td style={{ ...td, textAlign: "right", whiteSpace: "nowrap" }}>
<Button href={row.passUrl} style={{ ...tap, backgroundColor: PASS_BG }}>
got it
</Button>
<Button href={row.failUrl} style={{ ...tap, backgroundColor: FAIL_BG, marginLeft: "6px" }}>
missed it
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
/**
* The learning day's core list as the same table shape as reviews — this is
* the one section allowed to link problems, and ticks replace tap buttons.
*/
function TopicTable({ rows }: { rows: TopicRow[] }) {
return (
<table
width="100%"
border={0}
cellPadding={0}
cellSpacing={0}
style={{ borderCollapse: "collapse", width: "100%" }}
>
<thead>
<tr>
<th style={th}>Problem</th>
<th style={th}>Level</th>
<th style={{ ...th, textAlign: "right" }}>Solved</th>
</tr>
</thead>
<tbody>
{rows.map((p) => {
const difficulty = DIFFICULTY[p.difficulty];
return (
<tr key={p.lc}>
<td style={{ ...td, fontWeight: 600 }}>
<Link href={p.url} style={{ color: p.solved ? MUTED : LINK, textDecoration: "none" }}>
LC {p.lc}
</Link>
</td>
<td style={{ ...td, color: difficulty?.color ?? INK }}>{difficulty?.label ?? p.difficulty}</td>
<td style={{ ...td, color: p.solved ? GREEN : MUTED, textAlign: "right" }}>
{p.solved ? "✓" : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
);
}
function Block({
title,
note,
rows,
staged,
empty,
}: {
title: string;
note: string;
rows: DigestRow[];
staged: boolean;
empty: string;
}) {
return (
<Section style={{ marginTop: "28px" }}>
<Text style={label}>{title}</Text>
{rows.length === 0 ? (
<Text style={{ ...hint, margin: 0 }}>{empty}</Text>
) : (
<>
<Text style={hint}>{note}</Text>
<ProblemTable rows={rows} staged={staged} />
</>
)}
</Section>
);
}
// ── email ────────────────────────────────────────────────────────
function DigestEmail({ data }: { data: DigestData }) {
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
const lead = data.topics[0]
? `, starting with ${data.topics[0].name}`
: data.assigned
? ", starting with today's set"
: data.sweep
? " — sweep day, nothing new"
: "";
const preview = data.rest ? "Rest day — nothing to do but rest." : `${load} to work through today${lead}.`;
const passed = data.yesterday.total - data.yesterday.failed.length;
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
return (
<Html lang="en" dir="ltr">
<Head>
<meta name="color-scheme" content="dark" />
<meta name="supported-color-schemes" content="dark" />
</Head>
<Preview>{preview}</Preview>
<Body style={{ backgroundColor: CANVAS, fontFamily: FONT, margin: 0, padding: 0 }}>
<Section style={page}>
<Container style={card}>
<Heading as="h1" style={h1}>
{data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`}
</Heading>
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
{data.progress}
{data.rest
? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
: data.streak === 0
? " · no streak going yet — today is a good day to start one."
: ` · 🔥 ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`}
</Text>
{data.rest ? null : (
<>
{data.topics.map((topic, i) => (
<Section key={topic.name} style={{ marginTop: i === 0 ? "28px" : "20px" }}>
{i === 0 ? (
<Text style={label}>
{data.topics.length === 1 ? "Something new today" : "Two new things today"}
</Text>
) : null}
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
{topic.name}
</Text>
<Text style={hint}>Work through these from the top — a tick means it is already solved.</Text>
<TopicTable rows={topic.core} />
{topic.ifTime.length > 0 ? (
<>
<Text style={{ ...hint, margin: "12px 0 8px" }}>
If time — only once the core is done inside the clock.
</Text>
<TopicTable rows={topic.ifTime} />
</>
) : null}
</Section>
))}
{data.assigned ? (
<Section style={{ marginTop: "28px" }}>
<Text style={label}>Today's set</Text>
<Text style={hint}>
Picked by number, not by topic — all of them, in order, inside the clock.
</Text>
<TopicTable rows={data.assigned} />
</Section>
) : null}
{data.sweep ? (
<Block
title="Sweep — redo every red"
note="Every problem still standing on a miss. Nothing new today; clear these first."
rows={data.sweep}
staged
empty="The miss log is clear — nothing is standing on a fail."
/>
) : null}
<Block
title="Time to see these again"
note="Solve each one from scratch, and resist opening your old answer first."
rows={data.reviews}
staged
empty="Nothing is due for review today."
/>
{data.carried > 0 ? (
<Text style={{ ...hint, margin: "12px 0 0" }}>
{data.carried} more {data.carried === 1 ? "is" : "are"} waiting — spread over the
coming days, oldest first, never more than {REVIEW_CAP} a day.
</Text>
) : null}
<Block
title="Cold start, no hints"
note="You are not told the topic. Say the pattern out loud before you write a line."
rows={data.drills}
staged={false}
empty="No cold starts today."
/>
{data.gate ? (
<Section style={{ marginTop: "28px" }}>
<Text style={label}>Weekly checkpoint</Text>
<Text style={hint}>Timed and blind. Close the issue when you are done.</Text>
<Button
href={data.gate.url}
style={{ ...tap, backgroundColor: GATE_BG, fontSize: "13px", padding: "11px 18px" }}
>
Open week {data.gate.week} review
</Button>
</Section>
) : null}
</>
)}
{/* Eight weeks at a glance — the one thing here that is pure
encouragement rather than instruction. */}
<Section style={{ marginTop: "28px" }}>
<Text style={label}>Every day you showed up</Text>
<Link href={data.progressUrl}>
<Img
src={data.heatmapUrl}
alt="Attempt heatmap across the eight-week campaign"
width="552"
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
/>
</Link>
</Section>
<Hr style={{ borderColor: BORDER, margin: "28px 0 18px" }} />
<Text style={{ color: MUTED, fontSize: "12px", lineHeight: "19px", margin: 0 }}>
{data.yesterday.total === 0
? "You did not log anything yesterday."
: `Yesterday you logged ${data.yesterday.total} and got ${passed} of them` +
(missed ? `; ${missed} got away.` : ".")}{" "}
{data.gateRate === undefined
? "No checkpoints scored yet."
: `Checkpoints are running at ${Math.round(data.gateRate * 100)}%.`}{" "}
<Link href={data.progressUrl} style={{ color: LINK, textDecoration: "none" }}>
See the full picture →
</Link>
</Text>
</Container>
</Section>
</Body>
</Html>
);
}
/**
* The plain-text alternative. React Email's `plainText` mode flattens the
* problem tables into one unreadable run, so text gets its own writer — fed
* by the same `DigestData`, so the numbers and links can never disagree with
* the HTML even though the wording lives in two places.
*/
function plainDigest(data: DigestData): string {
const out: string[] = [
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
data.progress +
(data.rest
? " · the ladder never books a Sunday, so nothing is due — anything still open waits for Monday."
: data.streak === 0
? " · no streak going yet — today is a good day to start one."
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
];
const linked = (p: TopicRow) =>
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`;
if (!data.rest) {
for (const topic of data.topics) {
out.push(
"",
`SOMETHING NEW TODAY — ${topic.name}`,
"Work through these from the top; the ones marked done you have already solved.",
...topic.core.map(linked),
);
if (topic.ifTime.length > 0) {
out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked));
}
}
if (data.assigned) {
out.push(
"",
"TODAY'S SET",
"Picked by number, not by topic — all of them, in order, inside the clock.",
...data.assigned.map(linked),
);
}
const lists: [string, string, DigestRow[], string][] = [
[
"TIME TO SEE THESE AGAIN",
"Solve each one from scratch, and resist opening your old answer first.",
data.reviews,
"Nothing is due for review today.",
],
[
"COLD START, NO HINTS",
"You are not told the topic. Say the pattern out loud before you write a line.",
data.drills,
"No cold starts today.",
],
];
if (data.sweep) {
lists.unshift([
"SWEEP — REDO EVERY RED",
"Every problem still standing on a miss. Nothing new today; clear these first.",
data.sweep,
"The miss log is clear — nothing is standing on a fail.",
]);
}
for (const [title, note, rows, empty] of lists) {
out.push("", title);
if (rows.length === 0) {
out.push(empty);
continue;
}
out.push(note);
for (const row of rows) {
const seen = row.stage ? `, last seen ${LAST_SEEN[row.stage] ?? row.stage}` : "";
out.push(
` LC ${row.lc} — ${DIFFICULTY[row.difficulty]?.label ?? row.difficulty}${seen}`,
` got it: ${row.passUrl}`,
` missed it: ${row.failUrl}`,
);
}
}
if (data.carried > 0) {
out.push(
"",
`${data.carried} more ${data.carried === 1 ? "is" : "are"} waiting — spread over the coming days, oldest first, never more than ${REVIEW_CAP} a day.`,
);
}
if (data.gate) {
out.push(
"",
"WEEKLY CHECKPOINT",
"Timed and blind. Close the issue when you are done.",
` Week ${data.gate.week} review — ${data.gate.url}`,
);
}
}
const passed = data.yesterday.total - data.yesterday.failed.length;
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
out.push(
"",
"─".repeat(48),
data.yesterday.total === 0
? "You did not log anything yesterday."
: `Yesterday you logged ${data.yesterday.total} and got ${passed} of them` +
(missed ? `; ${missed} got away.` : "."),
data.gateRate === undefined
? "No checkpoints scored yet."
: `Checkpoints are running at ${Math.round(data.gateRate * 100)}%.`,
`Every day you showed up: ${data.heatmapUrl}`,
`See the full picture: ${data.progressUrl}`,
);
return out.join("\n");
}
/**
* The HTML body plus its plain-text alternative — always sent as a pair so a
* client that refuses HTML still gets the same problems and the same links.
*/
export async function renderDigest(data: DigestData): Promise<{ html: string; text: string }> {
return { html: await render(<DigestEmail data={data} />), text: plainDigest(data) };
}
+288
View File
@@ -0,0 +1,288 @@
/**
* Saturday review issue: created at midnight ET so the 8 AM digest can link
* to it; scored when the issue closes (webhook).
*
* The gate half is a topic-blind quiz. When the plan fixes the set
* (`gate` in data/schedule.json) that list IS the gate, verbatim — every
* number must be a catalog problem, or the issue is not created, because a
* `/done` on an uncatalogued number can never be logged. Otherwise the set
* is sampled: one unsolved optional problem per topic scheduled this week
* (which is why daily drills only draw from earlier weeks), never one the
* plan has reserved. The recap half is generated data, deliberately NOT a
* task list: the week's solved problems are already scheduled by the +3/+7
* ladder, and re-assigning them on Saturday would be massed practice.
*
* Label is `review` (the retired Actions system owned `gate`). Creation is
* idempotent by week; deferred Hards never appear in a sampled set.
*/
import {
type ProblemRow,
RESERVED,
type Result,
SCHEDULE,
campaignWeek,
isoWeek,
lookupProblems,
rng,
sample,
streak,
weekMonday,
} from "./srs.ts";
import type { GitHub } from "./github.ts";
const PASS_TARGET = 0.7;
const REVIEW_LABEL = "review";
const MAX_GATE_PROBLEMS = 5;
function capitalized(difficulty: string): string {
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
}
/** Unsolved problems of a topic in a set — never attempted, drilled, or reserved. */
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
const { results } = await db
.prepare(
`SELECT * FROM problems
WHERE topic_issue = ?1 AND set_label = ?2 AND stage = 'new' AND defer_until IS NULL
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = problems.lc_number)
ORDER BY lc_number`,
)
.bind(topic, set)
.all<ProblemRow>();
return results.filter((p) => !RESERVED.has(p.lc_number));
}
export interface CreateReport {
created: boolean;
issue?: number;
reason: string;
}
export async function createReviewIssue(env: Env, gh: GitHub, date: string): Promise<CreateReport> {
const db = env.DB;
const week = campaignWeek(date);
const iso = isoWeek(date);
const title = `Review — Week ${week}`;
const existing = await db
.prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL")
.bind(iso)
.first<{ issue: number }>();
if (existing) {
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
}
// The plan's fixed set wins; otherwise sample this week's topics, and in a
// week without topics, across every topic that still has an unsolved pool.
const picks: { p: ProblemRow; fallback: boolean }[] = [];
const planned = SCHEDULE[date]?.gate;
if (planned) {
const { rows, missing } = await lookupProblems(db, planned);
if (missing.length) {
return {
created: false,
reason: `planned gate names problems outside the catalog: LC ${missing.join(", LC ")}`,
};
}
picks.push(...rows.map((p) => ({ p, fallback: false })));
} else {
let topics = Object.entries(SCHEDULE)
.filter(([d]) => campaignWeek(d) === week)
.sort(([a], [b]) => (a < b ? -1 : 1))
.flatMap(([, day]) => day.topics ?? []);
if (topics.length === 0) {
const { results } = await db
.prepare(
`SELECT DISTINCT topic_issue AS t FROM problems
WHERE set_label = 'optional' AND stage = 'new'
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
ORDER BY topic_issue`,
)
.all<{ t: number }>();
topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort(
(a, b) => a - b,
);
}
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
const random = rng(`gate-${iso}`);
for (const topic of topics) {
const optional = await freshPool(db, topic, "optional");
if (optional.length > 0) {
picks.push({ p: sample(optional, 1, random)[0]!, fallback: false });
continue;
}
const core = await freshPool(db, topic, "core");
if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true });
// Both pools exhausted: the topic is fully solved; nothing to quiz.
}
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
}
// Recap: everything attempted this week, plus streak — data, not tasks.
const monday = weekMonday(date);
const { results: attempts } = await db
.prepare(
`SELECT a.lc_number, a.date, a.kind, a.result FROM attempts a
WHERE a.date >= ? AND a.date < ? AND a.source != 'import'
ORDER BY a.date, a.id`,
)
.bind(monday, date)
.all<{ lc_number: number; date: string; kind: string; result: string }>();
const reviewsDone = attempts.filter((a) => a.kind === "review" && a.result === "pass").length;
const currentStreak = await streak(db, date);
const gateLines = picks.map(
({ p, fallback }) =>
`- LC ${p.lc_number} — ${capitalized(p.difficulty)}${fallback ? " *(core fallback — optional pool exhausted)*" : ""}`,
);
const recapLines = attempts.length
? attempts.map(
(a) => `| LC ${a.lc_number} | ${a.kind} | ${a.result === "pass" ? "✅" : "❌"} | ${a.date} |`,
)
: ["| — | no attempts logged this week | | |"];
const body = [
"## Gate — blind set",
`**Target: ${Math.round(PASS_TARGET * 100)}% first-attempt pass rate. Timed. Narrate out loud.**`,
"No topics given. Log with `/done <n> pass|fail`, then close this issue.",
...gateLines,
"",
`## Week ${week} recap`,
`${attempts.length} attempts · ${reviewsDone} reviews passed · streak ${currentStreak}`,
"",
"| Problem | Kind | Result | Day |",
"| --- | --- | --- | --- |",
...recapLines,
].join("\n");
// Ensure the `review` label exists (422 = already there).
try {
await gh.rest(`/repos/${gh.repo}/labels`, {
method: "POST",
body: JSON.stringify({
name: REVIEW_LABEL,
color: "5319e7",
description: "Weekly blind gate + recap",
}),
});
} catch (err) {
if (!String(err).includes("422")) throw err;
}
const milestone = await db
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
.bind(picks[0]!.p.topic_issue)
.first<{ milestone: number }>();
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
method: "POST",
body: JSON.stringify({
title,
body,
labels: [REVIEW_LABEL],
milestone: milestone?.milestone,
}),
})) as { number: number };
await db
.prepare(
`INSERT INTO gates (week, issue, problems) VALUES (?1, ?2, ?3)
ON CONFLICT(week) DO UPDATE SET issue = ?2, problems = ?3`,
)
.bind(iso, created.number, JSON.stringify(picks.map(({ p }) => p.lc_number)))
.run();
// Last week's boosted topics had their remedial week — clear the flags.
await db.prepare("UPDATE topics SET boost = 0 WHERE boost = 1").run();
return { created: true, issue: created.number, reason: `created #${created.number}` };
}
export interface ScoreReport {
scored: boolean;
rate?: number;
reason: string;
}
/**
* Grade a closed review issue: first-attempt pass rate over its gate set.
* Unlogged problems count as failures — skipping a gate problem is not a
* pass. Boosts topics that failed a gate problem or reached 2 drill misses;
* miss counters reset once consumed. The badge/charts read D1 live, so
* "refreshing the badge" is this row update.
*/
export async function scoreReview(
env: Env,
gh: GitHub,
issueNumber: number,
date: string,
): Promise<ScoreReport> {
const db = env.DB;
const gate = await db
.prepare("SELECT week, problems, pass_rate FROM gates WHERE issue = ?")
.bind(issueNumber)
.first<{ week: number; problems: string; pass_rate: number | null }>();
if (!gate) return { scored: false, reason: `issue #${issueNumber} has no gate record` };
if (gate.pass_rate !== null) return { scored: false, rate: gate.pass_rate, reason: "already scored" };
const lcs: number[] = JSON.parse(gate.problems);
const results: Record<number, Result | undefined> = {};
for (const lc of lcs) {
const row = await db
.prepare("SELECT result FROM attempts WHERE lc_number = ? AND kind = 'gate' ORDER BY id LIMIT 1")
.bind(lc)
.first<{ result: Result }>();
results[lc] = row?.result;
}
const passes = lcs.filter((lc) => results[lc] === "pass");
const unlogged = lcs.filter((lc) => results[lc] === undefined);
const rate = passes.length / lcs.length;
await db
.prepare("UPDATE gates SET pass_rate = ?, closed_on = ? WHERE issue = ?")
.bind(rate, date, issueNumber)
.run();
const boosted: number[] = [];
for (const lc of lcs) {
if (results[lc] !== "fail") continue;
const p = await db
.prepare("SELECT topic_issue FROM problems WHERE lc_number = ?")
.bind(lc)
.first<{ topic_issue: number }>();
if (p) boosted.push(p.topic_issue);
}
const { results: missed } = await db
.prepare("SELECT issue FROM topics WHERE misses >= 2")
.all<{ issue: number }>();
boosted.push(...missed.map((m) => m.issue));
const boostSet = [...new Set(boosted)];
if (boostSet.length) {
await db.batch(
boostSet.map((t) => db.prepare("UPDATE topics SET boost = 1, misses = 0 WHERE issue = ?").bind(t)),
);
}
const passed = rate >= PASS_TARGET;
const summary = [
`## Gate — Week ${gate.week}: **${Math.round(rate * 100)}%** first-attempt (target ${Math.round(PASS_TARGET * 100)}%) — ${passed ? "✅ pass" : "❌ fail"}`,
"",
...lcs.map((lc) => {
const r = results[lc];
return `- LC ${lc} — ${r === "pass" ? "✅ pass" : r === "fail" ? "❌ fail" : "⬜ not logged (counted as fail)"}`;
}),
"",
unlogged.length ? `${unlogged.length} problem(s) were never logged.` : "",
boostSet.length
? `Boosted topics for next week's drills: ${boostSet.map((t) => `#${t}`).join(", ")}.`
: "No topics boosted.",
"",
`Live charts: ${env.PUBLIC_URL}/chart/progress.svg · progress: ${env.DOCS_URL}/progress`,
]
.filter((l) => l !== "")
.join("\n");
await gh.comment(issueNumber, summary);
return { scored: true, rate, reason: `scored ${Math.round(rate * 100)}%` };
}
+94
View File
@@ -0,0 +1,94 @@
/**
* GitHub REST + GraphQL client on GH_PAT. Hand-rolled fetch, no SDK —
* matching the repo's dependency-free automation rule.
*/
export interface GitHub {
repo: string;
rest(path: string, init?: RequestInit): Promise<unknown>;
graphql(query: string, variables?: Record<string, unknown>): Promise<unknown>;
list(path: string): AsyncGenerator<unknown, void, void>;
/** Comment first, then close — a failed close still leaves a visible note. */
closeIssue(issue: number, comment: string): Promise<void>;
comment(issue: number, body: string): Promise<void>;
react(commentId: number, content: string): Promise<void>;
}
const PER_PAGE = 100;
export function github(token: string, repo: string): GitHub {
async function rest(path: string, init: RequestInit = {}): Promise<unknown> {
const res = await fetch(`https://api.github.com${path}`, {
...init,
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"user-agent": "srs-api",
"x-github-api-version": "2022-11-28",
...(init.body ? { "content-type": "application/json" } : {}),
},
});
if (!res.ok) {
throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`);
}
return res.status === 204 ? null : res.json();
}
async function graphql(
query: string,
variables: Record<string, unknown> = {},
): Promise<unknown> {
const res = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
"user-agent": "srs-api",
},
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; "));
return payload.data;
}
async function* list(path: string): AsyncGenerator<unknown, void, void> {
const sep = path.includes("?") ? "&" : "?";
for (let page = 1; ; page++) {
const batch = await rest(`${path}${sep}per_page=${PER_PAGE}&page=${page}`);
if (!Array.isArray(batch)) throw new Error(`unexpected ${path} payload: not an array`);
yield* batch;
if (batch.length < PER_PAGE) return;
}
}
return {
repo,
rest,
graphql,
list,
async closeIssue(issue, comment) {
await rest(`/repos/${repo}/issues/${issue}/comments`, {
method: "POST",
body: JSON.stringify({ body: comment }),
});
await rest(`/repos/${repo}/issues/${issue}`, {
method: "PATCH",
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
});
},
async comment(issue, body) {
await rest(`/repos/${repo}/issues/${issue}/comments`, {
method: "POST",
body: JSON.stringify({ body }),
});
},
async react(commentId, content) {
await rest(`/repos/${repo}/issues/comments/${commentId}/reactions`, {
method: "POST",
body: JSON.stringify({ content }),
});
},
};
}
+394
View File
@@ -0,0 +1,394 @@
/**
* SRS Worker entry: fetch routes + DST-proof cron dispatch.
*
* Direction of truth: D1 owns SRS state; GitHub issues own the catalog;
* the bundled schedule.json owns the calendar. Every handler writes D1
* first and runs GitHub/Project side effects afterwards via ctx.waitUntil —
* a mirror failure logs a warning and never loses a state write.
*
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
* admin routes); everything else is read-only public.
*/
import { reconcileCatalog } from "./catalog.ts";
import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts";
import { sendDigest } from "./digest.ts";
import { createReviewIssue, scoreReview } from "./gate.ts";
import { type GitHub, github } from "./github.ts";
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
import { type Mirror, projectMirror } from "./mirror.ts";
import {
CAMPAIGN_START,
type LogOutcome,
type Stage,
daysBetween,
dueToday,
etDate,
etHour,
getProblem,
logAttempt,
weekdayOf,
} from "./srs.ts";
import { buildStats } from "./stats.ts";
// ── shared side effects after a state write ──────────────────────
/**
* Mirror one logged attempt into GitHub: the Project's Target Date always; on a
* first-ever pass also close the problem's sub-issue (comment first — repo
* convention). Runs inside ctx.waitUntil; failures are warnings.
*/
async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Promise<void> {
if (outcome.error || outcome.duplicate) return;
const gh = github(env.GH_PAT, env.REPO);
const mirror = projectMirror(gh, env.REPO.split("/")[0]!);
await mirror.setTargetDate(outcome.issue, outcome.next_review);
if (outcome.first && outcome.result === "pass") {
try {
await gh.closeIssue(
outcome.issue,
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +3. Logged by the SRS Worker.`,
);
} catch (err) {
console.warn(`close #${outcome.issue}: ${err}`);
}
}
}
function outcomeLine(o: LogOutcome): string {
if (o.error) return o.error;
if (o.duplicate) return `LC ${o.lc}: already logged today — nothing changed`;
return (
`LC ${o.lc}: ${o.kind} ${o.result} → stage ${o.stage}` +
(o.next_review ? `, review ${o.next_review}` : " — retired 🎉")
);
}
// ── one-tap email links ──────────────────────────────────────────
function page(title: string, body: string, status = 200): Response {
return new Response(
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">
<body style="font-family:-apple-system,Segoe UI,sans-serif;max-width:420px;margin:15vh auto;padding:0 16px;text-align:center">
<h2>${title}</h2><p style="color:#57606a">${body}</p></body>`,
{ status, headers: { "content-type": "text/html; charset=utf-8" } },
);
}
async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const lc = Number(url.searchParams.get("p"));
const result = url.searchParams.get("r");
const date = url.searchParams.get("d") ?? "";
const sig = url.searchParams.get("sig") ?? "";
if (!Number.isFinite(lc) || (result !== "pass" && result !== "fail") || !date) {
return page("Bad link", "Missing or malformed parameters.", 400);
}
if (!(await verifyLink(env.LINK_KEY, lc, result, date, sig))) {
return page("Bad signature", "This link was not signed by the SRS.", 403);
}
const today = etDate(new Date());
if (daysBetween(date, today) > 3 || daysBetween(today, date) > 1) {
return page("Link expired", "Older than 3 days — log it with a /done comment instead.", 410);
}
const outcome = await logAttempt(env.DB, { lc, date, result, source: "email" });
if (outcome.error) return page("Not logged", outcome.error, 422);
if (outcome.duplicate) {
return page("Already logged ✓", `LC ${lc} was already recorded today. Nothing changed.`);
}
ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
return page(
result === "pass" ? "Logged ✅" : "Logged — back to +3",
outcomeLine(outcome),
);
}
// ── GitHub webhook ───────────────────────────────────────────────
interface CommentEvent {
action: string;
issue: { number: number; title: string; labels: { name: string }[] };
comment: { id: number; body: string; user: { login: string } };
}
async function handleWebhook(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const raw = await request.arrayBuffer();
const ok = await verifyWebhook(env.WEBHOOK_SECRET, raw, request.headers.get("x-hub-signature-256"));
if (!ok) return new Response("bad signature", { status: 401 });
const event = request.headers.get("x-github-event");
const payload: unknown = JSON.parse(new TextDecoder().decode(raw));
const owner = env.REPO.split("/")[0]!;
const today = etDate(new Date());
if (event === "issues") {
if (!payload || typeof payload !== "object" || !("action" in payload) || !("issue" in payload)) {
return new Response("ignored");
}
const p = payload as { action: string; issue: { number: number; labels: { name: string }[] } };
if (p.action === "closed" && p.issue.labels.some((l) => l.name === "review")) {
const gh = github(env.GH_PAT, env.REPO);
const report = await scoreReview(env, gh, p.issue.number, today);
return Response.json(report);
}
return new Response("ignored");
}
if (event !== "issue_comment") return new Response("ignored");
const p = payload as CommentEvent;
if (p.action !== "created") return new Response("ignored");
if (p.comment.user.login !== owner) return new Response("ignored (owner only)");
const commands: { lc: number; result: "pass" | "fail" }[] = [];
const errors: string[] = [];
for (const rawLine of p.comment.body.split("\n")) {
const line = rawLine.trim();
if (!line.startsWith("/done")) continue;
const m = line.match(/^\/done\s+(\d+)\s+(pass|fail)\s*$/);
if (m) commands.push({ lc: Number(m[1]), result: m[2] as "pass" | "fail" });
else errors.push(`cannot parse \`${line}\` — expected \`/done <number> <pass|fail>\``);
}
if (commands.length === 0 && errors.length === 0) return new Response("no commands");
// Comments on a review issue log its gate problems as kind='gate'.
const gateRow = await env.DB.prepare("SELECT problems FROM gates WHERE issue = ?")
.bind(p.issue.number)
.first<{ problems: string }>();
const gateLcs = new Set<number>(gateRow ? JSON.parse(gateRow.problems) : []);
const outcomes: LogOutcome[] = [];
for (const cmd of commands) {
const outcome = await logAttempt(env.DB, {
lc: cmd.lc,
date: today,
result: cmd.result,
source: "webhook",
gate: gateLcs.has(cmd.lc),
});
if (outcome.error) errors.push(outcome.error);
else outcomes.push(outcome);
}
const gh = github(env.GH_PAT, env.REPO);
ctx.waitUntil(
(async () => {
for (const outcome of outcomes) await mirrorOutcome(env, outcome, "webhook");
try {
if (errors.length === 0) {
await gh.react(p.comment.id, "+1");
} else {
await gh.comment(
p.issue.number,
`Could not log everything:\n\n${errors.map((e) => `- ${e}`).join("\n")}` +
(outcomes.length
? `\n\nApplied anyway:\n\n${outcomes.map((o) => `- ${outcomeLine(o)}`).join("\n")}`
: ""),
);
}
} catch (err) {
console.warn(`webhook feedback: ${err}`);
}
})(),
);
return Response.json({ applied: outcomes.map(outcomeLine), errors });
}
// ── admin (bearer LINK_KEY, timing-safe) ─────────────────────────
async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
const header = request.headers.get("authorization") ?? "";
if (!header.startsWith("Bearer ")) return false;
return timingSafeEqual(header.slice(7), env.LINK_KEY);
}
/**
* Solutions landing in `work/` are the third way a problem gets solved, after
* the digest tap and a /done comment. `close-solved` posts its whole
* implemented set here on every push to main, each entry tagged with the
* BUCKET the file sits in — 1 for the first solve, 3 and 7 for the blind
* re-solves the picker scaffolds. The bucket names the ladder rung the file
* settles, and logAttempt writes only when the problem is standing on that
* rung, so this is a total recompute like the reconcilers that call it: a
* re-run changes nothing, and a pushed re-solve advances +3 → +7 instead of
* being invisible.
*
* Closing the sub-issue stays with close-solved: it is the side that knows
* which files at which commit, so this path mirrors Project fields only.
* Charts read D1 live, so a push moves them within the 5-minute cache.
*/
const RUNG_OF_BUCKET: Record<number, Stage> = { 1: "new", 3: "+3", 7: "+7" };
async function handleSolved(request: Request, env: Env, date: string): Promise<Response> {
const body: unknown = await request.json().catch(() => null);
const raw: unknown = body && typeof body === "object" && "solved" in body ? body.solved : null;
const shape = 'expected {"solved": [{"lc": <number>, "bucket": 1|3|7}, ...]}';
if (!Array.isArray(raw)) return new Response(shape, { status: 400 });
const entries: { lc: number; rung: Stage; bucket: number }[] = [];
for (const item of raw) {
if (!item || typeof item !== "object") return new Response(shape, { status: 400 });
const lc: unknown = "lc" in item ? item.lc : null;
const bucket: unknown = "bucket" in item ? item.bucket : null;
if (typeof lc !== "number" || !Number.isFinite(lc)) {
return new Response(`not an LC number: ${JSON.stringify(lc)}`, { status: 400 });
}
const rung = typeof bucket === "number" ? RUNG_OF_BUCKET[bucket] : undefined;
if (rung === undefined) return new Response(`not a work/ bucket: ${JSON.stringify(bucket)}`, { status: 400 });
entries.push({ lc, rung, bucket });
}
const dry = new URL(request.url).searchParams.get("dry") === "1";
const logged: string[] = [];
const skipped: string[] = [];
let mirror: Mirror | undefined;
for (const { lc, rung, bucket } of entries) {
if (dry) {
const p = await getProblem(env.DB, lc);
if (!p) skipped.push(`LC ${lc} is not in the curriculum`);
else if (p.stage !== rung) skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${p.stage}`);
else if (rung === "new") logged.push(`LC ${lc}: would enter the ladder at +3`);
else logged.push(`LC ${lc}: would pass ${rung} and move on`);
continue;
}
const outcome = await logAttempt(env.DB, { lc, date, result: "pass", source: "commit", rung });
if (outcome.error) skipped.push(outcome.error);
else if (outcome.duplicate) {
skipped.push(`LC ${lc}: work/${bucket} settles ${rung}, stage is ${outcome.stage}`);
} else {
logged.push(outcomeLine(outcome));
// One mirror for the batch: field IDs resolve once, not per problem.
mirror ??= projectMirror(github(env.GH_PAT, env.REPO), env.REPO.split("/")[0]!);
await mirror.setTargetDate(outcome.issue, outcome.next_review);
}
}
return Response.json({ date, logged, skipped, warnings: mirror?.warnings ?? [] });
}
async function handleAdmin(request: Request, env: Env, path: string): Promise<Response> {
if (!(await adminAuthorized(request, env))) return new Response("unauthorized", { status: 401 });
const url = new URL(request.url);
const date = url.searchParams.get("date") ?? etDate(new Date());
const gh = () => github(env.GH_PAT, env.REPO);
if (path === "/admin/reconcile") {
return Response.json(await reconcileCatalog(env.DB, gh()));
}
if (path === "/admin/solved") return await handleSolved(request, env, date);
if (path === "/admin/digest") {
const report = await sendDigest(env, date, {
dry: url.searchParams.get("dry") === "1",
force: url.searchParams.get("force") === "1",
});
if (url.searchParams.get("dry") === "1") {
return new Response(report.digest.html, { headers: { "content-type": "text/html" } });
}
return Response.json({ sent: report.sent, reason: report.reason, subject: report.digest.subject });
}
if (path === "/admin/review") {
return Response.json(await createReviewIssue(env, gh(), date));
}
// What is due on `date`, straight out of the ladder — the ONE answer to
// "which problems are open today", so the daily review issue and the digest
// cannot disagree. Read-only; the caller decides what to render.
if (path === "/admin/due") {
return Response.json(await dueToday(env.DB, date));
}
return new Response("not found", { status: 404 });
}
// ── router ───────────────────────────────────────────────────────
const SVG_HEADERS = {
"content-type": "image/svg+xml",
// GitHub Camo honors this; 5 minutes is the freshness floor for README charts.
"cache-control": "public, max-age=300",
};
// The digest embeds the raster heatmap; email clients refuse SVG entirely.
const PNG_HEADERS = {
"content-type": "image/png",
"cache-control": "public, max-age=300",
};
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
try {
if (path === "/log" && request.method === "GET") return await handleTap(request, env, ctx);
if (path === "/webhook/github" && request.method === "POST") {
return await handleWebhook(request, env, ctx);
}
if (path.startsWith("/admin/") && request.method === "POST") {
return await handleAdmin(request, env, path);
}
if (path === "/chart/progress.svg") {
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
}
if (path === "/chart/ladder.svg") {
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
}
if (path === "/chart/heatmap.svg") {
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, 56), { headers: SVG_HEADERS });
}
if (path === "/chart/heatmap.png") {
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, 56), { headers: PNG_HEADERS });
}
if (path === "/badge/gate.svg") {
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
}
if (path === "/api/stats") {
// The docs serve from both the Pages origin and the custom domain;
// reflect the requesting origin only when it is on the allowlist.
const allowed = env.DOCS_ORIGIN.split(",").map((o) => o.trim());
const origin = request.headers.get("origin");
const cors = {
"access-control-allow-origin":
origin && allowed.includes(origin) ? origin : allowed[0]!,
"access-control-allow-methods": "GET",
vary: "Origin",
};
if (request.method === "OPTIONS") return new Response(null, { headers: cors });
const stats = await buildStats(env.DB, etDate(new Date()));
return Response.json(stats, { headers: cors });
}
return new Response("srs-api", { status: path === "/" ? 200 : 404 });
} catch (err) {
console.error(`unhandled ${request.method} ${path}: ${err}`);
return new Response("internal error", { status: 500 });
}
},
/**
* DST-proof cron dispatch: crons fire at both possible UTC hours; the
* computed ET hour decides. 8 AM daily → catalog reconcile + digest;
* midnight Saturday → review issue (so the 8 AM digest can link to it).
*/
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
const now = new Date(controller.scheduledTime);
const hour = etHour(now);
const date = etDate(now);
if (hour === 8) {
try {
const report = await reconcileCatalog(env.DB, github(env.GH_PAT, env.REPO));
console.log(`catalog: ${report.topics} topics, ${report.problems} problems`);
} catch (err) {
console.warn(`catalog reconcile failed (digest still goes out): ${err}`);
}
const report = await sendDigest(env, date);
console.log(`digest ${date}: ${report.reason}`);
return;
}
if (hour === 0 && weekdayOf(date) === 6) {
const report = await createReviewIssue(env, github(env.GH_PAT, env.REPO), date);
console.log(`review issue ${date}: ${report.reason}`);
return;
}
console.log(`cron at ET hour ${hour} on ${date}: no-op (DST guard)`);
void ctx;
},
};
+79
View File
@@ -0,0 +1,79 @@
/**
* One-tap email links: `GET /log?p=704&r=pass&d=2026-08-31&sig=<hmac>`.
* The sig is HMAC-SHA256 over `p|r|d` with LINK_KEY (WebCrypto, hex).
* Verification is constant-time; links older than 3 days are rejected.
*/
async function hmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
}
export async function signLink(
key: string,
p: number,
r: string,
d: string,
): Promise<string> {
const mac = await crypto.subtle.sign(
"HMAC",
await hmacKey(key),
new TextEncoder().encode(`${p}|${r}|${d}`),
);
return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
export async function verifyLink(
key: string,
p: number,
r: string,
d: string,
sig: string,
): Promise<boolean> {
if (!/^[0-9a-f]{64}$/.test(sig)) return false;
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) bytes[i] = Number.parseInt(sig.slice(i * 2, i * 2 + 2), 16);
// crypto.subtle.verify is constant-time; never compare hex strings directly.
return crypto.subtle.verify(
"HMAC",
await hmacKey(key),
bytes,
new TextEncoder().encode(`${p}|${r}|${d}`),
);
}
/** Constant-time equality for webhook signatures and admin bearer keys. */
export async function timingSafeEqual(a: string, b: string): Promise<boolean> {
// HMAC both sides with a random key: unequal-length inputs and content
// differences are equally invisible to timing.
const key = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, false, [
"sign",
]);
const enc = new TextEncoder();
const [ma, mb] = await Promise.all([
crypto.subtle.sign("HMAC", key, enc.encode(a)),
crypto.subtle.sign("HMAC", key, enc.encode(b)),
]);
const va = new Uint8Array(ma);
const vb = new Uint8Array(mb);
let diff = 0;
for (let i = 0; i < va.length; i++) diff |= va[i]! ^ vb[i]!;
return diff === 0;
}
/** GitHub webhook `X-Hub-Signature-256: sha256=<hex>` verification. */
export async function verifyWebhook(
secret: string,
body: ArrayBuffer,
header: string | null,
): Promise<boolean> {
if (!header?.startsWith("sha256=")) return false;
const mac = await crypto.subtle.sign("HMAC", await hmacKey(secret), body);
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
return timingSafeEqual(header.slice(7), expected);
}
+143
View File
@@ -0,0 +1,143 @@
/**
* Best-effort mirror into the "Interview Prep" user Project — ported from
* scripts/srs-project.ts. D1 is the truth; a GraphQL failure here becomes a
* console warning and never blocks a state write. Only problem issues are
* ever passed in, so topic rows' Target Date is never written.
*
* Target Date is the ONLY mirrored column. SRS stage and first-attempt result
* live exclusively in D1 — they are read from there (charts, digest, /api/stats)
* and duplicating them into Project single-selects bought nothing but drift and
* two extra writes per log. Do not re-add them here.
*
* Field IDs are resolved by NAME per invocation (a Worker isolate is
* short-lived; the extra query per mirror burst is irrelevant at this
* volume and survives field re-creation).
*/
import type { GitHub } from "./github.ts";
const PROJECT_TITLE = "Interview Prep";
interface ProjectInfo {
id: string;
targetDate: string;
}
export interface Mirror {
setTargetDate(issue: number, date: string | null): Promise<void>;
warnings: string[];
}
export function projectMirror(gh: GitHub, owner: string): Mirror {
const warnings: string[] = [];
let info: ProjectInfo | undefined;
const itemIds = new Map<number, string | undefined>();
async function resolve(): Promise<ProjectInfo> {
if (info) return info;
const data = (await gh.graphql(
`query($owner: String!, $title: String!) {
user(login: $owner) {
projectsV2(first: 10, query: $title) {
nodes {
id title
fields(first: 30) {
nodes {
... on ProjectV2FieldCommon { id name dataType }
}
}
}
}
}
}`,
{ owner, title: PROJECT_TITLE },
)) as {
user: {
projectsV2: {
nodes: {
id: string;
title: string;
fields: {
nodes: { id: string; name: string }[];
};
}[];
};
};
};
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
const date = node.fields.nodes.find((n) => n.name === "Target Date");
if (!date) throw new Error(`date field "Target Date" missing`);
info = { id: node.id, targetDate: date.id };
return info;
}
async function itemId(issue: number): Promise<string> {
if (itemIds.has(issue)) {
const cached = itemIds.get(issue);
if (!cached) throw new Error(`issue #${issue} is not in the project`);
return cached;
}
const project = await resolve();
const [repoOwner, repoName] = gh.repo.split("/");
const data = (await gh.graphql(
`query($owner: String!, $name: String!, $issue: Int!) {
repository(owner: $owner, name: $name) {
issue(number: $issue) {
projectItems(first: 10, includeArchived: true) {
nodes { id project { id } }
}
}
}
}`,
{ owner: repoOwner, name: repoName, issue },
)) as {
repository: { issue: { projectItems: { nodes: { id: string; project: { id: string } }[] } } };
};
const item = data.repository.issue.projectItems.nodes.find((n) => n.project.id === project.id);
itemIds.set(issue, item?.id);
if (!item) throw new Error(`issue #${issue} is not in the project`);
return item.id;
}
async function setField(issue: number, fieldId: string, value: object): Promise<void> {
const project = await resolve();
await gh.graphql(
`mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
updateProjectV2ItemFieldValue(
input: { projectId: $project, itemId: $item, fieldId: $field, value: $value }
) { projectV2Item { id } }
}`,
{ project: project.id, item: await itemId(issue), field: fieldId, value },
);
}
async function attempt(what: string, op: () => Promise<void>): Promise<void> {
try {
await op();
} catch (err) {
const message = `mirror ${what}: ${err instanceof Error ? err.message : String(err)}`;
warnings.push(message);
console.warn(message); // observability picks this up; never rethrow
}
}
return {
warnings,
setTargetDate: (issue, date) =>
attempt(`Target Date #${issue}`, async () => {
const project = await resolve();
if (date === null) {
await gh.graphql(
`mutation($project: ID!, $item: ID!, $field: ID!) {
clearProjectV2ItemFieldValue(
input: { projectId: $project, itemId: $item, fieldId: $field }
) { projectV2Item { id } }
}`,
{ project: project.id, item: await itemId(issue), field: project.targetDate },
);
} else {
await setField(issue, project.targetDate, { date });
}
}),
};
}
+254
View File
@@ -0,0 +1,254 @@
/**
* Hand-rolled PNG encoder for the SRS Worker.
*
* Every major email client — Gmail, Outlook, Apple Mail — refuses to render
* SVG, inline or remote, so the daily digest cannot reuse /chart/heatmap.svg;
* it needs raster bytes. The Worker ships with no runtime dependencies by
* design (SVG, HMAC and GraphQL are all hand-rolled here), so this file
* encodes PNG itself instead of pulling in a codec.
*
* The format reduces to four things the runtime already provides:
* - an 8-byte signature plus chunks (IHDR, IDAT, IEND), each one
* length + type + payload + CRC32, every integer big-endian;
* - one filter byte per scanline, always 0 here: these images are flat
* rectangles, so a predictor would buy nothing;
* - zlib-wrapped deflate for IDAT, which is exactly what
* `new CompressionStream("deflate")` emits ("deflate-raw" would not);
* - CRC32, a 256-entry table built lazily on the first chunk.
*
* Colour type 2 (truecolour, 8-bit, no alpha): these charts are opaque cards,
* so an alpha channel would cost a third more bytes for nothing. That does mean
* transparency is unavailable — a caller wanting rounded corners paints the
* colour that sits behind the image first, then the rounded card on top.
*/
// ── crc32 ────────────────────────────────────────────────────────
// One table for every chunk of every image; built on first use.
let crcTable: Uint32Array | undefined;
function crc32(bytes: Uint8Array): number {
let table = crcTable;
if (!table) {
table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c;
}
crcTable = table;
}
let crc = 0xffffffff;
for (const byte of bytes) crc = table[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
// ── chunks ───────────────────────────────────────────────────────
const SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/** length, 4-char type, payload, then CRC32 over the type and payload. */
function chunk(type: string, data: Uint8Array): Uint8Array {
const out = new Uint8Array(data.length + 12);
const view = new DataView(out.buffer);
view.setUint32(0, data.length);
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
out.set(data, 8);
view.setUint32(data.length + 8, crc32(out.subarray(4, data.length + 8)));
return out;
}
// ── 5x7 bitmap font ──────────────────────────────────────────────
const GLYPH_W = 5;
const GLYPH_H = 7;
/**
* Seven row bitmasks per glyph, five low bits each, MSB (0b10000) leftmost.
* Uppercase only: chart labels are short and mechanical, and a lowercase set
* would double the table for no gain. Unlisted characters render blank.
*/
const GLYPHS: Record<string, number[]> = {
A: [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
B: [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
C: [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
D: [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110],
E: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
F: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
G: [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110],
H: [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
I: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111],
J: [0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100],
K: [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
L: [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
M: [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001],
N: [0b10001, 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001],
O: [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
P: [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
Q: [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10011, 0b01101],
R: [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
S: [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110],
T: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
U: [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
V: [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100],
W: [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
X: [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001],
Y: [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100],
Z: [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111],
"0": [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
"1": [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
"2": [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
"3": [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
"4": [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
"5": [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
"6": [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
"7": [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
"8": [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
"9": [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
" ": [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000],
"-": [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000],
"/": [0b00001, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b10000],
":": [0b00000, 0b00100, 0b00100, 0b00000, 0b00100, 0b00100, 0b00000],
};
/**
* Device pixels `content` occupies at `scale`, inter-glyph gaps included.
* Exported because callers need it to size a canvas before constructing one.
*/
export function textWidth(content: string, scale = 1): number {
return content.length === 0 ? 0 : (content.length * (GLYPH_W + 1) - 1) * scale;
}
// ── canvas ───────────────────────────────────────────────────────
/** "#rrggbb" to its three channel bytes; called once per draw, not per pixel. */
function rgb(color: string): [number, number, number] {
const packed = Number.parseInt(color.slice(1), 16);
return [(packed >> 16) & 0xff, (packed >> 8) & 0xff, packed & 0xff];
}
/** Fixed-size RGB canvas: filled rectangles plus 5x7 bitmap text. */
export class Canvas {
readonly width: number;
readonly height: number;
/** Row-major RGB triples, unpadded; encode() interleaves the filter bytes. */
private readonly pixels: Uint8Array;
constructor(width: number, height: number, background: string) {
this.width = width;
this.height = height;
this.pixels = new Uint8Array(width * height * 3);
const [r, g, b] = rgb(background);
for (let i = 0; i < this.pixels.length; i += 3) {
this.pixels[i] = r;
this.pixels[i + 1] = g;
this.pixels[i + 2] = b;
}
}
/**
* Filled rectangle, clipped to the canvas. `radius` rounds all four corners
* by dropping the pixels whose centre falls outside the corner circle —
* enough for the small cell roundings and the card these charts draw.
*/
rect(x: number, y: number, w: number, h: number, color: string, radius = 0): void {
const [r, g, b] = rgb(color);
// Device pixels only: a fractional origin would index the buffer between
// bytes and silently drop the whole rectangle.
const left = Math.round(x);
const top = Math.round(y);
const rw = Math.round(w);
const rh = Math.round(h);
const rad = Math.min(radius, rw / 2, rh / 2);
const y1 = Math.min(this.height, top + rh);
const x1 = Math.min(this.width, left + rw);
for (let py = Math.max(0, top); py < y1; py++) {
// How far this row lies past the nearer corner centre; 0 in between.
const cy = py + 0.5;
const dy = cy < top + rad ? top + rad - cy : cy > top + rh - rad ? cy - (top + rh - rad) : 0;
const rowBase = py * this.width * 3;
for (let px = Math.max(0, left); px < x1; px++) {
const cx = px + 0.5;
const dx = cx < left + rad ? left + rad - cx : cx > left + rw - rad ? cx - (left + rw - rad) : 0;
if (dx * dx + dy * dy > rad * rad) continue;
const at = rowBase + px * 3;
this.pixels[at] = r;
this.pixels[at + 1] = g;
this.pixels[at + 2] = b;
}
}
}
/**
* Bitmap text from a top-left origin (not a baseline), `scale` device pixels
* per glyph pixel, 1 glyph pixel of advance between characters. Input is
* uppercased; unknown characters advance without drawing.
*/
text(x: number, y: number, content: string, color: string, scale = 1): void {
const [r, g, b] = rgb(color);
const left = Math.round(x);
const top = Math.round(y);
const upper = content.toUpperCase();
for (let i = 0; i < upper.length; i++) {
const glyph = GLYPHS[upper[i]!];
if (!glyph) continue;
const originX = left + i * (GLYPH_W + 1) * scale;
for (let gy = 0; gy < GLYPH_H; gy++) {
const bits = glyph[gy]!;
if (bits === 0) continue;
for (let gx = 0; gx < GLYPH_W; gx++) {
if ((bits & (1 << (GLYPH_W - 1 - gx))) === 0) continue;
// Every set glyph pixel is a scale x scale block of device pixels.
const blockX = originX + gx * scale;
const blockY = top + gy * scale;
const y1 = Math.min(this.height, blockY + scale);
const x1 = Math.min(this.width, blockX + scale);
for (let py = Math.max(0, blockY); py < y1; py++) {
const rowBase = py * this.width * 3;
for (let px = Math.max(0, blockX); px < x1; px++) {
const at = rowBase + px * 3;
this.pixels[at] = r;
this.pixels[at + 1] = g;
this.pixels[at + 2] = b;
}
}
}
}
}
}
/** PNG bytes: 8-bit truecolour, one IDAT, zlib via CompressionStream. */
async encode(): Promise<Uint8Array> {
const stride = this.width * 3;
const raw = new Uint8Array((stride + 1) * this.height);
for (let y = 0; y < this.height; y++) {
// Leading 0 of each scanline slot is the "no filter" byte.
raw.set(this.pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
}
const deflated = new Response(raw).body!.pipeThrough(new CompressionStream("deflate"));
const idat = new Uint8Array(await new Response(deflated).arrayBuffer());
const ihdr = new Uint8Array(13);
const header = new DataView(ihdr.buffer);
header.setUint32(0, this.width);
header.setUint32(4, this.height);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // colour type 2: truecolour RGB
// Bytes 10-12 stay 0: deflate, adaptive filtering, no interlace.
const chunks = [chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0))];
let total = SIGNATURE.length;
for (const c of chunks) total += c.length;
const png = new Uint8Array(total);
png.set(SIGNATURE);
let at = SIGNATURE.length;
for (const c of chunks) {
png.set(c, at);
at += c.length;
}
return png;
}
}
+203
View File
@@ -0,0 +1,203 @@
/**
* Acceptance proof for the two rules that cannot be eyeballed in production:
* the double crons fire exactly once at 8 AM ET (digest) and midnight Saturday
* ET (review issue) on BOTH UTC offsets, and NOTHING is ever scheduled on the
* Sunday rest day — which is the whole reason the ladder's windows are 3 and 7.
*/
import { describe, expect, test } from "bun:test";
import {
INTERVAL,
RESERVED,
SCHEDULE,
STAGES,
WINDOWS,
addDays,
campaignDay,
campaignWeek,
etDate,
etHour,
isRestDay,
isoWeek,
rng,
sample,
TEMPERATURES,
temperatureOf,
topicDate,
weekdayOf,
workingDay,
} from "./srs.ts";
/** Every ET date of the campaign, plus the deferred-Hard tail. */
const CALENDAR = Array.from({ length: 70 }, (_, i) => addDays("2026-08-17", i));
describe("DST-proof cron guard", () => {
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
// Cron pair 12:00 / 13:00 UTC during EDT (2026-08-29 is EDT).
expect(etHour(new Date("2026-08-29T12:00:00Z"))).toBe(8); // fires
expect(etHour(new Date("2026-08-29T13:00:00Z"))).toBe(9); // no-op
});
test("digest fires only at ET hour 8 — EST (UTC-5)", () => {
// DST ends 2026-11-01; 2026-11-05 is EST.
expect(etHour(new Date("2026-11-05T12:00:00Z"))).toBe(7); // no-op
expect(etHour(new Date("2026-11-05T13:00:00Z"))).toBe(8); // fires
});
test("saturday review fires only at ET midnight — both offsets", () => {
// EDT Saturday: 4 UTC = 0 ET fires, 5 UTC = 1 ET no-op.
expect(etHour(new Date("2026-08-29T04:00:00Z"))).toBe(0);
expect(weekdayOf(etDate(new Date("2026-08-29T04:00:00Z")))).toBe(6);
expect(etHour(new Date("2026-08-29T05:00:00Z"))).toBe(1);
// EST Saturday (2026-11-07): 5 UTC = 0 ET fires, 4 UTC = 11 PM FRIDAY ET.
expect(etHour(new Date("2026-11-07T05:00:00Z"))).toBe(0);
expect(weekdayOf(etDate(new Date("2026-11-07T05:00:00Z")))).toBe(6);
expect(etHour(new Date("2026-11-07T04:00:00Z"))).toBe(23);
expect(etDate(new Date("2026-11-07T04:00:00Z"))).toBe("2026-11-06"); // still Friday ET
});
});
describe("date math (noon-UTC anchored)", () => {
test("addDays crosses the DST-end boundary without drift", () => {
expect(addDays("2026-10-31", 2)).toBe("2026-11-02");
expect(addDays("2026-08-24", 2)).toBe("2026-08-26");
});
test("campaign math", () => {
expect(campaignDay("2026-08-17")).toBe(1);
expect(campaignDay("2026-08-24")).toBe(8);
expect(campaignWeek("2026-08-24")).toBe(2);
expect(campaignWeek("2026-08-30")).toBe(2);
expect(campaignWeek("2026-08-31")).toBe(3);
});
test("iso week is stable across a week", () => {
expect(isoWeek("2026-08-24")).toBe(isoWeek("2026-08-29"));
expect(isoWeek("2026-08-30")).toBe(isoWeek("2026-08-24")); // Sun ends the ISO week
expect(isoWeek("2026-08-31")).toBe(isoWeek("2026-08-24") + 1);
});
});
describe("the rest day", () => {
test("Sunday is the only rest day", () => {
expect(isRestDay("2026-08-30")).toBe(true); // Sunday
expect(isRestDay("2026-08-29")).toBe(false); // Saturday: gate day, still work
expect(isRestDay("2026-08-31")).toBe(false);
});
test("workingDay never returns a Sunday, from any day and any offset", () => {
for (const date of CALENDAR) {
for (let offset = 0; offset < 10; offset++) {
expect(isRestDay(workingDay(date, offset))).toBe(false);
}
}
});
test("workingDay(date) leaves a working day alone and slides Sunday to Monday", () => {
expect(workingDay("2026-09-04")).toBe("2026-09-04"); // Friday
expect(workingDay("2026-09-05")).toBe("2026-09-05"); // Saturday
expect(workingDay("2026-09-06")).toBe("2026-09-07"); // Sunday → Monday
});
test("offsets count working days, so a spill skips the rest day", () => {
// Levelling from Saturday: tomorrow is Sunday, so the spill starts Monday.
expect(workingDay("2026-09-05", 1)).toBe("2026-09-07");
expect(workingDay("2026-09-05", 2)).toBe("2026-09-08");
// Two deferred Hards per working day from a Monday.
expect(workingDay("2026-09-28", 5)).toBe("2026-10-03"); // Saturday
expect(workingDay("2026-09-28", 6)).toBe("2026-10-05"); // Sunday skipped
});
test("every ladder interval lands on a working day, from every learning day", () => {
for (const date of CALENDAR) {
if (isRestDay(date)) continue;
for (const days of WINDOWS) {
const review = workingDay(addDays(date, days));
expect(isRestDay(review)).toBe(false);
// Never earlier than the interval: a review may slip, never shorten.
expect(review >= addDays(date, days)).toBe(true);
}
}
});
test("only Thursday's +3 needs the slide", () => {
const slid = CALENDAR.filter(
(date) => !isRestDay(date) && workingDay(addDays(date, 3)) !== addDays(date, 3),
);
expect(new Set(slid.map(weekdayOf))).toEqual(new Set([4])); // Thursday only
for (const date of CALENDAR) {
if (isRestDay(date)) continue;
expect(workingDay(addDays(date, 7))).toBe(addDays(date, 7)); // +7 keeps the weekday
}
});
});
describe("the plan", () => {
test("never books the rest day, and gates only on Saturday", () => {
for (const [date, day] of Object.entries(SCHEDULE)) {
expect(isRestDay(date)).toBe(false);
if (day.gate) expect(weekdayOf(date)).toBe(6);
if (day.topics) expect(weekdayOf(date)).toBeLessThan(6);
}
});
test("a topic is taught once; a doubled day answers for both", () => {
const seen = new Set<number>();
for (const day of Object.values(SCHEDULE)) {
for (const topic of day.topics ?? []) {
expect(seen.has(topic)).toBe(false);
seen.add(topic);
}
}
expect(topicDate(17)).toBe("2026-09-21");
expect(topicDate(18)).toBe("2026-09-21");
expect(topicDate(999)).toBeUndefined();
});
test("every gate and set problem is reserved from the samplers", () => {
for (const day of Object.values(SCHEDULE)) {
for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) {
expect(RESERVED.has(lc)).toBe(true);
}
}
expect(RESERVED.size).toBeGreaterThan(0);
});
});
describe("the ladder", () => {
test("two rungs, named after the windows", () => {
expect(STAGES).toEqual(["new", "+3", "+7", "retired"]);
expect(WINDOWS.map((w) => INTERVAL[`+${w}`])).toEqual([...WINDOWS]);
// Every non-terminal stage has an interval, and only those.
expect(Object.keys(INTERVAL).sort()).toEqual(STAGES.slice(1, -1).sort());
});
});
describe("concept temperature", () => {
test("the bands are the ladder's own windows", () => {
// Boundaries are inclusive, and the bands tile the whole age axis with no
// gap: whatever the windows become, every age still lands in exactly one.
expect(TEMPERATURES).toEqual(["hot", "fresh", "fading", "cold"]);
expect(temperatureOf(0)).toBe("hot");
expect(temperatureOf(WINDOWS[0])).toBe("hot");
expect(temperatureOf(WINDOWS[0] + 1)).toBe("fresh");
expect(temperatureOf(WINDOWS[1])).toBe("fresh");
expect(temperatureOf(WINDOWS[1] + 1)).toBe("fading");
expect(temperatureOf(WINDOWS[1] * 2)).toBe("fading");
expect(temperatureOf(WINDOWS[1] * 2 + 1)).toBe("cold");
});
test("never attempted is cold, not unknown", () => {
expect(temperatureOf(null)).toBe("cold");
});
test("every age of the campaign has exactly one band", () => {
for (let age = 0; age <= CALENDAR.length; age++) {
expect(TEMPERATURES).toContain(temperatureOf(age));
}
});
});
describe("deterministic sampling", () => {
test("same seed, same picks", () => {
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
expect(sample(pool, 3, rng("drill-2026-08-31"))).toEqual(sample(pool, 3, rng("drill-2026-08-31")));
expect(sample(pool, 3, rng("drill-2026-09-01"))).not.toEqual(sample(pool, 3, rng("drill-2026-08-31")));
});
});
+606
View File
@@ -0,0 +1,606 @@
/**
* SRS domain: ET dates, the work week, the interval ladder, concept
* temperature, deterministic sampling, and the one write path for attempts.
*
* D1 is the single source of truth. The stage names the review a problem must
* pass NEXT (`+3` = due 3 working days after the last clean solve). Passing
* advances new → +3 → +7 → retired; any failure resets to +3. A problem's
* FIRST-ever log enters the ladder at +3 regardless of result: a pass earns a
* 3-day review, a fail must be re-solved just as soon. Two rungs, 3 and 7 —
* the same numbers as the `work/3` and `work/7` buckets a re-solve is
* scaffolded into, and as the review-issue windows in
* apps/cli/spaced-repetition.ts.
*
* Sunday is off, structurally: every scheduled date comes out of
* workingDay(), which never returns one. Nothing "falls on" the rest day and
* gets swallowed or carried — it is simply never booked there.
*
* All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is
* anchored at noon UTC so DST edges cannot shift a date. Never raw Date math.
*/
import scheduleJson from "../data/schedule.json";
// ── dates (America/New_York) ─────────────────────────────────────
export const CAMPAIGN_START = "2026-08-17";
export const CAMPAIGN_DAYS = 56;
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
timeZone: "America/New_York",
dateStyle: "short",
});
const ET_HOUR = new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
hour: "numeric",
hourCycle: "h23",
});
/** ET calendar date of an instant. */
export function etDate(now: Date): string {
return ET_DATE.format(now);
}
/** ET hour 0–23 of an instant — the DST-proof cron guard. */
export function etHour(now: Date): number {
return Number(ET_HOUR.format(now));
}
/** Noon-UTC anchor: date-only arithmetic immune to DST edges. */
function atNoon(date: string): Date {
return new Date(`${date}T12:00:00Z`);
}
export function addDays(date: string, days: number): string {
return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10);
}
export function daysBetween(from: string, to: string): number {
return Math.round((atNoon(to).getTime() - atNoon(from).getTime()) / 86_400_000);
}
/** 0 = Sunday … 6 = Saturday. */
export function weekdayOf(date: string): number {
return atNoon(date).getUTCDay();
}
// ── the work week ────────────────────────────────────────────────
/**
* Sunday, the one day the campaign never schedules: topics run Mon–Fri
* (data/schedule.json), the gate is Saturday, and the review windows below are
* picked so a solve comes back on a working day.
*/
const REST_DAY = 0;
export function isRestDay(date: string): boolean {
return weekdayOf(date) === REST_DAY;
}
/**
* The `offset`-th working day counting from `from` (0 = `from` itself), with
* Sundays skipped — the ONE place a scheduled date is minted.
*
* Work may slide later, never earlier: pulling a review back to Saturday would
* shorten the very interval it exists to test, so a Sunday landing becomes
* Monday. With the +3/+7 ladder that only ever happens to a Thursday solve's
* 3-day review; +7 lands on the solve's own weekday, which is never a Sunday.
*/
export function workingDay(from: string, offset = 0): string {
let cursor = isRestDay(from) ? addDays(from, 1) : from;
for (let i = 0; i < offset; i++) {
cursor = addDays(cursor, 1);
if (isRestDay(cursor)) cursor = addDays(cursor, 1);
}
return cursor;
}
export function campaignDay(date: string): number {
return daysBetween(CAMPAIGN_START, date) + 1;
}
/** 1-based campaign week (Mon–Sun), aligned to CAMPAIGN_START. */
export function campaignWeek(date: string): number {
return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1;
}
/** Monday of the date's campaign week. */
export function weekMonday(date: string): string {
return addDays(CAMPAIGN_START, (campaignWeek(date) - 1) * 7);
}
export function isoWeek(date: string): number {
const d = atNoon(date);
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
}
export function prettyDate(date: string): string {
return new Intl.DateTimeFormat("en-US", {
timeZone: "UTC",
weekday: "long",
month: "long",
day: "numeric",
}).format(atNoon(date));
}
// ── the schedule (bundled; humans edit apps/api/data/schedule.json) ───
/**
* One calendar day of the plan. Most days teach topics (one, or two when the
* plan doubles up); the rest are named by what they assign instead: a fixed
* gate set for Saturday's review issue, a fixed problem set (a "B-set" day),
* or a sweep — redo every miss, nothing new. Absent dates are unplanned:
* the ladder still runs, the digest just has nothing new to say.
*/
export interface ScheduledDay {
/** Topic issues taught that day, in the order the digest presents them. */
topics?: number[];
/** Problems assigned by number instead of by topic. */
problems?: number[];
/** Saturday: the blind set, fixed by the plan rather than sampled. */
gate?: number[];
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
sweep?: boolean;
}
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
/** Date the schedule teaches a topic on, if it teaches it at all. */
export function topicDate(topic: number): string | undefined {
for (const [date, day] of Object.entries(SCHEDULE)) {
if (day.topics?.includes(topic)) return date;
}
return undefined;
}
/** Week in which a topic was (or will be) taught. */
export function topicWeek(topic: number): number | undefined {
const date = topicDate(topic);
return date === undefined ? undefined : campaignWeek(date);
}
/**
* Every problem the plan names outright — a gate's blind set or a set day's
* list. The plan owns these: the samplers (daily drills, a sampled gate)
* never draw them, so a gate problem stays unseen until its Saturday and a
* set day's problems wait for their day.
*/
export const RESERVED: ReadonlySet<number> = new Set(
Object.values(SCHEDULE).flatMap((day) => [...(day.gate ?? []), ...(day.problems ?? [])]),
);
// ── deterministic sampling ───────────────────────────────────────
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
export function rng(seed: string): () => number {
let h = 0x811c9dc5;
for (let i = 0; i < seed.length; i++) {
h ^= seed.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return () => {
h = Math.imul(h ^ (h >>> 15), h | 1);
h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
};
}
/** Up to n elements, Fisher–Yates order driven by the seeded PRNG. */
export function sample<T>(pool: T[], n: number, random: () => number): T[] {
const copy = [...pool];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[copy[i], copy[j]] = [copy[j]!, copy[i]!];
}
return copy.slice(0, n);
}
// ── rows ─────────────────────────────────────────────────────────
/**
* Review windows in days. One vocabulary for the whole campaign: these are the
* ladder rungs, the `work/<n>` buckets the picker scaffolds into, and the
* windows the `Spaced Repetition — <date>` issues are built from.
*
* 3 and 7 keep the rest day free. +7 returns a solve to its own weekday, and
* of the five learning weekdays only Thursday's +3 touches a Sunday — which
* workingDay() slides to Monday.
*/
export const WINDOWS = [3, 7] as const;
export type Window = (typeof WINDOWS)[number];
export type Stage = "new" | `+${Window}` | "retired";
/** Chart / stats order, low to high. */
export const STAGES: Stage[] = ["new", "+3", "+7", "retired"];
export type Result = "pass" | "fail";
export type Kind = "first" | "review" | "drill" | "gate";
/**
* Catalog vocabulary, carried by the `diff:*` / `set:*` issue labels and
* stored verbatim in `problems`. Listed low to high / most to least required,
* which is the order every chart, table and digest section prints them in.
*/
export const DIFFICULTIES = ["easy", "medium", "hard"] as const;
export type Difficulty = (typeof DIFFICULTIES)[number];
export const SETS = ["core", "optional", "deferred"] as const;
export type SetLabel = (typeof SETS)[number];
/**
* How warm a concept is: the age of a topic's most recent attempt, bucketed by
* the ladder's own windows so the two cannot drift. Touched inside the first
* window it is `hot`, inside the second `fresh`, inside one more full second
* window `fading`; older than that — or never attempted — it is `cold`.
*
* A read-side label only: nothing is scheduled off it. It answers the question
* the ladder cannot, because the ladder tracks problems and a topic can go
* quiet for a fortnight while not one of its problems comes due.
*/
export const TEMPERATURES = ["hot", "fresh", "fading", "cold"] as const;
export type Temperature = (typeof TEMPERATURES)[number];
/** Warmest band first, with the age it tolerates. Past the last one is cold. */
const TEMPERATURE_MAX_AGE: [Temperature, number][] = [
["hot", WINDOWS[0]],
["fresh", WINDOWS[1]],
["fading", WINDOWS[1] * 2],
];
/** Days since a topic's last attempt → its band; null (never) is cold. */
export function temperatureOf(age: number | null): Temperature {
if (age === null) return "cold";
for (const [temperature, max] of TEMPERATURE_MAX_AGE) {
if (age <= max) return temperature;
}
return "cold";
}
export interface ProblemRow {
lc_number: number;
issue: number;
topic_issue: number;
title: string;
difficulty: string;
set_label: string;
stage: Stage;
next_review: string | null;
defer_until: string | null;
}
export const INTERVAL: Record<string, number> = { "+3": 3, "+7": 7 };
const NEXT_STAGE: Record<string, Stage> = { new: "+3", "+3": "+7", "+7": "retired" };
// ── queries ──────────────────────────────────────────────────────
export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow | null> {
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
}
/**
* Catalog rows for a list of numbers the plan names, in the plan's order,
* plus the numbers the catalog does not know — a planned problem without an
* issue can never be logged, so callers decide whether that is fatal.
*/
export async function lookupProblems(
db: D1Database,
lcs: number[],
): Promise<{ rows: ProblemRow[]; missing: number[] }> {
if (lcs.length === 0) return { rows: [], missing: [] };
const { results } = await db
.prepare(`SELECT * FROM problems WHERE lc_number IN (${lcs.map(() => "?").join(",")})`)
.bind(...lcs)
.all<ProblemRow>();
const byNumber = new Map(results.map((p) => [p.lc_number, p]));
const rows: ProblemRow[] = [];
const missing: number[] = [];
for (const lc of lcs) {
const row = byNumber.get(lc);
if (row) rows.push(row);
else missing.push(lc);
}
return { rows, missing };
}
/**
* What "due" means, in one place: not retired, scheduled on/before ?1, and
* past its deferral if it has one. Every caller that asks "what is open on
* this day" — the digest, the review issue, the docs queue chart — shares
* this clause so they cannot answer differently.
*/
export const DUE_WHERE = `stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
AND (defer_until IS NULL OR defer_until <= ?1)`;
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
const { results } = await db
.prepare(`SELECT * FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`)
.bind(date)
.all<ProblemRow>();
return results;
}
/** One problem the ladder wants re-solved today. */
export interface DueRow {
lc: number;
issue: number;
title: string;
difficulty: string;
/** The rung being settled: also the `work/<n>` bucket the re-solve goes in. */
stage: Stage;
bucket: number;
/** Scheduled day; earlier than `date` when a day was missed. */
due: string;
/** Days late (0 = due today), so a renderer can say so. */
late: number;
/** Last logged attempt, and how long ago — the "last seen" line. */
last_seen: string | null;
days_since: number | null;
}
/**
* Everything the ladder wants re-solved on `date`, oldest first.
*
* The read side of "which problems are open today": the same rows the digest
* mails, shaped for whoever renders them (the daily Spaced Repetition issue).
* Nothing here writes, so it is safe to call repeatedly, and there is no
* second schedule to drift — D1's ladder is the schedule.
*/
export async function dueToday(db: D1Database, date: string): Promise<{ date: string; due: DueRow[] }> {
const { results } = await db
.prepare(
`SELECT lc_number, issue, title, difficulty, stage, next_review,
(SELECT MAX(a.date) FROM attempts a WHERE a.lc_number = problems.lc_number) AS last_seen
FROM problems WHERE ${DUE_WHERE} ORDER BY next_review, lc_number`,
)
.bind(date)
.all<{
lc_number: number;
issue: number;
title: string;
difficulty: string;
stage: Stage;
next_review: string;
last_seen: string | null;
}>();
return {
date,
due: results.map((r) => ({
lc: r.lc_number,
issue: r.issue,
title: r.title,
difficulty: r.difficulty,
stage: r.stage,
// "+3" -> 3: the rung's number IS its work/ bucket.
bucket: Number(r.stage.slice(1)),
due: r.next_review,
late: daysBetween(r.next_review, date),
last_seen: r.last_seen,
days_since: r.last_seen === null ? null : daysBetween(r.last_seen, date),
})),
};
}
/** Reviews surfaced per day; the digest levels everything past this forward. */
export const REVIEW_CAP = 3;
/**
* Load-level an overloaded review queue: everything due beyond today's
* REVIEW_CAP is pushed to a concrete future WORKING day — at most REVIEW_CAP
* per day, oldest first — instead of piling up as "due today". Runs every
* digest morning, so a future day that grows past the cap (spill plus newly
* maturing reviews) is simply re-levelled when it arrives. Idempotent within
* a date: after one pass at most REVIEW_CAP problems remain due today, so a
* second pass moves nothing. The spill starts tomorrow, or Monday when
* tomorrow is the rest day.
*/
export async function levelReviews(db: D1Database, date: string): Promise<number> {
const overflow = (await dueReviews(db, date)).slice(REVIEW_CAP);
if (overflow.length === 0) return 0;
await db.batch(
overflow.map((p, i) =>
db
.prepare("UPDATE problems SET next_review = ? WHERE lc_number = ?")
.bind(workingDay(addDays(date, 1), Math.floor(i / REVIEW_CAP)), p.lc_number),
),
);
return overflow.length;
}
/**
* Blind drills: unsolved optional problems from topics ALREADY LEARNED —
* scheduled in an earlier week (the current week's optional pool is reserved
* for Saturday's gate) AND showing learning evidence: at least one of the
* topic's core problems has entered the ladder. A skipped learning day never
* feeds drills just because its calendar week lapsed. Problems the plan names
* outright (RESERVED) are never drawn. Never repeated, seeded by date.
* Boosted topics contribute up to 2 extra.
*/
export async function pickDrills(
db: D1Database,
date: string,
budget: number,
): Promise<ProblemRow[]> {
if (budget <= 0) return [];
const week = campaignWeek(date);
const { results } = await db
.prepare(
`SELECT p.*, t.boost AS boost FROM problems p
JOIN topics t ON t.issue = p.topic_issue
WHERE p.set_label = 'optional' AND p.stage = 'new'
AND p.lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = p.lc_number)
AND EXISTS (SELECT 1 FROM problems c
WHERE c.topic_issue = p.topic_issue
AND c.set_label = 'core' AND c.stage != 'new')
ORDER BY p.lc_number`,
)
.all<ProblemRow & { boost: number }>();
const pool = results.filter((p) => {
if (RESERVED.has(p.lc_number)) return false;
const w = topicWeek(p.topic_issue);
return w !== undefined && w < week;
});
const boosted = pool.filter((p) => p.boost === 1);
const regular = pool.filter((p) => p.boost !== 1);
const boostPicks = sample(boosted, Math.min(2, budget), rng(`boost-${date}`));
const regularPicks = sample(
regular,
Math.min(2, Math.max(0, budget - boostPicks.length)),
rng(`drill-${date}`),
);
return [...boostPicks, ...regularPicks].slice(0, budget);
}
/** Consecutive days with ≥1 attempt, ending today or yesterday. */
export async function streak(db: D1Database, today: string): Promise<number> {
const { results } = await db
.prepare("SELECT DISTINCT date FROM attempts ORDER BY date DESC LIMIT 90")
.all<{ date: string }>();
const days = new Set(results.map((r) => r.date));
let cursor = days.has(today) ? today : addDays(today, -1);
let n = 0;
while (days.has(cursor)) {
n++;
cursor = addDays(cursor, -1);
}
return n;
}
// ── the one write path ───────────────────────────────────────────
export interface LogOutcome {
lc: number;
title: string;
kind: Kind;
result: Result;
stage: Stage;
next_review: string | null;
/** First-ever attempt — caller closes the sub-issue on pass. */
first: boolean;
issue: number;
/** Email one-tap replay: nothing changed. */
duplicate: boolean;
error?: string;
}
/**
* Record an attempt and move the ladder. Ladder semantics live here and only
* here — email one-taps, webhook /done lines, gate scoring, and solutions
* landing in work/ all converge.
*
* Email idempotency comes from the partial unique index on
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
* nothing and must not touch the ladder. Webhook corrections (pass then fail
* on the same day) remain legal — every webhook attempt appends.
*
* Commit idempotency needs no index either. A commit names the rung the file
* it landed in settles — `work/1` settles `new`, `work/3` settles `+3`,
* `work/7` settles `+7` — and the stage check below writes only when the
* ladder is actually standing on that rung. So re-pushing a solution, or the
* reconciler re-sending its whole implemented set on every push, moves
* nothing: the rung it names has already been left behind.
*/
export async function logAttempt(
db: D1Database,
opts: {
lc: number;
date: string;
result: Result;
source: "email" | "webhook" | "commit";
/** Commit only: the stage this file settles. Default `new` = first solve. */
rung?: Stage;
gate?: boolean;
},
): Promise<LogOutcome> {
const p = await getProblem(db, opts.lc);
const nothing: LogOutcome = {
lc: opts.lc,
title: "",
kind: "review",
result: opts.result,
stage: "new",
next_review: null,
first: false,
issue: 0,
duplicate: false,
};
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
// A commit reports a file, not an event: it may only settle the rung that
// file's bucket IS. Any other stage means the push is old news (the ladder
// already moved past it) or premature — no write either way, and a retired
// problem can never be woken. Fresh reviews otherwise come from the digest
// tap, a /done comment, or a gate.
if (opts.source === "commit" && p.stage !== (opts.rung ?? "new")) {
return {
...nothing,
title: p.title,
stage: p.stage,
next_review: p.next_review,
issue: p.issue,
duplicate: true,
};
}
if (p.stage === "retired") {
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
}
const attempted = await db
.prepare("SELECT 1 AS x FROM attempts WHERE lc_number = ? LIMIT 1")
.bind(opts.lc)
.first();
const first = !attempted && p.stage === "new";
const kind: Kind = opts.gate ? "gate" : first ? (p.set_label === "optional" ? "drill" : "first") : "review";
const inserted = await db
.prepare(
`INSERT INTO attempts (lc_number, date, kind, result, source) VALUES (?, ?, ?, ?, ?)
ON CONFLICT DO NOTHING`,
)
.bind(opts.lc, opts.date, kind, opts.result, opts.source)
.run();
if (opts.source === "email" && inserted.meta.changes === 0) {
return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true };
}
// Ladder move. First-ever logs enter at +3 for pass AND fail.
let stage: Stage;
if (first) {
stage = "+3";
} else if (opts.result === "pass") {
stage = NEXT_STAGE[p.stage] ?? "retired";
} else {
stage = "+3";
}
// workingDay(), not addDays(): a Thursday solve's +3 would land on the rest
// day, and it takes Monday instead.
const next = stage === "retired" ? null : workingDay(addDays(opts.date, INTERVAL[stage]!));
const writes = [
db.prepare(
"UPDATE problems SET stage = ?, next_review = ?, defer_until = NULL WHERE lc_number = ?",
).bind(stage, next, opts.lc),
];
if (first && kind === "drill") {
writes.push(
db.prepare("INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (?)").bind(opts.lc),
);
if (opts.result === "fail") {
writes.push(
db.prepare("UPDATE topics SET misses = misses + 1 WHERE issue = ?").bind(p.topic_issue),
);
}
}
await db.batch(writes);
return {
lc: opts.lc,
title: p.title,
kind,
result: opts.result,
stage,
next_review: next,
first,
issue: p.issue,
duplicate: false,
};
}
+230
View File
@@ -0,0 +1,230 @@
/**
* GET /api/stats — the one JSON document behind the docs progress page and the
* `bun run stats` terminal dashboard. Read-only aggregation over D1; the shape
* is both readers' contract, so change all three together. A problem counts as
* "done" once it entered the ladder (stage != 'new').
*
* The temperature block is the one figure here that is not a count of rows:
* srs.ts turns a topic's days-since-last-attempt into a band, so "which
* concepts have gone cold" has exactly one definition and the dashboard is
* free to colour it however it likes.
*/
import {
CAMPAIGN_DAYS,
CAMPAIGN_START,
DIFFICULTIES,
DUE_WHERE,
addDays,
campaignDay,
campaignWeek,
daysBetween,
isoWeek,
STAGES,
streak,
temperatureOf,
topicDate,
type Temperature,
} from "./srs.ts";
const PHASE_NAMES: Record<number, string> = {
1: "I — Linear",
2: "II — Nodal & Grid",
3: "III — Hierarchical",
4: "IV — Relational",
5: "V — Decision Space",
};
/** One concept: catalog coverage plus how long since it was last exercised. */
interface TopicStat {
issue: number;
name: string;
milestone: number | null;
/** Campaign week the schedule teaches it in; null when unscheduled. */
week: number | null;
/** False while the schedule has not reached it — temperature is moot. */
taught: boolean;
total: number;
done: number;
retired: number;
due: number;
/** ET date of the most recent attempt on any of its problems. */
last: string | null;
/** Days since `last`; null when never attempted. */
age: number | null;
temperature: Temperature;
}
async function buildTopics(db: D1Database, today: string): Promise<TopicStat[]> {
// Three shapes, three statements: batch() carries one row type, and casting
// its results apart would cost more than the round trips it saves.
const { results: coverage } = await db
.prepare(
`SELECT t.issue AS issue, t.name AS name, t.milestone AS milestone,
COUNT(p.lc_number) AS total,
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done,
SUM(CASE WHEN p.stage = 'retired' THEN 1 ELSE 0 END) AS retired
FROM topics t LEFT JOIN problems p ON p.topic_issue = t.issue
GROUP BY t.issue
ORDER BY t.issue`,
)
.all<{
issue: number;
name: string;
milestone: number | null;
total: number;
done: number | null;
retired: number | null;
}>();
const { results: activity } = await db
.prepare(
`SELECT p.topic_issue AS issue, MAX(a.date) AS last
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
GROUP BY p.topic_issue`,
)
.all<{ issue: number; last: string | null }>();
const lastByTopic = new Map<number, string>();
for (const row of activity) {
if (row.last) lastByTopic.set(row.issue, row.last);
}
const { results: dueRows } = await db
.prepare(
`SELECT topic_issue AS issue, COUNT(*) AS n
FROM problems WHERE ${DUE_WHERE} GROUP BY topic_issue`,
)
.bind(today)
.all<{ issue: number; n: number }>();
const dueByTopic = new Map<number, number>();
for (const row of dueRows) dueByTopic.set(row.issue, row.n);
return coverage.map((row) => {
const taughtOn = topicDate(row.issue);
const last = lastByTopic.get(row.issue) ?? null;
const age = last === null ? null : daysBetween(last, today);
return {
issue: row.issue,
name: row.name,
milestone: row.milestone,
week: taughtOn === undefined ? null : campaignWeek(taughtOn),
taught: taughtOn !== undefined && taughtOn <= today,
total: row.total,
done: row.done ?? 0,
retired: row.retired ?? 0,
due: dueByTopic.get(row.issue) ?? 0,
last,
age,
temperature: temperatureOf(age),
};
});
}
export async function buildStats(db: D1Database, today: string): Promise<object> {
const { results: phaseRows } = await db
.prepare(
`SELECT t.milestone AS milestone, p.set_label AS set_label,
COUNT(*) AS total,
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done
FROM problems p JOIN topics t ON t.issue = p.topic_issue
WHERE t.milestone IS NOT NULL
GROUP BY t.milestone, p.set_label
ORDER BY t.milestone`,
)
.all<{ milestone: number; set_label: string; total: number; done: number }>();
const phases = new Map<
number,
{ milestone: number; name: string } & Record<string, number | string>
>();
for (const row of phaseRows) {
const phase =
phases.get(row.milestone) ??
({
milestone: row.milestone,
name: PHASE_NAMES[row.milestone] ?? `Phase ${row.milestone}`,
core_done: 0,
core_total: 0,
optional_done: 0,
optional_total: 0,
deferred_done: 0,
deferred_total: 0,
} as { milestone: number; name: string } & Record<string, number | string>);
phase[`${row.set_label}_done`] = row.done;
phase[`${row.set_label}_total`] = row.total;
phases.set(row.milestone, phase);
}
const { results: ladderRows } = await db
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
.all<{ stage: string; n: number }>();
// Every rung is present even at zero: the page's bar list is this object.
const ladder: Record<string, number> = Object.fromEntries(STAGES.map((s) => [s, 0]));
for (const row of ladderRows) ladder[row.stage] = row.n;
// Same shape as `ladder`: every band present, zeros included, in DIFFICULTIES
// order so a reader can index it without sorting.
const { results: diffRows } = await db
.prepare(
`SELECT difficulty,
COUNT(*) AS total,
SUM(CASE WHEN stage != 'new' THEN 1 ELSE 0 END) AS done
FROM problems GROUP BY difficulty`,
)
.all<{ difficulty: string; total: number; done: number }>();
const difficulty = DIFFICULTIES.map((name) => {
const row = diffRows.find((r) => r.difficulty === name);
return { difficulty: name, total: row?.total ?? 0, done: row?.done ?? 0 };
});
// gates rows are keyed by ISO week (that's the digest/gate contract), but
// the page speaks campaign weeks. The campaign never crosses a year
// boundary, so a plain offset converts safely.
const isoWeekOffset = isoWeek(CAMPAIGN_START) - 1;
const { results: gateRows } = await db
.prepare("SELECT week, issue, pass_rate, closed_on FROM gates ORDER BY week")
.all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>();
const gates = gateRows.map((g) => ({ ...g, week: g.week - isoWeekOffset }));
// Review-queue depth for the next 14 days: everything due by that day. One
// statement per day, but DUE_WHERE stays the only definition of "due" — the
// batch is a single round trip, not fourteen.
const dates = Array.from({ length: 14 }, (_, i) => addDays(today, i));
const queueRows = await db.batch<{ n: number }>(
dates.map((date) =>
db.prepare(`SELECT COUNT(*) AS n FROM problems WHERE ${DUE_WHERE}`).bind(date),
),
);
const queue = dates.map((date, i) => ({ date, due: queueRows[i]?.results[0]?.n ?? 0 }));
// Attempts per campaign day, every day present. This is the heatmap's data
// model — the docs page reads the tail of it for "last 7 days" rather than
// asking a second question that could answer differently.
const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1);
const { results: heatRows } = await db
.prepare(
`SELECT date,
COUNT(*) AS attempts,
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
FROM attempts WHERE date BETWEEN ?1 AND ?2 GROUP BY date`,
)
.bind(CAMPAIGN_START, end)
.all<{ date: string; attempts: number; passes: number | null }>();
const byDate = new Map(heatRows.map((r) => [r.date, r]));
const heat = Array.from({ length: CAMPAIGN_DAYS }, (_, i) => {
const date = addDays(CAMPAIGN_START, i);
const row = byDate.get(date);
return { date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 };
});
return {
generated: new Date().toISOString(),
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
phases: [...phases.values()],
ladder,
difficulty,
topics: await buildTopics(db, today),
gates,
streak: await streak(db, today),
queue,
heat,
};
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["esnext"],
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["./worker-configuration.d.ts", "bun"]
},
"include": ["src", "scripts"]
}
+41
View File
@@ -0,0 +1,41 @@
// SRS Worker — daily digest email, one-tap logging, weekly review issues,
// live SVG charts, and the /api/stats feed for the docs progress page.
//
// D1 is the source of truth for SRS state; GitHub issues own the catalog;
// apps/api/data/schedule.json (bundled) owns the schedule. JSONC over TOML per
// Workers best practices (newer features are JSON-only).
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "srs-api",
"main": "src/index.ts",
"compatibility_date": "2026-08-24",
"observability": { "enabled": true },
"d1_databases": [
{ "binding": "DB", "database_name": "srs", "database_id": "2ee2c34a-c2e2-4ebb-934e-c9e7dc8aa4f1" }
],
// Sender domain prdlk.com is onboarded to Email Service; the binding is
// restricted to the one verified destination inbox.
"send_email": [
{ "name": "EMAIL", "allowed_destination_addresses": ["prnk28@gmail.com"] }
],
// Double-cron per event makes each DST-proof: the code fires only when the
// computed ET hour matches (8 AM daily digest, midnight-Saturday review).
// 8 AM ET = 12:00 UTC in EDT, 13:00 UTC in EST; midnight ET = 4/5 UTC.
"triggers": {
"crons": ["0 12 * * *", "0 13 * * *", "0 4 * * 6", "0 5 * * 6"]
},
"vars": {
"REPO": "prdlk/leetcode",
"FROM_EMAIL": "srs@prdlk.com",
"TO_EMAIL": "prnk28@gmail.com",
"DOCS_URL": "https://lc.prd.lk",
"DOCS_ORIGIN": "https://lc.prd.lk,https://prdlk.github.io",
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev"
}
// Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY.
}
+87
View File
@@ -0,0 +1,87 @@
/**
* The SRS Worker, from the CLI side: one base URL and one read-side payload.
*
* `SRS_API` points a script at a `wrangler dev` instance instead of production
* — the same switch close-solved.ts uses for its `/admin/solved` push, which is
* why the default lives here rather than in either script.
*
* The payload is narrowed with a guard rather than cast: D1 is the only source
* of SRS truth, so if the Worker answers something else the caller must say so
* out loud, not render half a dashboard off undefined fields.
*/
import { STAGES, TEMPERATURES, type Stage, type Temperature } from "../api/src/srs.ts";
export const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
// The rungs and the bands come straight from the Worker's domain module — the
// same import work.ts makes for WINDOWS — so a change to either vocabulary
// reaches this dashboard without a second list to remember.
export { STAGES, TEMPERATURES, type Stage, type Temperature };
export interface Phase {
milestone: number;
name: string;
core_done: number;
core_total: number;
optional_done: number;
optional_total: number;
deferred_done: number;
deferred_total: number;
}
export interface Topic {
issue: number;
name: string;
milestone: number | null;
week: number | null;
taught: boolean;
total: number;
done: number;
retired: number;
due: number;
last: string | null;
age: number | null;
temperature: Temperature;
}
export interface Stats {
generated: string;
campaign: { day: number; week: number; start: string; days: number };
phases: Phase[];
ladder: Record<Stage, number>;
difficulty: { difficulty: string; total: number; done: number }[];
topics: Topic[];
/** week is the 1-based campaign week — the Worker converts from ISO. */
gates: { week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }[];
streak: number;
queue: { date: string; due: number }[];
/** Every campaign day, zeros and future days included. */
heat: { date: string; attempts: number; passes: number }[];
}
function isStats(value: unknown): value is Stats {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
return (
typeof v.campaign === "object" &&
v.campaign !== null &&
typeof v.ladder === "object" &&
v.ladder !== null &&
typeof v.streak === "number" &&
Array.isArray(v.phases) &&
Array.isArray(v.difficulty) &&
Array.isArray(v.topics) &&
Array.isArray(v.gates) &&
Array.isArray(v.queue) &&
Array.isArray(v.heat)
);
}
/** GET /api/stats. Throws with the reason; there is no local substitute. */
export async function fetchStats(signal?: AbortSignal): Promise<Stats> {
const res = await fetch(`${SRS_API}/api/stats`, { signal });
if (!res.ok) throw new Error(`GET /api/stats -> ${res.status} ${await res.text()}`);
const body: unknown = await res.json();
if (!isStats(body)) throw new Error("GET /api/stats -> unrecognised payload shape");
return body;
}
+232
View File
@@ -0,0 +1,232 @@
#!/usr/bin/env bun
/**
* Close the GitHub issue for every LeetCode problem actually solved under work/,
* then report the solved set to the SRS Worker so D1 agrees.
*
* Reconciles state instead of reacting to a push diff: any `problem`-labelled
* issue whose LC number has an *implemented* solution file in work/ gets
* closed. Only open issues are touched, so re-runs are no-ops and backfilling
* needs no special casing. Removing a solution never reopens an issue.
*
* `bun run pick` scaffolds a statement header plus an empty function body, so
* file existence alone means nothing — a stub must not close its issue. See
* isImplemented() in source.ts.
*
* bun apps/cli/close-solved.ts # close matches, log them in D1
* bun apps/cli/close-solved.ts --dry-run # report only, touch nothing
*
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
* SRS_ADMIN_KEY for the D1 push (unset = skip it); SRS_API overrides
* the Worker URL for local `wrangler dev` runs.
*/
import { join } from "node:path";
import { SRS_API } from "./api.ts";
import { github } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
import { isImplemented } from "./source.ts";
import { WORK, parseWorkPath, type Bucket } from "./work.ts";
const DRY =
process.argv.includes("--dry-run") ||
process.env.DRY_RUN === "1" ||
process.env.DRY_RUN === "true";
// ── repo + auth ──────────────────────────────────────────────────
const gh = await github();
// ── work/ inventory ──────────────────────────────────────────────
interface WorkEntry {
files: string[];
/** Any bucket implemented — this is what closes the problem issue. */
implemented: boolean;
/** Buckets holding a real solution: 1 = first solve, 3/7 = the re-solves. */
solved: Set<Bucket>;
}
/** LC number -> its solution files across every bucket (js and py both count). */
const work = new Map<number, WorkEntry>();
for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
const file = parseWorkPath(rel);
if (!file) {
console.warn(`skip (off-layout): work/${rel}`);
continue;
}
const src = await Bun.file(join(WORK, rel)).text();
const implemented = isImplemented(src, rel.endsWith(".py") ? "py" : "js");
const entry = work.get(file.lc) ?? { files: [], implemented: false, solved: new Set<Bucket>() };
entry.files.push(`work/${rel}`);
entry.implemented ||= implemented;
if (implemented) entry.solved.add(file.bucket);
work.set(file.lc, entry);
}
// ── open problem issues ──────────────────────────────────────────
interface ProblemIssue {
number: number;
set: string;
}
/**
* Narrow one element of the /issues payload to the fields this script needs.
* Returns undefined for pull requests and for anything whose title is not a
* `LC <num> ...` problem, which is how non-curriculum rows get skipped.
*/
function readProblemIssue(
value: unknown,
): { lc: number; issue: ProblemIssue } | undefined {
if (!value || typeof value !== "object") return;
if ("pull_request" in value) return; // the /issues route also lists PRs
if (!("number" in value) || typeof value.number !== "number") return;
if (!("title" in value) || typeof value.title !== "string") return;
const lc = value.title.match(/^LC (\d+) /);
if (!lc) return;
let set = "—";
if ("labels" in value && Array.isArray(value.labels)) {
for (const label of value.labels) {
if (
label &&
typeof label === "object" &&
"name" in label &&
typeof label.name === "string" &&
label.name.startsWith("set:")
) {
set = label.name;
}
}
}
return { lc: Number(lc[1]), issue: { number: value.number, set } };
}
/** LC number -> open issue carrying the `problem` label. */
const open = new Map<number, ProblemIssue>();
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=open`)) {
const parsed = readProblemIssue(raw);
if (parsed) open.set(parsed.lc, parsed.issue);
}
// ── reconcile ────────────────────────────────────────────────────
const sha = process.env.GITHUB_SHA;
const report: string[][] = [];
let closed = 0;
for (const num of [...work.keys()].sort((a, b) => a - b)) {
const entry = work.get(num)!;
const issue = open.get(num);
// Warm-ups and out-of-curriculum practice have no issue. Not an error.
if (!issue) {
report.push([`${num}`, "—", "—", "no open issue"]);
continue;
}
if (!entry.implemented) {
report.push([`${num}`, `#${issue.number}`, issue.set, "stub — skipped"]);
continue;
}
if (DRY) {
report.push([`${num}`, `#${issue.number}`, issue.set, "would close"]);
continue;
}
const links = entry.files
.map((f) =>
sha ? `[\`${f}\`](https://github.com/${gh.repo}/blob/${sha}/${encodeURI(f)})` : `\`${f}\``,
)
.join(", ");
await gh.closeIssue(
issue.number,
`Solved — solution committed at ${links}.\n\n` +
"Closed automatically by `close-solved`. Fill in the close-out block above if you have not already.",
);
report.push([`${num}`, `#${issue.number}`, issue.set, "closed"]);
closed++;
}
// ── push the solved set into D1 ───────────────────────────────────
// Closing an issue is invisible to the Worker: its catalog reconcile reads
// GitHub for titles, labels and milestones and never looks at issue state,
// and D1's SRS columns only ever move through logAttempt(). So a solution
// pushed here — rather than tapped in the digest email — has to be reported,
// or the charts, the digest's "already solved" ticks, and the drill/gate
// pools all keep treating it as untouched.
//
// Each entry carries the BUCKET the file sits in, which is the rung it
// settles: work/1 is the first solve, work/3 the 3-day review, work/7 the
// 7-day one. That is what makes a pushed re-solve advance the ladder instead
// of vanishing — and the Worker writes only when a problem is standing on the
// rung named, so the whole implemented set can go over on every push (a total
// recompute that backfills whatever earlier runs missed) and re-runs write
// nothing.
// SRS_API (apps/cli/api.ts) is the switch that points this at a wrangler dev
// instance instead of production.
const solved = [...work.entries()]
.flatMap(([lc, entry]) => [...entry.solved].sort().map((bucket) => ({ lc, bucket })))
.sort((a, b) => a.lc - b.lc || a.bucket - b.bucket);
let syncNote: string;
let syncFailed = false;
if (!process.env.SRS_ADMIN_KEY) {
syncNote = "SRS_ADMIN_KEY unset — D1 not touched";
} else if (solved.length === 0) {
syncNote = "nothing implemented — D1 not touched";
} else {
try {
const res = await fetch(`${SRS_API}/admin/solved${DRY ? "?dry=1" : ""}`, {
method: "POST",
headers: {
authorization: `Bearer ${process.env.SRS_ADMIN_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({ solved }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
// Narrow the report payload instead of casting it; a shape change should
// read as "logged nothing", not throw inside the reconciler.
const payload: unknown = await res.json();
const fields = payload && typeof payload === "object" ? payload : {};
const strings = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : []);
const logged = strings("logged" in fields ? fields.logged : null);
const skipped = strings("skipped" in fields ? fields.skipped : null);
syncNote =
`${DRY ? "would log" : "logged"} ${logged.length} of ${solved.length} ` +
`(${skipped.length} already on the ladder or off-curriculum)`;
for (const line of logged) console.log(` d1: ${line}`);
} catch (err) {
// The closes above already landed; surface the failure instead of
// letting D1 drift silently until the next push.
syncFailed = true;
syncNote = `D1 push FAILED: ${err instanceof Error ? err.message : String(err)}`;
}
}
// ── report ───────────────────────────────────────────────────────
const rows = [["lc", "issue", "set", "status"], ...report];
printTable(rows);
const actionable = report.filter((r) => r[3] !== "no open issue");
console.log(
`\n${work.size} problems in work/ · ${solved.length} solved files across buckets · ` +
`${actionable.length} matched an open issue · ` +
`${DRY ? `would close ${actionable.filter((r) => r[3] === "would close").length}` : `closed ${closed}`}` +
`\nd1: ${syncNote}`,
);
await writeStepSummary(
`### close-solved${DRY ? " (dry run)" : ""}\n\n` +
`${work.size} problems in \`work/\`, ${solved.length} solved files across buckets, ` +
`${actionable.length} matched an open issue.\n\n` +
(actionable.length
? `${markdownTable([rows[0]!, ...actionable])}\n`
: "Nothing to close.\n") +
`\nD1: ${syncNote}\n`,
);
// Non-zero only for the D1 push: the closes are already reported above.
if (syncFailed) process.exitCode = 1;
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bun
/**
* Close the GitHub issue for every topic whose required (core) problems are done.
*
* Each `topic`-labelled issue owns its problems as GitHub sub-issues, and each
* problem carries exactly one `set:` label — `set:core` is required, while
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
* every one of its core sub-issues is closed; optional/deferred state is
* ignored, matching the day rule in each topic body ("Core problems first").
* The rule lives in topics.ts, shared with release-topics.ts.
*
* Like close-solved.ts this reconciles state instead of reacting to an event
* payload: re-runs are no-ops, backfilling needs no special casing, and only
* open topics are touched, so reopening a problem never reopens its topic.
*
* bun apps/cli/close-topics.ts # close finished topics
* bun apps/cli/close-topics.ts --dry-run # report only, touch nothing
*
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
*/
import { github } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
import { coreSet, readProblems } from "./topics.ts";
const DRY =
process.argv.includes("--dry-run") ||
process.env.DRY_RUN === "1" ||
process.env.DRY_RUN === "true";
const gh = await github();
// ── open topic issues ────────────────────────────────────────────
interface Topic {
number: number;
title: string;
}
/**
* Narrow one element of the /issues payload to the fields this script needs.
* The `topic` label filter cannot exclude pull requests, so drop those here.
*/
function readTopic(value: unknown): Topic | undefined {
if (!value || typeof value !== "object") return;
if ("pull_request" in value) return; // the /issues route also lists PRs
if (!("number" in value) || typeof value.number !== "number") return;
if (!("title" in value) || typeof value.title !== "string") return;
return { number: value.number, title: value.title };
}
const topics: Topic[] = [];
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=open`)) {
const topic = readTopic(raw);
if (topic) topics.push(topic);
}
topics.sort((a, b) => a.number - b.number);
// ── reconcile ────────────────────────────────────────────────────
const report: string[][] = [];
let closed = 0;
for (const topic of topics) {
const core = coreSet(await readProblems(gh, topic.number));
const progress = `${core.all.length - core.pending.length}/${core.all.length}`;
// A topic with no core sub-issues has nothing to complete: never close it,
// since that would be indistinguishable from a mis-labelled problem set.
if (core.all.length === 0) {
report.push([`#${topic.number}`, topic.title, progress, "no core set"]);
continue;
}
if (core.pending.length > 0) {
report.push([
`#${topic.number}`,
topic.title,
progress,
`open: ${core.pending.map((n) => `#${n}`).join(" ")}`,
]);
continue;
}
if (DRY) {
report.push([`#${topic.number}`, topic.title, progress, "would close"]);
continue;
}
await gh.closeIssue(
topic.number,
`Core set complete — all ${core.all.length} core problems closed ` +
`(${core.all.map((n) => `#${n}`).join(", ")}).\n\n` +
"Closed automatically by `close-topics`. Optional and deferred problems " +
"stay open as extra credit.",
);
report.push([`#${topic.number}`, topic.title, progress, "closed"]);
closed++;
}
// ── report ───────────────────────────────────────────────────────
const rows = [["topic", "title", "core", "status"], ...report];
printTable(rows);
const finished = report.filter((r) => r[3] === "closed" || r[3] === "would close");
console.log(
`\n${topics.length} open topics · ` +
`${DRY ? `would close ${finished.length}` : `closed ${closed}`}`,
);
await writeStepSummary(
`### close-topics${DRY ? " (dry run)" : ""}\n\n` +
`${topics.length} open topic issues, ${finished.length} with a complete core set.\n\n` +
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\n"),
);
+235
View File
@@ -0,0 +1,235 @@
/**
* Local LeetCode problem cache backed by SQLite at
* ~/.local/share/leetcode/data.db (XDG_DATA_HOME honored).
*
* One `problems` table, two freshness rules:
* - the full problem index (id / title / slug / difficulty / paid flag) is
* refreshed from leetcode.com at most once per 24h (`meta.index_fetched_at`);
* - descriptions (HTML from GraphQL `question.content`) are fetched once per
* slug and kept forever. Only the campaign problems linked from README.md
* are ever fetched, and paid-only problems are skipped because GraphQL
* returns null content for them.
*
* A `solutions` table tracks the lifecycle of each work/ file by LC number:
* `created_at` (backfilled from the file's birthtime on every picker run),
* `last_tested` and `last_submitted` (stamped on exit 0 of leetcode-cli).
* The test picker sorts by last_tested, the submit picker by created_at, and
* submit hides anything with a last_submitted.
*
* Library: side-effect-free on import — nothing is opened or fetched until
* problemDb() is called. Index upserts never touch the description column, so
* a daily refresh cannot evict cached statements.
*/
import { Database } from "bun:sqlite";
import { mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
export interface Problem {
id: number;
title: string;
slug: string;
difficulty: "Easy" | "Medium" | "Hard";
paidOnly: boolean;
}
/** Lifecycle of one work/ solution, keyed by LC number. ISO timestamps. */
export interface SolutionActivity {
createdAt: string;
lastTested: string | null;
lastSubmitted: string | null;
}
const DIFFICULTY = ["", "Easy", "Medium", "Hard"] as const;
const INDEX_TTL_MS = 24 * 60 * 60 * 1000;
export const DB_PATH = join(
process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"),
"leetcode",
"data.db",
);
// ── README parsing ───────────────────────────────────────────────
/** Campaign problem slugs linked from README.md, in order, deduplicated. */
export function readmeSlugs(markdown: string): string[] {
const slugs = new Set<string>();
for (const m of markdown.matchAll(/https:\/\/leetcode\.com\/problems\/([\w-]+)\//g)) {
slugs.add(m[1]!);
}
return [...slugs];
}
// ── remote fetches ───────────────────────────────────────────────
async function fetchIndex(): Promise<Problem[]> {
const res = await fetch("https://leetcode.com/api/problems/all/", {
headers: { "user-agent": "Mozilla/5.0" },
});
if (!res.ok) throw new Error(`GET /api/problems/all -> ${res.status} ${res.statusText}`);
const data = (await res.json()) as {
stat_status_pairs: Array<{
stat: {
frontend_question_id: number;
question__title: string;
question__title_slug: string;
};
difficulty: { level: 1 | 2 | 3 };
paid_only: boolean;
}>;
};
return data.stat_status_pairs
.map((p) => ({
id: p.stat.frontend_question_id,
title: p.stat.question__title,
slug: p.stat.question__title_slug,
difficulty: DIFFICULTY[p.difficulty.level] as Problem["difficulty"],
paidOnly: p.paid_only,
}))
.sort((a, b) => a.id - b.id);
}
/** HTML problem statement, or null when LeetCode withholds it (paid-only). */
export async function fetchDescription(slug: string): Promise<string | null> {
const res = await fetch("https://leetcode.com/graphql", {
method: "POST",
headers: {
"content-type": "application/json",
"user-agent": "Mozilla/5.0",
referer: `https://leetcode.com/problems/${slug}/`,
},
body: JSON.stringify({
query: "query questionContent($titleSlug: String!) { question(titleSlug: $titleSlug) { content } }",
variables: { titleSlug: slug },
}),
});
if (!res.ok) throw new Error(`POST /graphql (${slug}) -> ${res.status} ${res.statusText}`);
const data = (await res.json()) as { data?: { question?: { content?: string | null } | null } };
return data.data?.question?.content ?? null;
}
// ── the cache ────────────────────────────────────────────────────
export function problemDb(path = DB_PATH) {
mkdirSync(dirname(path), { recursive: true });
const db = new Database(path);
db.exec(`
CREATE TABLE IF NOT EXISTS problems (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
difficulty TEXT NOT NULL,
paid_only INTEGER NOT NULL,
description TEXT
);
CREATE TABLE IF NOT EXISTS solutions (
id INTEGER PRIMARY KEY,
created_at TEXT NOT NULL,
last_tested TEXT,
last_submitted TEXT
);
DROP TABLE IF EXISTS submissions;
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
const getMeta = db.query<{ value: string }, [string]>("SELECT value FROM meta WHERE key = ?1");
const setMeta = db.query<never, [string, string]>(
"INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
);
const upsert = db.query<never, [number, string, string, string, number]>(
`INSERT INTO problems (id, title, slug, difficulty, paid_only)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title, slug = excluded.slug,
difficulty = excluded.difficulty, paid_only = excluded.paid_only`,
);
const descState = db.query<{ paid_only: number; described: number }, [string]>(
"SELECT paid_only, description IS NOT NULL AS described FROM problems WHERE slug = ?1",
);
return {
path,
/** True when the index has never been fetched or is older than 24h. */
indexStale(): boolean {
const row = getMeta.get("index_fetched_at");
return !row || Date.now() - Number(row.value) > INDEX_TTL_MS;
},
/** Pull the full index from leetcode.com and upsert it. Returns the count. */
async refreshIndex(): Promise<number> {
const problems = await fetchIndex();
db.transaction(() => {
for (const p of problems) upsert.run(p.id, p.title, p.slug, p.difficulty, p.paidOnly ? 1 : 0);
setMeta.run("index_fetched_at", String(Date.now()));
})();
return problems.length;
},
/** Every cached problem, ordered by LC number. */
all(): Problem[] {
const rows = db
.query<{ id: number; title: string; slug: string; difficulty: string; paid_only: number }, []>(
"SELECT id, title, slug, difficulty, paid_only FROM problems ORDER BY id",
)
.all();
return rows.map((r) => ({
id: r.id,
title: r.title,
slug: r.slug,
difficulty: r.difficulty as Problem["difficulty"],
paidOnly: r.paid_only === 1,
}));
},
/** Free (non-premium) slugs among `slugs` with no cached description yet. */
missingDescriptions(slugs: string[]): string[] {
return slugs.filter((slug) => {
const row = descState.get(slug);
return row !== null && !row.paid_only && !row.described;
});
},
saveDescription(slug: string, html: string): void {
db.query("UPDATE problems SET description = ?2 WHERE slug = ?1").run(slug, html);
},
/** Per-solution lifecycle rows keyed by LC number. */
solutionActivity(): Map<number, SolutionActivity> {
const rows = db
.query<{ id: number; created_at: string; last_tested: string | null; last_submitted: string | null }, []>(
"SELECT id, created_at, last_tested, last_submitted FROM solutions",
)
.all();
return new Map(
rows.map((r) => [r.id, { createdAt: r.created_at, lastTested: r.last_tested, lastSubmitted: r.last_submitted }]),
);
},
/** Register a solution's creation time; existing rows are left alone. */
ensureSolution(id: number, createdAt: string): void {
db.query("INSERT INTO solutions (id, created_at) VALUES (?1, ?2) ON CONFLICT(id) DO NOTHING").run(id, createdAt);
},
markTested(id: number): void {
db.query(
`INSERT INTO solutions (id, created_at, last_tested) VALUES (?1, ?2, ?2)
ON CONFLICT(id) DO UPDATE SET last_tested = excluded.last_tested`,
).run(id, new Date().toISOString());
},
markSubmitted(id: number): void {
db.query(
`INSERT INTO solutions (id, created_at, last_submitted) VALUES (?1, ?2, ?2)
ON CONFLICT(id) DO UPDATE SET last_submitted = excluded.last_submitted`,
).run(id, new Date().toISOString());
},
close(): void {
db.close();
},
};
}
+113
View File
@@ -0,0 +1,113 @@
/**
* GitHub REST + GraphQL plumbing shared by the issue reconcilers (close-solved,
* close-topics) and the Project field reconciler (sync-project-fields).
*
* Importing this module is side-effect free — nothing resolves credentials or
* touches the network until github() is awaited — so a script can import it
* without inheriting another script's pipeline.
*
* Repo: GITHUB_REPOSITORY, else the `origin` remote.
* Auth: GH_TOKEN / GITHUB_TOKEN, else `gh auth token`.
*/
import { $ } from "bun";
import { join } from "node:path";
const ROOT = join(import.meta.dir, "..", "..");
/** Page size used for every list endpoint; also the "more pages" threshold. */
const PER_PAGE = 100;
export interface GitHub {
/** `owner/name`. */
repo: string;
/** One authenticated request against api.github.com; throws on non-2xx. */
api(path: string, init?: RequestInit): Promise<unknown>;
/** One GraphQL call (Projects v2 lives here); throws on transport or query errors. */
graphql(query: string, variables?: Record<string, unknown>): Promise<unknown>;
/** Every page of a list endpoint, flattened into one stream of elements. */
list(path: string): AsyncGenerator<unknown, void, void>;
/** Comment on an issue, then close it as completed. */
closeIssue(issue: number, comment: string): Promise<void>;
}
async function resolveRepo(): Promise<string> {
if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY;
const url = (await $`git -C ${ROOT} remote get-url origin`.text()).trim();
const m = url.match(/github\.com[:/](.+?)(?:\.git)?$/);
if (!m) throw new Error(`cannot derive owner/repo from remote: ${url}`);
return m[1]!;
}
async function resolveToken(): Promise<string> {
const env = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
if (env) return env;
const token = (await $`gh auth token`.text()).trim();
if (!token) throw new Error("no credentials: set GH_TOKEN or run `gh auth login`");
return token;
}
export async function github(): Promise<GitHub> {
const repo = await resolveRepo();
const token = await resolveToken();
async function api(path: string, init: RequestInit = {}): Promise<unknown> {
const res = await fetch(`https://api.github.com${path}`, {
...init,
headers: {
accept: "application/vnd.github+json",
authorization: `Bearer ${token}`,
"x-github-api-version": "2022-11-28",
...(init.body ? { "content-type": "application/json" } : {}),
},
});
if (!res.ok) {
throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`);
}
return res.json();
}
async function* list(path: string): AsyncGenerator<unknown, void, void> {
const sep = path.includes("?") ? "&" : "?";
for (let page = 1; ; page++) {
const batch = await api(`${path}${sep}per_page=${PER_PAGE}&page=${page}`);
if (!Array.isArray(batch)) throw new Error(`unexpected ${path} payload: not an array`);
yield* batch;
if (batch.length < PER_PAGE) return;
}
}
async function graphql(
query: string,
variables: Record<string, unknown> = {},
): Promise<unknown> {
const res = await fetch("https://api.github.com/graphql", {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; "));
return payload.data;
}
/**
* Comment before closing: if the PATCH fails, the issue still carries a
* visible note of what the automation decided, instead of failing silently.
*/
async function closeIssue(issue: number, comment: string): Promise<void> {
await api(`/repos/${repo}/issues/${issue}/comments`, {
method: "POST",
body: JSON.stringify({ body: comment }),
});
await api(`/repos/${repo}/issues/${issue}`, {
method: "PATCH",
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
});
}
return { repo, api, graphql, list, closeIssue };
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "cli",
"private": true,
"type": "module",
"scripts": {
"pick": "bun ./pick.ts",
"stats": "bun ./stats.ts",
"test": "bun ./test.ts",
"submit": "bun ./submit.ts",
"sync": "bun ./sync.ts",
"videos": "bun ./videos.ts",
"close-solved": "bun ./close-solved.ts",
"close-topics": "bun ./close-topics.ts",
"release-topics": "bun ./release-topics.ts"
},
"devDependencies": {
"@clack/prompts": "^1.7.0",
"@night-slayer18/leetcode-cli": "^3.5.2",
"@types/bun": "^1.3.14"
}
}
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env bun
/**
* Fuzzy-pick a problem and scaffold it via leetcode-cli, in one of the three
* work/ buckets (see work.ts): a first solve under work/1, or the 3-day and
* 7-day blind re-solves under work/3 and work/7. Tab cycles the sections.
*
* The problem index lives in the local SQLite cache (see db.ts), refreshed at
* most once per 24h; campaign problem descriptions from README.md are
* backfilled into the same db on the way. The new-problem section hides
* anything that already has a numbered file under work/1 — re-picking it would
* only clobber the existing scaffold — and each review section hides whatever
* its own bucket already holds, so a finished re-solve leaves the list.
*/
import { autocomplete, cancel, isCancel, log, spinner } from "@clack/prompts";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { styleText } from "node:util";
import { etDate } from "../api/src/srs.ts";
import { fetchDescription, problemDb, readmeSlugs } from "./db";
import { ROOT, WINDOWS, bucketDir, reviewQueues, scanBucket, type Bucket } from "./work";
const db = problemDb();
// ── problem index (24h TTL) ──────────────────────────────────────
if (db.indexStale()) {
const s = spinner();
s.start("Fetching problem index from leetcode.com");
try {
s.stop(`Indexed ${await db.refreshIndex()} problems`);
} catch (err) {
if (db.all().length === 0) {
s.error(`Fetch failed: ${err}`);
process.exit(1);
}
s.stop(`Index refresh failed (${err}) — using cached copy`);
}
}
// ── description backfill for the campaign problems in README.md ──
const missing = db.missingDescriptions(readmeSlugs(await Bun.file(join(ROOT, "README.md")).text()));
if (missing.length > 0) {
const s = spinner();
s.start(`Caching ${missing.length} problem descriptions`);
let fetched = 0;
let failed = 0;
await Promise.all(
Array.from({ length: 6 }, async () => {
for (let slug = missing.shift(); slug; slug = missing.shift()) {
try {
const html = await fetchDescription(slug);
if (html !== null) db.saveDescription(slug, html);
s.message(`Caching problem descriptions (${++fetched})`);
} catch {
failed++; // left NULL — retried on the next run
}
}
}),
);
s.stop(failed ? `Cached ${fetched} descriptions (${failed} failed, will retry next run)` : `Cached ${fetched} problem descriptions`);
}
// ── the three sections ───────────────────────────────────────────
/** What Enter scaffolds: this problem, into this bucket. */
interface Choice {
bucket: Bucket;
lc: number;
slug: string;
}
interface Section {
label: string;
options: { value: Choice; label: string; hint: string }[];
}
const first = scanBucket(1);
const problems = db.all();
const titles = new Map(problems.map((p) => [p.id, p.title] as const));
db.close();
const queues = reviewQueues(etDate(new Date()));
const sections: Section[] = [
{
label: "new",
options: problems
.filter((p) => !first.has(p.id))
.map((p) => ({
value: { bucket: 1 as Bucket, lc: p.id, slug: p.slug },
label: `${p.id}. ${p.title}`,
hint: p.paidOnly ? `${p.difficulty} 🔒 premium` : p.difficulty,
})),
},
...WINDOWS.map((window) => ({
label: `${window}-day`,
options: queues.get(window)!.map((owed) => ({
value: { bucket: window as Bucket, lc: owed.lc, slug: owed.slug },
label: `${owed.lc}. ${titles.get(owed.lc) ?? owed.slug}`,
hint: `${owed.difficulty} · ${owed.category} · solved ${owed.solved}, ${owed.days}d ago`,
})),
})),
];
let active = 0;
/**
* The slice of clack's AutocompletePrompt this picker drives. @clack/prompts
* re-exports neither the class nor its type, and apps/cli does not depend on
* @clack/core directly, so the contract is spelled out structurally.
*/
interface Autocomplete {
userInput: string;
on(event: "key", cb: (char: string | undefined, key: { name?: string }) => void): void;
emit(event: "userInput", input: string): void;
}
/** Search text that cannot match any option — see refresh() below. */
const NO_MATCH = "\u0000";
let hooked = false;
/**
* Bind Tab to the section switch, once per prompt.
*
* clack has no hook for extra keys, but it calls `options` with the prompt as
* `this`, which is all a subscription needs. Switching sections also has to
* re-filter the list, and the prompt only recomputes `filteredOptions` when
* the search text *changes* — hence the pair of userInput events: an
* unmatchable string, then the real one, which recomputes against the new
* section, resets the cursor onto its first match and leaves the typed query
* on screen untouched. clack repaints as soon as the key handlers return.
*
* No `placeholder` is passed to the prompt on purpose: clack's own Tab binding
* (fill the input with the placeholder) is inert without one.
*/
function hookTab(prompt: Autocomplete): void {
if (hooked) return;
hooked = true;
prompt.on("key", (_char, key) => {
if (key.name !== "tab") return;
active = (active + 1) % sections.length;
prompt.emit("userInput", NO_MATCH);
prompt.emit("userInput", prompt.userInput);
});
}
// Widened, not asserted: clack resolves `undefined` when Enter lands on an
// empty list, which its own types do not admit.
const picked: Choice | symbol | undefined = await autocomplete<Choice>({
// Getter, not a string: clack reads `message` on every repaint, so the tab
// bar tracks the active section.
get message(): string {
const bar = sections
.map((section, i) => {
const text = `${section.label} (${section.options.length})`;
return i === active ? styleText("cyan", `▸ ${text}`) : styleText("dim", text);
})
.join(styleText("dim", " · "));
return `Pick a problem ${bar} ${styleText("dim", "Tab: next section")}`;
},
maxItems: 12,
options(this: Autocomplete) {
hookTab(this);
return sections[active]!.options;
},
filter: (search, option) => {
const haystack = option.label!.toLowerCase();
return search
.toLowerCase()
.split(/\s+/)
.every((token) => haystack.includes(token));
},
});
if (isCancel(picked)) {
cancel("Nothing picked.");
process.exit(0);
}
if (picked === undefined) {
// Enter on an empty section: nothing was focused, so there is nothing to do.
cancel(`Nothing owed in the ${sections[active]!.label} section.`);
process.exit(0);
}
// ── scaffold into the picked bucket ──────────────────────────────
// The active workspace names the config file; everything else about it belongs
// to leetcode-cli.
const registry = join(homedir(), ".leetcode", "workspaces.json");
let workspace = "default";
if (existsSync(registry)) {
const parsed: unknown = JSON.parse(readFileSync(registry, "utf8"));
if (parsed && typeof parsed === "object" && "active" in parsed && typeof parsed.active === "string") {
workspace = parsed.active || workspace;
}
}
const configPath = join(homedir(), ".leetcode", "workspaces", workspace, "config.json");
if (!existsSync(configPath)) {
log.error(`No leetcode-cli config at ${configPath} — run \`leetcode config -i\` once.`);
process.exit(1);
}
/**
* Point leetcode-cli at one bucket. It reads its output directory from the
* active workspace config and from nowhere else — no flag, no environment
* override — so `workDir` is the whole handover.
*
* Each write MERGES the file as it is on disk at that moment; a saved copy is
* never played back. The CLI owns this file too (theme, language, whatever a
* `leetcode login` adds), and replaying stale bytes over it would silently
* undo what it wrote while the child ran. After the pick, `workDir` is set to
* work/1 instead of to whatever it was before: the first-solve bucket is the
* one resting value that is always right, so even a killed run leaves a bare
* `leetcode pick` on-layout.
*/
function setWorkDir(target: string): void {
const raw: unknown = JSON.parse(readFileSync(configPath, "utf8"));
if (raw === null || typeof raw !== "object") {
log.error(`${configPath} holds no JSON object — run \`leetcode config -i\` to rewrite it.`);
process.exit(1);
}
// 2-space JSON with a trailing newline: leetcode-cli's own format.
writeFileSync(configPath, `${JSON.stringify({ ...raw, workDir: target }, null, 2)}\n`);
}
const dir = bucketDir(picked.bucket);
mkdirSync(dir, { recursive: true });
setWorkDir(dir);
let code: number;
try {
const proc = Bun.spawn(
// bun installs workspace-dep bins into this package's node_modules, not the root's
[join(import.meta.dir, "node_modules", ".bin", "leetcode"), "pick", picked.slug],
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
);
code = await proc.exited;
} finally {
setWorkDir(bucketDir(1));
}
// leetcode-cli exits 0 even when it wrote nothing (an expired session only
// prints a warning), so the scaffold itself is the acceptance test: without a
// file for this problem in the bucket, the pick failed.
if (!scanBucket(picked.bucket).has(picked.lc)) {
log.error(
`leetcode-cli scaffolded nothing for LC ${picked.lc} under work/${picked.bucket}/.\n` +
"If it printed “Session expired”, run `leetcode login` and pick again.",
);
process.exit(code === 0 ? 1 : code);
}
process.exit(code);
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env bun
/**
* Publish one GitHub release per finished topic, versioned by timever
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668).
*
* "Finished" is topics.ts's rule — every core sub-issue closed — the same one
* close-topics.ts closes the topic issue on, and the version is the instant
* the LAST core problem closed. Keying off the sub-issues rather than the
* topic issue lets this run as the tail of Close Solved: the push that closes
* a topic's final core problem releases it in the same run, without waiting
* for close-topics to chain after it. The topic issue itself closes seconds
* later, on the same day.
*
* Each release is a changelog snapshot at that instant: the topic's problem
* sub-issues grouped as closed on the finish day (ET), closed earlier, and
* open at release, each with its set, difficulty, and the solution files as
* they stood in the tagged commit — the last main commit before the finish.
*
* The tag is annotated and backdated (tagger date = finish), so
* `git tag --sort=taggerdate` reads as the campaign's history even for tags
* minted by a backfill. The release's own published date cannot be backdated
* (the API has no field for it), and the docs changelog (githubReleases in
* apps/docs/blume.config.ts) dates entries by it — so a backfilled entry
* carries its real finish time in the version and the body's first sentence.
*
* Reconciles instead of reacting: every finished topic without a release gets
* one, so a first run backfills the whole campaign; an existing release keeps
* its tag and commit and only has its body rewritten when render() would say
* something else. Re-runs with nothing new are no-ops.
* A release is found by the marker comment in its body, never by tag name, so
* reopening and re-closing a core problem can never mint a second release.
* A tag that exists without its release (a run that died between the two) is
* reused, not re-created.
*
* bun apps/cli/release-topics.ts # tag, push, release, fix bodies
* bun apps/cli/release-topics.ts --dry-run # print the would-be releases
*
* Auth: GH_TOKEN / GITHUB_TOKEN, else `gh auth token` (needs contents: write).
* Git: a clone with full history, an `origin` it may push tags to, and a
* committer identity (the tagger).
*/
import { etDate } from "../api/src/srs.ts";
import { github } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
import { coreSet, labelValue, readProblems, type Problem } from "./topics.ts";
import { ROOT, parseWorkPath } from "./work.ts";
const DRY =
process.argv.includes("--dry-run") ||
process.env.DRY_RUN === "1" ||
process.env.DRY_RUN === "true";
/** The docs site (apps/docs/blume.config.ts `deployment.site`); sync publishes `/<num>` short links. */
const DOCS_SITE = "https://lc.prd.lk";
const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668";
const gh = await github();
function git(...args: string[]): string {
const run = Bun.spawnSync(["git", ...args], { cwd: ROOT });
if (run.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${run.stderr.toString().trim()}`);
return run.stdout.toString().trim();
}
// ── topics ───────────────────────────────────────────────────────
interface Topic {
number: number;
title: string;
milestone: string | null;
body: string;
}
/** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */
function readTopic(value: unknown): Topic | undefined {
if (!value || typeof value !== "object") return;
if ("pull_request" in value) return;
if (!("number" in value) || typeof value.number !== "number") return;
if (!("title" in value) || typeof value.title !== "string") return;
const milestone =
"milestone" in value &&
value.milestone &&
typeof value.milestone === "object" &&
"title" in value.milestone &&
typeof value.milestone.title === "string"
? value.milestone.title
: null;
const body = "body" in value && typeof value.body === "string" ? value.body : "";
return { number: value.number, title: value.title, milestone, body };
}
interface Finished {
topic: Topic;
problems: Problem[];
core: number[];
finishedAt: string;
}
// Closed and open topics alike: the rule reads the sub-issues, so a topic
// finished moments ago (close-topics not yet run) releases now.
const finished: Finished[] = [];
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
const topic = readTopic(raw);
if (!topic) continue;
const problems = await readProblems(gh, topic.number);
const core = coreSet(problems);
if (core.finishedAt) finished.push({ topic, problems, core: core.all, finishedAt: core.finishedAt });
}
// Oldest first, so a backfill creates releases in campaign order.
finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.topic.number - b.topic.number);
// ── existing releases ────────────────────────────────────────────
/** Topic -> its release. The body is kept in sync with render() on every run. */
const released = new Map<number, { id: number; tag: string; body: string }>();
for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) {
if (!raw || typeof raw !== "object") continue;
if (!("id" in raw) || typeof raw.id !== "number") continue;
if (!("tag_name" in raw) || typeof raw.tag_name !== "string") continue;
if (!("body" in raw) || typeof raw.body !== "string") continue;
const m = raw.body.match(/<!-- release-topics: #(\d+) -->/);
if (m) released.set(Number(m[1]), { id: raw.id, tag: raw.tag_name, body: raw.body });
}
// ── timever ──────────────────────────────────────────────────────
/**
* Timever of an instant. The API reports whole seconds, so SUBTIME is the
* tiebreak: two topics finishing in the same second (one push closing both
* last core problems) get consecutive nanoseconds, in (instant, topic) order.
*/
const tags = new Map<number, string>(); // topic -> tag
{
let prev = "";
let ns = 0;
for (const f of finished) {
const iso = new Date(f.finishedAt).toISOString(); // 2026-09-24T18:36:38.000Z
const base = `${iso.slice(0, 10).replaceAll("-", "")}.${iso.slice(11, 19).replaceAll(":", "")}`;
ns = base === prev ? ns + 1 : Number(iso.slice(20, 23)) * 1_000_000;
prev = base;
tags.set(f.topic.number, `${base}.${String(ns).padStart(9, "0")}`);
}
}
// ── release body ─────────────────────────────────────────────────
const ET_WHEN = new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
const ET_DAY = new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
month: "short",
day: "numeric",
});
/** Topic body sections worth carrying into the changelog (skipped while still `TODO`). */
const NOTES = ["Trigger", "Invariant", "Trap"] as const;
function bodyField(body: string, field: string): string | undefined {
return body.match(new RegExp(`^\\*\\*${field}:\\*\\*\\s*(.+)$`, "m"))?.[1]?.trim();
}
function bodySection(body: string, heading: string): string | undefined {
const m = body.match(new RegExp(`^### ${heading}\\n([\\s\\S]*?)(?=^### |(?![\\s\\S]))`, "m"));
const text = m?.[1]?.trim();
return text && text !== "TODO" ? text : undefined;
}
function render(f: Finished, tag: string, sha: string, files: Map<number, string[]>): string {
const { topic, problems } = f;
const finishDay = etDate(new Date(f.finishedAt));
// Snapshot at the finish instant: a problem closed later was open then.
const closedBy = (p: Problem) => p.closedAt !== null && p.closedAt <= f.finishedAt;
const onDay = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) === finishDay);
const earlier = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) !== finishDay);
const open = problems.filter((p) => !closedBy(p));
const row = (p: Problem): string[] => {
const lc = Number(p.title.match(/^LC (\d+)/)?.[1]);
const code = closedBy(p)
? (files.get(lc) ?? []).map(
(path) => `[${path.slice(path.lastIndexOf(".") + 1)}](https://github.com/${gh.repo}/blob/${tag}/${encodeURI(path)})`,
)
: [];
return [
`#${p.number}`,
p.title,
labelValue(p, "set:") ?? "—",
labelValue(p, "diff:") ?? "—",
closedBy(p) ? ET_DAY.format(new Date(p.closedAt!)) : "—",
code.length ? [...code, `[docs](${DOCS_SITE}/${lc})`].join(" · ") : "—",
];
};
const table = (list: Problem[]) =>
markdownTable([["Issue", "Problem", "Set", "Difficulty", "Closed (ET)", "Solution"], ...list.map(row)]);
// Blume's changelog derives each page's meta description from the opening
// prose, stripping markdown and line breaks without adding a space — so lead
// with one self-contained sentence and keep the fields on one `·` line.
const when = ET_WHEN.format(new Date(f.finishedAt));
const summary =
`Finished ${when} ET: all ${f.core.length} core problems closed, ` +
`${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`;
const fields: string[] = [];
const scheduled = bodyField(topic.body, "Scheduled");
if (scheduled) fields.push(`**Scheduled:** ${scheduled}`);
if (topic.milestone) fields.push(`**Phase:** ${topic.milestone}`);
fields.push(`**Topic:** #${topic.number}`);
const buildsOn = bodyField(topic.body, "Builds on");
if (buildsOn) fields.push(`**Builds on:** ${buildsOn}`);
// The marker is how a later run finds this release; see the file header.
const sections = [`<!-- release-topics: #${topic.number} -->\n${summary}`, fields.join(" · ")];
sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the topic finished\n\n${table(onDay)}`);
if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`);
if (open.length) sections.push(`### Open at release\n\n${table(open)}`);
const notes = NOTES.flatMap((h) => {
const text = bodySection(topic.body, h);
return text ? [`**${h}.** ${text}`] : [];
});
if (notes.length) sections.push(`### Notes\n\n${notes.join("\n\n")}`);
sections.push(
`---\n\n\`${tag}\` is [timever](${TIMEVER}) for the UTC instant the core set completed; ` +
`it tags ${sha.slice(0, 7)}, the last main commit before it.`,
);
return `${sections.join("\n\n")}\n`;
}
// ── reconcile ────────────────────────────────────────────────────
const latest = finished.at(-1)?.topic.number;
const report: string[][] = [];
const previews: string[] = [];
let created = 0;
let updated = 0;
for (const f of finished) {
const { topic } = f;
const finishedEt = etDate(new Date(f.finishedAt));
const existing = released.get(topic.number);
// An existing release keeps its tag and commit; only its body is reconciled.
const tag = existing?.tag ?? tags.get(topic.number)!;
const sha = existing
? git("rev-parse", `${tag}^{commit}`)
: git("rev-list", "-1", "--first-parent", `--before=${f.finishedAt}`, "HEAD");
if (!sha) {
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "ERROR: no commit before finish"]);
continue;
}
// LC number -> its first-solve files in the tagged tree. Keyed by file name,
// like firstSolved(): tags before the bucket split see `work/<Difficulty>/…`,
// and the work/3 + work/7 re-solves are not the solve that closed the issue.
const files = new Map<number, string[]>();
for (const path of git("ls-tree", "-r", "--name-only", sha, "--", "work").split("\n")) {
const bucket = parseWorkPath(path.slice("work/".length))?.bucket;
const lc = path.match(/\/(\d+)\.[^/]+\.(?:js|py)$/)?.[1];
if (!lc || (bucket && bucket !== 1)) continue;
files.set(Number(lc), [...(files.get(Number(lc)) ?? []), path]);
}
const body = render(f, tag, sha, files);
if (existing) {
if (existing.body === body) {
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "released"]);
} else if (DRY) {
previews.push(`## ${tag} — ${topic.title}\n\n${body}`);
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "would update"]);
} else {
await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, {
method: "PATCH",
body: JSON.stringify({ body }),
});
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "updated"]);
updated++;
}
continue;
}
if (DRY) {
previews.push(`## ${tag} — ${topic.title}\n\n${body}`);
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "would release"]);
continue;
}
const remote = git("ls-remote", "--tags", "origin", `refs/tags/${tag}`);
if (!remote) {
const local = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT });
if (local.exitCode !== 0) {
const tagged = Bun.spawnSync(
["git", "tag", "-a", tag, sha, "-m", `${topic.title} (#${topic.number})\n\nCore set complete: ${f.core.map((n) => `#${n}`).join(", ")}`],
{ cwd: ROOT, env: { ...process.env, GIT_COMMITTER_DATE: f.finishedAt } },
);
if (tagged.exitCode !== 0) throw new Error(`git tag ${tag}: ${tagged.stderr.toString().trim()}`);
}
git("push", "origin", `refs/tags/${tag}`);
}
await gh.api(`/repos/${gh.repo}/releases`, {
method: "POST",
body: JSON.stringify({
tag_name: tag,
name: topic.title,
body,
make_latest: topic.number === latest ? "true" : "false",
}),
});
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "released now"]);
created++;
}
// ── report ───────────────────────────────────────────────────────
if (previews.length) console.log(`${previews.join("\n")}\n`);
const rows = [["topic", "title", "finished (ET)", "tag", "status"], ...report];
printTable(rows);
const would = (status: string) => report.filter((r) => r[4] === status).length;
console.log(
`\n${finished.length} finished topics · ` +
(DRY
? `would release ${would("would release")}, update ${would("would update")}`
: `released ${created}, updated ${updated}`),
);
await writeStepSummary(
`### release-topics${DRY ? " (dry run)" : ""}\n\n` +
`${finished.length} finished topics, ${released.size} already released.\n\n` +
(report.length ? `${markdownTable(rows)}\n` : "No finished topics.\n") +
(previews.length ? `\n${previews.join("\n")}` : ""),
);
+37
View File
@@ -0,0 +1,37 @@
/**
* Console + GitHub Actions reporting shared by the issue reconcilers.
*
* Both reconcilers end the same way: an aligned table on stdout, and the same
* table as Markdown in the step summary when running under Actions.
*/
/** Print rows[0] as a header, a rule, then the body — every column padded. */
export function printTable(rows: string[][]): void {
const widths = rows[0]!.map((_, i) => Math.max(...rows.map((r) => r[i]!.length)));
const render = (r: string[]) =>
r
.map((cell, i) => cell.padEnd(widths[i]!))
.join(" ")
.trimEnd();
console.log(render(rows[0]!));
console.log(widths.map((n) => "─".repeat(n)).join(" "));
for (const row of rows.slice(1)) console.log(render(row));
}
/** Same rows as a GitHub-flavoured Markdown table; rows[0] is the header. */
export function markdownTable(rows: string[][]): string {
const [header, ...body] = rows;
return [
`| ${header!.join(" | ")} |`,
`| ${header!.map(() => "---").join(" | ")} |`,
...body.map((r) => `| ${r.join(" | ")} |`),
].join("\n");
}
/** Append to the Actions step summary; a no-op outside Actions. */
export async function writeStepSummary(markdown: string): Promise<void> {
const path = process.env.GITHUB_STEP_SUMMARY;
if (!path) return;
await Bun.write(path, markdown);
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Shared work/ solution picker for the test and submit entries: fuzzy-select
* a solution file, then hand it to leetcode-cli.
*
* Every bucket is listed — a 3-day or 7-day re-solve is tested and submitted
* exactly like a first solve — and the hint says which one a file belongs to.
* The db's per-solution timestamps are keyed by LC number, so the buckets of
* one problem share them.
*
* Every run backfills the db's solutions table with created_at (from the
* file's birthtime, mtime when the filesystem has none) so ordering works for
* files that predate the db. Ordering is per command — test lists the most
* recently tested first (never-tested after, by name); submit lists the most
* recently created first. Nothing is ever hidden. Exit 0 of leetcode-cli
* stamps last_tested / last_submitted.
*
* Library: side-effect-free on import — nothing scans the filesystem until
* runOnSolution() is called.
*/
import { autocomplete, cancel, isCancel, log } from "@clack/prompts";
import { statSync } from "node:fs";
import { basename, join, relative } from "node:path";
import { problemDb } from "./db";
import { ROOT, WORK as WORK_DIR, parseWorkPath } from "./work";
/** Leading LC number of a work/ file, or undefined for unnumbered names. */
function lcNumber(rel: string): number | undefined {
const m = basename(rel).match(/^(\d+)\./);
return m ? Number(m[1]) : undefined;
}
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
export async function runOnSolution(
command: "test" | "submit",
message: string,
): Promise<never> {
const db = problemDb();
const scanned = [
...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync(
{ cwd: WORK_DIR },
),
];
// register creation times so files older than the db still sort correctly
for (const f of scanned) {
const num = lcNumber(f);
if (num === undefined) continue;
const stat = statSync(join(WORK_DIR, f));
db.ensureSolution(
num,
new Date(stat.birthtimeMs || stat.mtimeMs).toISOString(),
);
}
const activity = db.solutionActivity();
// test → most recently tested first; submit → most recently created first
const sortKey = (f: string) => {
const num = lcNumber(f);
const row = num === undefined ? undefined : activity.get(num);
return (command === "test" ? row?.lastTested : row?.createdAt) ?? undefined;
};
const files = scanned.sort((a, b) => {
const ka = sortKey(a);
const kb = sortKey(b);
if (ka !== kb) {
if (ka === undefined) return 1;
if (kb === undefined) return -1;
return kb.localeCompare(ka); // ISO timestamps: lexicographic = chronological
}
return a.localeCompare(b, undefined, { numeric: true });
});
if (files.length === 0) {
log.error(
`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`,
);
process.exit(1);
}
const picked = await autocomplete<string>({
message,
placeholder: "Type to search...",
maxItems: 12,
options: files.map((f) => {
const parsed = parseWorkPath(f);
const stage =
parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`;
return {
value: f,
label: basename(f),
hint: parsed
? `${stage} · ${parsed.difficulty} · ${parsed.category}`
: f,
};
}),
filter: (search, option) => {
const haystack = option.value.toLowerCase();
return search
.toLowerCase()
.split(/\s+/)
.every((token) => haystack.includes(token));
},
});
if (isCancel(picked)) {
cancel("Nothing selected.");
process.exit(0);
}
const proc = Bun.spawn(
[
// bun installs workspace-dep bins into this package's node_modules, not the root's
join(import.meta.dir, "node_modules", ".bin", "leetcode"),
command,
join(WORK_DIR, picked),
],
{ cwd: ROOT, stdio: ["inherit", "inherit", "inherit"] },
);
const code = await proc.exited;
const num = lcNumber(picked);
if (code === 0 && num !== undefined) {
if (command === "test") db.markTested(num);
else db.markSubmitted(num);
}
db.close();
process.exit(code);
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Is a work/ file a real solution or just a scaffolded stub?
*
* `bun run pick` writes a statement header plus an empty function body, so
* file existence alone means nothing: close-solved.ts must not close an issue
* on a stub, and work.ts must not ask for the spaced-repetition re-solve of a
* problem that was never solved in the first place.
*
* sync.ts keeps its own copy of the header split — it runs its whole pipeline
* on import, so it cannot be imported from. Don't "deduplicate" that one.
*
* Library: side-effect-free on import.
*/
/** Placeholder bodies that leetcode-cli / a human leaves behind. */
const PLACEHOLDERS: Record<string, true> = { pass: true, "...": true, TODO: true };
/**
* Decide whether a work/ file contains an implementation.
*
* The header comment is dropped the same way sync.ts splitSource() does it.
* Comments must go before any brace analysis: the scaffold's JSDoc carries
* `@param {number[]}`, whose braces would otherwise read as a body.
*
* A brace-language file counts as implemented when at least one *innermost*
* brace pair holds real content. That distinguishes a bare stub
* (`function(nums) {}`) and a class-shaped design stub (every method body
* empty) from any genuine solution, whose innermost block always has code.
* Every non-Python language the campaign has ever used is brace-shaped, so
* "not .py" is the only branch that matters.
*/
export function isImplemented(src: string, lang: "js" | "py"): boolean {
if (lang === "py") {
const open = src.indexOf('"""');
const close = src.indexOf('"""', open + 3);
const code = open === -1 || close === -1 ? src : src.slice(close + 3);
for (const raw of code.split("\n")) {
const line = raw.replace(/#.*$/, "").trim();
if (!line || PLACEHOLDERS[line]) continue;
if (/^(?:@|def\s|class\s)/.test(line)) continue;
return true; // a statement inside some def body
}
return false;
}
const headerEnd = src.indexOf("*/");
const body = headerEnd === -1 ? src : src.slice(headerEnd + 2);
const code = body.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/\/\/.*$/gm, " ");
let innermost = -1;
for (let i = 0; i < code.length; i++) {
if (code[i] === "{") {
innermost = i;
} else if (code[i] === "}" && innermost !== -1) {
const inner = code.slice(innermost + 1, i).replace(/[\s;]/g, "");
if (inner && !PLACEHOLDERS[inner]) return true;
innermost = -1; // measured; the enclosing pair is not innermost
}
}
return false;
}
+384
View File
@@ -0,0 +1,384 @@
#!/usr/bin/env bun
/**
* Open today's spaced-repetition issue: whatever the SRS ladder has due,
* linked straight to LeetCode.
*
* D1 is the schedule and this script is a renderer. `POST /admin/due` returns
* exactly the rows the 8 AM digest mails — reviews standing at the `+3` and
* `+7` rungs, oldest first — so the issue and the email can never disagree
* about a day, and there is no second scheduler to drift. The rung is also
* the `work/<n>` bucket the blind re-solve belongs in, which is the section
* `bun run pick` opens on Tab.
*
* How a problem gets here: closing its issue (a push, a digest tap, a `/done`
* comment) enters it on the ladder at `+3` with a concrete next_review, and
* passing that review moves it to `+7`. Nothing is "activated" by this script
* — it asks what is standing today and writes it down. A day's issue is keyed
* by its title (`Spaced Repetition — <Month D, YYYY>`), so re-runs rewrite
* that one body instead of stacking duplicates.
*
* Sunday writes nothing: the ladder never schedules the rest day (WINDOWS of
* 3 and 7 exist for exactly that reason, see apps/api/src/srs.ts), so there is
* nothing to render. Each rung shows at most WINDOW_CAP problems, with the
* overflow listed as optional — the digest's leveller moves that overflow to
* later working days the next morning.
*
* Links: D1 stores titles, not URLs, so each due problem's issue body is read
* for the canonical LeetCode link. A body with no URL falls back to a slug
* guessed from the title and is flagged in the report, because repo titles are
* sometimes abbreviated ("LC 167 · Two Sum II") and a guess can 404.
*
* bun apps/cli/spaced-repetition.ts # create/update today's issue
* bun apps/cli/spaced-repetition.ts --dry-run # print the body, touch nothing
* bun apps/cli/spaced-repetition.ts --date=2026-09-02 # pretend it is that ET day
*
* Auth: GH_TOKEN / GITHUB_TOKEN, else falls back to `gh auth token`.
* SRS_ADMIN_KEY reads the due list; SRS_API overrides the Worker URL for
* local `wrangler dev` runs.
*/
import { WINDOWS, etDate, isRestDay } from "../api/src/srs.ts";
import { github } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
const DRY =
process.argv.includes("--dry-run") ||
process.env.DRY_RUN === "1" ||
process.env.DRY_RUN === "true";
/**
* Required re-solves per window: 3 + 3 on top of the day's 3 new core problems
* is already a 90-minute session. A window that closed more than this (the
* catalog backfill closed 19 issues in one day) keeps the rest listed under a
* collapsed "optional" block rather than pretending they were never due.
*/
const WINDOW_CAP = 3;
const LABEL = "spaced-repetition";
// ── ET calendar dates ────────────────────────────────────────────
// Every date here is an ET calendar string (YYYY-MM-DD): the campaign runs on
// ET days, and an issue closed at 21:30 ET belongs to that ET day, not to the
// next UTC one. The helpers come from the Worker's SRS domain so this script,
// the picker and D1 cannot disagree about a date, a window, or the rest day.
const LONG = new Intl.DateTimeFormat("en-US", {
timeZone: "UTC",
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
/** `2026-08-26` -> `August 26, 2026`, or with the weekday prefix. */
function longDate(date: string, weekday = false): string {
// Noon-UTC anchor, like every date in srs.ts: no DST edge can move the day.
const text = LONG.format(new Date(`${date}T12:00:00Z`));
return weekday ? text : text.slice(text.indexOf(", ") + 2);
}
// ── today ────────────────────────────────────────────────────────
// `||`, not `??`: the workflow passes SRS_TODAY="" on scheduled runs, where the
// input has no value.
const override = process.argv.find((a) => a.startsWith("--date="))?.slice(7) || process.env.SRS_TODAY;
if (override && !/^\d{4}-\d{2}-\d{2}$/.test(override)) {
throw new Error(`--date wants YYYY-MM-DD, got ${override}`);
}
const today = override || etDate(new Date());
// Sunday is the campaign's rest day and the reason the windows are 3 and 7:
// nothing is ever booked on it, so there is no issue to write. The cron still
// fires — the guard lives here, not in the workflow, so a manual run agrees.
if (isRestDay(today)) {
console.log(`${today} is the rest day — no spaced-repetition issue.`);
await writeStepSummary(`### Spaced Repetition — ${longDate(today)}\n\nRest day: nothing scheduled.\n`);
process.exit(0);
}
// ── what is due today (D1, via the Worker) ───────────────────────
// The ladder is the schedule: `POST /admin/due` returns exactly the rows the
// morning digest mails, so the issue and the email can never disagree about a
// day. This script adds no scheduling of its own — it renders, caps, and
// links.
const SRS_API = process.env.SRS_API ?? "https://srs-api.prdlk.workers.dev";
/** One problem the ladder wants re-solved today, as the Worker reports it. */
interface Due {
lc: number;
issue: number;
title: string;
difficulty: string;
/** The rung being settled: "+3" or "+7". */
stage: string;
/** The rung's number — also the `work/<n>` bucket the re-solve belongs in. */
bucket: number;
due: string;
late: number;
last_seen: string | null;
days_since: number | null;
}
function readDue(value: unknown): Due | undefined {
if (!value || typeof value !== "object") return;
const num = (key: string): number | undefined =>
key in value && typeof (value as Record<string, unknown>)[key] === "number"
? ((value as Record<string, unknown>)[key] as number)
: undefined;
const str = (key: string): string | undefined =>
key in value && typeof (value as Record<string, unknown>)[key] === "string"
? ((value as Record<string, unknown>)[key] as string)
: undefined;
const lc = num("lc");
const issue = num("issue");
const bucket = num("bucket");
const stage = str("stage");
const title = str("title");
const dueOn = str("due");
if (lc === undefined || issue === undefined || bucket === undefined) return;
if (stage === undefined || title === undefined || dueOn === undefined) return;
const difficulty = str("difficulty") ?? "unrated";
return {
lc,
issue,
title,
difficulty: difficulty.charAt(0).toUpperCase() + difficulty.slice(1),
stage,
bucket,
due: dueOn,
late: num("late") ?? 0,
last_seen: str("last_seen") ?? null,
days_since: num("days_since") ?? null,
};
}
if (!process.env.SRS_ADMIN_KEY) {
// Failing loudly beats writing an issue from a second, guessed schedule:
// there is only one schedule now, and it lives in D1.
console.error("SRS_ADMIN_KEY unset — cannot read today's due list from the Worker.");
process.exit(1);
}
const res = await fetch(`${SRS_API}/admin/due?date=${today}`, {
method: "POST",
headers: { authorization: `Bearer ${process.env.SRS_ADMIN_KEY}` },
});
if (!res.ok) throw new Error(`GET due list -> ${res.status} ${await res.text()}`);
const payload: unknown = await res.json();
const dueRaw: unknown = payload && typeof payload === "object" && "due" in payload ? payload.due : null;
const due: Due[] = (Array.isArray(dueRaw) ? dueRaw : []).flatMap((r) => {
const row = readDue(r);
return row ? [row] : [];
});
// ── repo + auth ──────────────────────────────────────────────────
const gh = await github();
// ── canonical LeetCode links ─────────────────────────────────────
const LC_URL = /https:\/\/leetcode\.com\/problems\/[a-z0-9-]+/;
/** LeetCode's own slug shape, for bodies that lost their URL line. */
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
/**
* D1 stores titles, not URLs, so each due problem's issue body is read for the
* canonical LeetCode link written by the campaign-issues convention. That is
* one request per due problem — at most REVIEW_CAP per rung — instead of the
* old walk over every closed issue in the repo. A body with no URL falls back
* to a slug guessed from the title and is flagged in the report, because repo
* titles are sometimes abbreviated ("LC 167 · Two Sum II") and a guess can 404.
*/
interface Linked extends Due {
url: string;
linked: boolean;
}
async function link(row: Due): Promise<Linked> {
let url: string | undefined;
try {
const issue: unknown = await gh.api(`/repos/${gh.repo}/issues/${row.issue}`);
const body =
issue && typeof issue === "object" && "body" in issue && typeof issue.body === "string"
? issue.body
: "";
url = LC_URL.exec(body)?.[0];
} catch (err) {
console.warn(`#${row.issue}: could not read the issue body (${err})`);
}
return {
...row,
url: `${url ?? `https://leetcode.com/problems/${slugify(row.title)}`}/`,
linked: url !== undefined,
};
}
// ── issue body ───────────────────────────────────────────────────
const title = `Spaced Repetition — ${longDate(today)}`;
/** The window's due problems, capped, oldest scheduled first. */
const byWindow = new Map<number, Linked[]>();
for (const days of WINDOWS) {
const rows = due.filter((d) => d.bucket === days);
byWindow.set(days, await Promise.all(rows.map(link)));
}
function bucket(days: number): Linked[] {
return byWindow.get(days) ?? [];
}
/** One checkbox line, straight to LeetCode. */
function line(s: Linked, box: boolean): string {
const seen = s.days_since === null ? "unlogged" : `last seen ${s.days_since}d ago`;
return (
`${box ? "- [ ] " : "- "}[LC ${s.lc} · ${s.title}](${s.url}) · ${s.difficulty} · #${s.issue}` +
` · ${seen}${s.late > 0 ? ` · ⏰ ${s.late}d late` : ""}` +
(s.linked ? "" : " · ⚠️ link guessed from title")
);
}
const sections = WINDOWS.map((days) => {
const rows = bucket(days);
const required = rows.slice(0, WINDOW_CAP);
const spill = rows.slice(WINDOW_CAP);
return [
`### +${days} days — re-solve into \`work/${days}/\``,
"",
...(required.length
? required.map((s) => line(s, true))
: [`_Nothing at the +${days} rung today._`]),
// Overflow is shown, not dropped: the ladder levels everything past the
// cap onto later working days, but until that morning's digest runs this
// issue is the only record of what was standing.
...(spill.length
? [
"",
`<details><summary>${spill.length} more at this rung — optional</summary>`,
"",
...spill.map((s) => line(s, false)),
"",
"</details>",
]
: []),
"",
].join("\n");
});
const total = WINDOWS.reduce((n, days) => n + Math.min(bucket(days).length, WINDOW_CAP), 0);
const body = [
`Re-solve from memory, ${longDate(today, true)}. Blind: no notes, no \`work/\` file, no editor history.`,
"",
"- 15 minutes per problem, then stop and read your own solution.",
"- Say the invariant and the complexity out loud before typing.",
"- Failed one? Tick it anyway and expect it back at +3.",
"- `bun run pick` → Tab to this rung's section scaffolds the blind re-solve.",
"",
...sections,
"<sub>Today's due list straight from the SRS ladder in D1 (`POST /admin/due`), rendered by " +
"`.github/workflows/spaced-repetition.yml` — re-runs on the same day rewrite this body. " +
"The ladder never schedules a Sunday, so there is no issue on the rest day.</sub>",
].join("\n");
// ── create or refresh today's issue ──────────────────────────────
interface Existing {
number: number;
title: string;
body: string;
}
function readExisting(value: unknown): Existing | undefined {
if (!value || typeof value !== "object") return;
if ("pull_request" in value) return;
if (!("number" in value) || typeof value.number !== "number") return;
if (!("title" in value) || typeof value.title !== "string") return;
const text = "body" in value && typeof value.body === "string" ? value.body : "";
return { number: value.number, title: value.title, body: text };
}
// state=all: a day's issue that was already closed must not be recreated.
let existing: Existing | undefined;
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=${LABEL}&state=all`)) {
const row = readExisting(raw);
if (row?.title === title) {
existing = row;
break;
}
}
let status: string;
if (total === 0) {
status = existing
? `nothing due — left #${existing.number} alone`
: "nothing due — no issue created";
} else if (existing && existing.body.trim() === body.trim()) {
status = `#${existing.number} already current`;
} else if (DRY) {
status = existing ? `would update #${existing.number}` : "would create";
} else if (existing) {
await gh.api(`/repos/${gh.repo}/issues/${existing.number}`, {
method: "PATCH",
body: JSON.stringify({ body }),
});
status = `updated #${existing.number}`;
} else {
// Ensure the label exists before it is used (422 = someone already made it).
try {
await gh.api(`/repos/${gh.repo}/labels`, {
method: "POST",
body: JSON.stringify({
name: LABEL,
color: "0E8A16",
description: "Daily blind re-solve of whatever the SRS ladder has due",
}),
});
} catch (err) {
if (!String(err).includes("422")) throw err;
}
const created = (await gh.api(`/repos/${gh.repo}/issues`, {
method: "POST",
body: JSON.stringify({ title, body, labels: [LABEL] }),
})) as { number: number };
status = `created #${created.number}`;
}
// ── report ───────────────────────────────────────────────────────
const report = WINDOWS.flatMap((days) =>
bucket(days).map((s, i) => [
`+${days}d`,
String(s.lc),
`#${s.issue}`,
s.difficulty,
s.due + (s.late > 0 ? ` (${s.late}d late)` : ""),
i < WINDOW_CAP ? "due" : "optional",
s.linked ? s.url : `${s.url} (inferred)`,
]),
);
const rows = [["rung", "lc", "issue", "diff", "scheduled", "state", "leetcode"], ...report];
if (report.length) printTable(rows);
console.log(
`\n${title}${DRY ? " (dry run)" : ""}\n` +
WINDOWS.map((days) => {
const n = bucket(days).length;
return `+${days}d: ${Math.min(n, WINDOW_CAP)} due${n > WINDOW_CAP ? ` (+${n - WINDOW_CAP} optional)` : ""}`;
}).join(" · ") +
`\n${status}`,
);
if (DRY) console.log(`\n${body}`);
await writeStepSummary(
`### ${title}${DRY ? " (dry run)" : ""}\n\n` +
`${total} problem${total === 1 ? "" : "s"} due · ${status}\n\n` +
(report.length ? `${markdownTable(rows)}\n` : "The ladder has nothing due today.\n"),
);
+458
View File
@@ -0,0 +1,458 @@
#!/usr/bin/env bun
/**
* `bun run stats` — the campaign dashboard, in the terminal.
*
* Four panes over ONE source: GET /api/stats. D1 owns SRS state, so this
* fetches the Worker's aggregate rather than recomputing anything from work/
* or from git — a second answer to "how far along am I" is exactly the drift
* the campaign is built to avoid. The one local fact shown is the work/ file
* inventory, which is inventory, not schedule. Unreachable Worker = an error,
* never a reconstructed guess.
*
* The panes:
* Overview campaign clock, solved counts, phase bars, recent activity
* Ladder the +3/+7 rungs and the 14-day due queue
* Concepts every topic by temperature — what has gone cold
* Activity the campaign heatmap and the weekly gates
*
* Outside a TTY (a pipe, CI) it prints every pane once and exits, so the same
* command works in a scrollback or a file.
*/
import {
fetchStats,
SRS_API,
STAGES,
TEMPERATURES,
type Stats,
type Temperature,
type Topic,
} from "./api.ts";
import {
BLUE,
FG,
GREEN,
GREENS,
LINE,
MUTED,
RED,
YELLOW,
bar,
bold,
cell,
fg,
heatCell,
openScreen,
padTo,
rule,
sparkline,
stackedBar,
width,
} from "./tui.ts";
import { BUCKETS, WINDOWS, solvedInBucket } from "./work.ts";
// ── shared vocabulary → colour ───────────────────────────────────
// Temperature is an action ramp, not a thermometer: green is warm enough to
// leave alone, red is the concept that wants revisiting. The band names carry
// the heat, the colours carry the urgency — and the glyphs carry both again,
// so a piped or NO_COLOR dashboard still separates the bands.
const TEMP_COLOR: Record<Temperature, string> = {
hot: GREENS[4]!,
fresh: GREEN,
fading: YELLOW,
cold: RED,
};
const TEMP_GLYPH: Record<Temperature, string> = {
hot: "█",
fresh: "▓",
fading: "▒",
cold: "░",
};
const DIFF_COLOR: Record<string, string> = { easy: GREEN, medium: YELLOW, hard: RED };
const LABEL = 15; // left-hand label column, shared by every pane
/** "2026-08-31" → "Mon 31", read as a plain calendar date, never localised. */
function dayLabel(iso: string): string {
const [y, m, d] = iso.split("-").map(Number);
const weekday = new Date(Date.UTC(y!, m! - 1, d!)).getUTCDay();
return `${["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][weekday]} ${String(d).padStart(2, "0")}`;
}
// ── overview ─────────────────────────────────────────────────────
function overview(s: Stats, cols: number): string[] {
const bars = Math.max(10, Math.min(48, cols - LABEL - 18));
const lines: string[] = [];
const { day, days, week } = s.campaign;
lines.push(rule("campaign", cols));
lines.push(
`${cell("Day", LABEL)}${bar(day, days, bars)} ${fg(FG, `${day}/${days}`)}` +
fg(MUTED, ` week ${week}`),
);
const streak = s.streak > 0 ? fg(GREENS[4]!, `${s.streak} day${s.streak === 1 ? "" : "s"}`) : fg(MUTED, "none");
lines.push(`${cell("Streak", LABEL)}${streak}`);
const total = s.difficulty.reduce((n, d) => n + d.total, 0);
const done = s.difficulty.reduce((n, d) => n + d.done, 0);
lines.push("");
lines.push(rule("solved", cols));
lines.push(
`${cell("All", LABEL)}${bar(done, total, bars)} ${fg(FG, `${done}/${total}`)}` +
fg(MUTED, ` ${total > 0 ? Math.round((done / total) * 100) : 0}%`),
);
for (const d of s.difficulty) {
lines.push(
`${cell(d.difficulty, LABEL)}${bar(d.done, d.total, bars, DIFF_COLOR[d.difficulty] ?? GREEN)}` +
` ${fg(MUTED, `${d.done}/${d.total}`)}`,
);
}
lines.push("");
lines.push(rule("phases", cols));
lines.push(
`${" ".repeat(LABEL)}${fg(GREEN, "█")} ${fg(MUTED, "core")} ${fg(BLUE, "█")} ${fg(MUTED, "optional")} ` +
`${fg(RED, "█")} ${fg(MUTED, "deferred")} ${fg(LINE, "░")} ${fg(MUTED, "remaining")}`,
);
// Every phase bar is scaled to the LARGEST phase, not to itself: phases are
// not the same size, and equal-length bars would hide that.
const scale = Math.max(
1,
...s.phases.map((p) => p.core_total + p.optional_total + p.deferred_total),
);
for (const p of s.phases) {
const phaseDone = p.core_done + p.optional_done + p.deferred_done;
const phaseTotal = p.core_total + p.optional_total + p.deferred_total;
const segments = [
{ value: p.core_done, color: GREEN },
{ value: p.optional_done, color: BLUE },
{ value: p.deferred_done, color: RED },
{ value: phaseTotal - phaseDone, color: LINE, glyph: "░" },
];
lines.push(
`${cell(p.name, LABEL - 1)} ${stackedBar(segments, scale, bars)} ${fg(MUTED, `${phaseDone}/${phaseTotal}`)}`,
);
}
lines.push("");
lines.push(rule("last 14 days", cols));
const window = s.heat.slice(Math.max(0, day - 14), day);
const attempts = window.reduce((n, h) => n + h.attempts, 0);
const passes = window.reduce((n, h) => n + h.passes, 0);
lines.push(
`${cell("Attempts", LABEL)}${sparkline(window.map((h) => h.attempts))} ` +
fg(MUTED, `${attempts} attempts · ${passes} passed`),
);
// The one local fact on the dashboard: solutions on disk, per
// spaced-repetition bucket. Inventory, not schedule — D1's ladder is the
// schedule, and the Ladder pane is where it is shown. Empty scaffolds do not
// count, the same rule close-solved.ts closes issues by.
lines.push("");
lines.push(rule("work/ on disk", cols));
lines.push(
cell("Solutions", LABEL) +
BUCKETS.map((b) => `${fg(FG, String(solvedInBucket(b)))} ${fg(MUTED, `in work/${b}`)}`).join(
fg(MUTED, " · "),
),
);
return lines;
}
// ── ladder ───────────────────────────────────────────────────────
function ladder(s: Stats, cols: number): string[] {
const bars = Math.max(10, Math.min(48, cols - LABEL - 18));
const lines: string[] = [rule("ladder", cols)];
lines.push(fg(MUTED, `${" ".repeat(LABEL)}the review each problem owes next`));
const rungs = STAGES.map((stage) => s.ladder[stage] ?? 0);
const peak = Math.max(1, ...rungs);
STAGES.forEach((stage, i) => {
const n = rungs[i]!;
// new is the untouched pile and retired is the finished one; only the
// middle rungs are live work, so only they get the live colour.
const color = stage === "new" ? LINE : stage === "retired" ? GREENS[4]! : GREEN;
lines.push(`${cell(stage, LABEL)}${bar(n, peak, bars, color)} ${fg(FG, String(n))}`);
});
lines.push("");
lines.push(rule("due queue", cols));
const today = s.queue[0];
lines.push(
fg(MUTED, `${" ".repeat(LABEL)}cumulative: everything the ladder wants by that day`) +
(today ? fg(FG, ` · ${today.due} due today`) : ""),
);
const depth = Math.max(1, ...s.queue.map((q) => q.due));
for (const q of s.queue) {
const color = q.due > 3 ? RED : q.due > 0 ? GREEN : LINE;
lines.push(
`${cell(dayLabel(q.date), LABEL)}${bar(q.due, depth, bars, color)} ${fg(MUTED, String(q.due))}`,
);
}
return lines;
}
// ── concepts ─────────────────────────────────────────────────────
/** Coldest first, and everything the schedule has not reached yet last. */
function byUrgency(a: Topic, b: Topic): number {
if (a.taught !== b.taught) return a.taught ? -1 : 1;
return (
TEMPERATURES.indexOf(b.temperature) - TEMPERATURES.indexOf(a.temperature) ||
(b.age ?? Infinity) - (a.age ?? Infinity) ||
a.issue - b.issue
);
}
// Row layout: "● " + name + temp + coverage bar + done/total + last + due.
// Everything but the name is fixed, so the name absorbs the terminal width.
const TEMP_W = 7;
const COVER_W = 10;
const COUNT_W = 6;
const AGE_W = 6;
const DUE_W = 3;
const CONCEPT_FIXED = 3 + (TEMP_W + 1) + (COVER_W + 1) + (COUNT_W + 1) + (AGE_W + 1) + DUE_W + 1;
function concepts(s: Stats, cols: number): string[] {
const lines: string[] = [rule("concepts by temperature", cols)];
// Thresholds are the ladder's windows; print them rather than restate them.
const bands: [Temperature, string][] = [
["hot", `≤${WINDOWS[0]}d`],
["fresh", `≤${WINDOWS[1]}d`],
["fading", `≤${WINDOWS[1] * 2}d`],
["cold", "older, or never"],
];
lines.push(
fg(MUTED, " last exercised — ") +
bands
.map(([t, when]) => `${fg(TEMP_COLOR[t], `${TEMP_GLYPH[t]} ${t}`)} ${fg(MUTED, when)}`)
.join(fg(MUTED, " · ")),
);
const taught = s.topics.filter((t) => t.taught);
const counts = TEMPERATURES.map((t) => ({
value: taught.filter((x) => x.temperature === t).length,
color: TEMP_COLOR[t],
glyph: TEMP_GLYPH[t],
}));
lines.push("");
lines.push(` ${stackedBar(counts, Math.max(1, taught.length), Math.max(10, Math.min(60, cols - 4)))}`);
lines.push(
` ${TEMPERATURES.map((t, i) => `${fg(TEMP_COLOR[t], TEMP_GLYPH[t])} ${counts[i]!.value} ${fg(MUTED, t)}`).join(" ")}` +
fg(MUTED, ` of ${taught.length} taught so far`),
);
const nameW = Math.max(12, Math.min(34, cols - CONCEPT_FIXED));
lines.push("");
lines.push(
fg(
MUTED,
` ${cell("concept", nameW)} ${cell("temp", TEMP_W)} ` +
`${cell("coverage", COVER_W + 1 + COUNT_W)} ${cell("last", AGE_W)} ${cell("due", DUE_W, "right")}`,
),
);
for (const t of s.topics.slice().sort(byUrgency)) {
// An unreached concept has no temperature to report — it shows the week
// the schedule gets to it instead, so the list stays one list.
const color = t.taught ? TEMP_COLOR[t.temperature] : LINE;
const age =
!t.taught ? "—" : t.age === null ? "never" : t.age <= 0 ? "today" : `${t.age}d`;
lines.push(
` ${fg(color, t.taught ? TEMP_GLYPH[t.temperature] : "·")} ${fg(t.taught ? FG : MUTED, cell(t.name, nameW))} ` +
`${fg(color, cell(t.taught ? t.temperature : `wk ${t.week ?? "?"}`, TEMP_W))} ` +
`${bar(t.done, t.total, COVER_W, t.taught ? GREEN : LINE)} ` +
`${fg(MUTED, cell(`${t.done}/${t.total}`, COUNT_W))} ${fg(MUTED, cell(age, AGE_W))} ` +
`${t.due > 0 ? fg(GREEN, cell(String(t.due), DUE_W, "right")) : fg(LINE, cell("·", DUE_W, "right"))}`,
);
}
return lines;
}
// ── activity ─────────────────────────────────────────────────────
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
function activity(s: Stats, cols: number): string[] {
const lines: string[] = [rule("attempts per campaign day", cols)];
lines.push(fg(MUTED, " the README heatmap, same ramp — Sunday is the rest day"));
lines.push("");
// CAMPAIGN_START is a Monday, so the grid is simply the series in weeks of
// seven: column = week, row = weekday, exactly as the SVG lays it out.
const weeks = Math.ceil(s.heat.length / 7);
lines.push(
` ${fg(MUTED, Array.from({ length: weeks }, (_, w) => ` ${String(w + 1).padStart(2)}`).join(""))}`,
);
WEEKDAYS.forEach((name, row) => {
const cells = Array.from({ length: weeks }, (_, w) => {
const entry = s.heat[w * 7 + row];
if (entry === undefined) return " ";
// The marker sits in the gutter, not inside the cell: a foreground
// escape within a background run would end the background early.
const marker = s.campaign.day - 1 === w * 7 + row ? fg(FG, "▸") : " ";
return marker + heatCell(entry.attempts, " ");
}).join("");
lines.push(` ${fg(MUTED, cell(name, 4))}${cells}`);
});
lines.push("");
lines.push(
` ${fg(MUTED, "less ")}${GREENS.map((_, level) => heatCell(level, " ")).join("")}${fg(MUTED, " more")}` +
fg(MUTED, " · one cell per day, colour = attempts, ▸ marks today"),
);
lines.push("");
lines.push(rule("weekly gates", cols));
if (s.gates.length === 0) {
lines.push(fg(MUTED, " no gate has been scored yet"));
}
const gateW = Math.max(10, Math.min(24, cols - 30));
for (const g of s.gates) {
// Gates were stored as an integer percent before they were stored as a
// fraction; both are in the table, so normalise on read.
const rate = g.pass_rate === null ? null : g.pass_rate <= 1 ? g.pass_rate : g.pass_rate / 100;
const color = rate === null ? LINE : rate >= 0.7 ? GREEN : RED;
lines.push(
` ${fg(FG, cell(`Week ${g.week}`, 9))}` +
`${bar(rate ?? 0, 1, gateW, color)}` +
` ${fg(color, cell(rate === null ? "open" : `${Math.round(rate * 100)}%`, 5))}` +
` ${fg(MUTED, g.issue === null ? "" : `#${g.issue}`)}${fg(MUTED, g.closed_on ? ` closed ${g.closed_on}` : "")}`,
);
}
return lines;
}
// ── panes ────────────────────────────────────────────────────────
const PANES: { name: string; render: (s: Stats, cols: number) => string[] }[] = [
{ name: "Overview", render: overview },
{ name: "Ladder", render: ladder },
{ name: "Concepts", render: concepts },
{ name: "Activity", render: activity },
];
// ── non-interactive: every pane, once ────────────────────────────
let stats: Stats;
try {
stats = await fetchStats();
} catch (err) {
console.error(`stats: ${err instanceof Error ? err.message : String(err)}`);
console.error(`stats: D1 is the only source of SRS truth — nothing to fall back to (SRS_API=${SRS_API}).`);
process.exit(1);
}
if (!process.stdout.isTTY) {
const cols = 80;
for (const pane of PANES) {
console.log(bold(pane.name.toUpperCase()));
for (const line of pane.render(stats, cols)) console.log(line);
console.log("");
}
process.exit(0);
}
// ── interactive ──────────────────────────────────────────────────
let pane = 0;
let offset = 0;
let note = "";
// Quitting resolves this; the finally below is what restores the terminal, so
// a thrown render never leaves the shell in raw mode on the alternate screen.
const { promise: quit, resolve: leave } = Promise.withResolvers<void>();
const screen = openScreen(onKey, draw);
function frame(): string[] {
const cols = screen.columns;
const tabs = PANES.map((p, i) =>
i === pane ? bold(fg(FG, ` ${p.name} `)) : fg(MUTED, ` ${p.name} `),
).join(fg(LINE, "│"));
const clock = fg(MUTED, `day ${stats.campaign.day}/${stats.campaign.days} · streak ${stats.streak} `);
const header = padTo(tabs, Math.max(0, cols - width(clock))) + clock;
// header + rule + body + footer fills the terminal exactly.
const body = PANES[pane]!.render(stats, cols);
const height = Math.max(1, screen.rows - 3);
offset = Math.max(0, Math.min(offset, Math.max(0, body.length - height)));
const scrolled =
body.length > height ? ` · ${offset + 1}-${Math.min(offset + height, body.length)}/${body.length}` : "";
return [
header,
fg(LINE, "─".repeat(cols)),
...body.slice(offset, offset + height),
fg(MUTED, `Tab/←→ pane · ↑↓ scroll · r refresh · q quit${scrolled}`) + (note ? ` ${fg(YELLOW, note)}` : ""),
];
}
function draw(): void {
screen.draw(frame());
}
function onKey(chunk: string): void {
switch (chunk) {
// Raw mode delivers Ctrl-C as a byte, not a signal, so it is handled here.
case "q":
case "\x03":
case "\x1b":
leave();
return;
case "\t":
case "\x1b[C":
case "l":
pane = (pane + 1) % PANES.length;
offset = 0;
break;
case "\x1b[Z":
case "\x1b[D":
case "h":
pane = (pane + PANES.length - 1) % PANES.length;
offset = 0;
break;
case "\x1b[A":
case "k":
offset = Math.max(0, offset - 1);
break;
case "\x1b[B":
case "j":
offset += 1;
break;
case "r":
note = "refreshing…";
draw();
void fetchStats()
.then((fresh) => {
stats = fresh;
note = "";
})
.catch((err: unknown) => {
note = `refresh failed: ${err instanceof Error ? err.message : String(err)}`;
})
.finally(draw);
return;
default:
if (chunk >= "1" && chunk <= String(PANES.length)) {
pane = Number(chunk) - 1;
offset = 0;
break;
}
return;
}
draw();
}
try {
draw();
await quit;
} finally {
screen.close();
}
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bun
/**
* Fuzzy-select a solution file from work/ and submit it to LeetCode's judge.
*/
import { runOnSolution } from "./solutions";
await runOnSolution("submit", "Submit which solution?");
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env bun
/**
* Reconcile the "Interview Prep" Project's Set and Difficulty fields from the
* labels on each problem issue.
*
* The issue labels are the truth: `set:core|optional|deferred` and
* `diff:easy|medium|hard` are fixed when a problem issue is filed, and the
* Project's single-selects are a projection of them so the board can group and
* filter. This recomputes every desired value from scratch and writes only the
* ones that differ, so re-runs are no-ops and a backfill needs no special
* casing — same reconcile-don't-react shape as close-solved / close-topics.
*
* Scope guard: only items whose issue carries the `problem` label are touched.
* Topic, cadence and review issues have no set or difficulty, so they are
* skipped outright — never written, never cleared.
*
* Deliberately NOT mirrored: SRS stage and first-attempt result. Those live
* only in D1 (apps/api owns them; the charts, digest and /api/stats read them
* from there). The Project mirror writes Target Date and nothing else.
*
* Auth: needs a token carrying the `project` scope — neither the default
* `gh auth token` nor Actions' GITHUB_TOKEN has it. Pass one in GH_TOKEN: the
* PROJECT_PAT secret in CI, or the Worker's GH_PAT locally:
* GH_TOKEN=$GH_PAT bun run sync-project-fields -- --dry-run
*/
import { github, type GitHub } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
const PROJECT_TITLE = "Interview Prep";
/** Label → single-select option name, per field. Static tables, so Record. */
const FIELDS: Record<string, Record<string, string>> = {
Set: { "set:core": "Core", "set:optional": "Optional", "set:deferred": "Deferred" },
Difficulty: { "diff:easy": "Easy", "diff:medium": "Medium", "diff:hard": "Hard" },
};
const DRY =
process.argv.includes("--dry-run") ||
process.env.DRY_RUN === "1" ||
process.env.DRY_RUN === "true";
// ── project + field resolution ───────────────────────────────────
interface SelectField {
id: string;
/** option name → option id */
options: Record<string, string>;
}
interface Project {
id: string;
fields: Record<string, SelectField>;
}
/**
* Field and option IDs are resolved by NAME, so re-creating a field in the UI
* does not strand this script on a dead id.
*/
async function readProject(gh: GitHub, owner: string): Promise<Project> {
const data = (await gh.graphql(
`query($owner: String!, $title: String!) {
user(login: $owner) {
projectsV2(first: 10, query: $title) {
nodes {
id title
fields(first: 30) {
nodes {
... on ProjectV2SingleSelectField { id name options { id name } }
}
}
}
}
}
}`,
{ owner, title: PROJECT_TITLE },
)) as {
user: {
projectsV2: {
nodes: {
id: string;
title: string;
fields: { nodes: ({ id: string; name: string; options: { id: string; name: string }[] } | Record<string, never>)[] };
}[];
};
};
};
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
const fields: Record<string, SelectField> = {};
for (const name of Object.keys(FIELDS)) {
const field = node.fields.nodes.find((f) => "name" in f && f.name === name);
if (!field || !("options" in field)) {
throw new Error(`single-select field "${name}" missing from "${PROJECT_TITLE}"`);
}
fields[name] = {
id: field.id,
options: Object.fromEntries(field.options.map((o) => [o.name, o.id])),
};
}
return { id: node.id, fields };
}
// ── items ────────────────────────────────────────────────────────
interface Item {
id: string;
issue: number;
title: string;
labels: string[];
/** field name → current option name */
current: Record<string, string>;
}
async function* readItems(gh: GitHub, project: string): AsyncGenerator<Item, void, void> {
let cursor: string | null = null;
for (;;) {
const data = (await gh.graphql(
`query($id: ID!, $after: String) {
node(id: $id) {
... on ProjectV2 {
items(first: 100, after: $after) {
pageInfo { hasNextPage endCursor }
nodes {
id
fieldValues(first: 25) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name field { ... on ProjectV2FieldCommon { name } }
}
}
}
content {
... on Issue { number title labels(first: 25) { nodes { name } } }
}
}
}
}
}
}`,
{ id: project, after: cursor },
)) as {
node: {
items: {
pageInfo: { hasNextPage: boolean; endCursor: string };
nodes: {
id: string;
fieldValues: { nodes: ({ name: string; field: { name: string } } | Record<string, never>)[] };
content: { number: number; title: string; labels: { nodes: { name: string }[] } } | Record<string, never>;
}[];
};
};
};
for (const node of data.node.items.nodes) {
const content = node.content;
// Draft items and PRs have no issue number; nothing to reconcile.
if (!("number" in content) || typeof content.number !== "number") continue;
const current: Record<string, string> = {};
for (const value of node.fieldValues.nodes) {
if ("name" in value && value.field?.name) current[value.field.name] = value.name;
}
yield {
id: node.id,
issue: content.number,
title: content.title,
labels: content.labels.nodes.map((l) => l.name),
current,
};
}
const page = data.node.items.pageInfo;
if (!page.hasNextPage) return;
cursor = page.endCursor;
}
}
// ── reconcile ────────────────────────────────────────────────────
interface Write {
item: string;
issue: number;
title: string;
field: string;
from: string;
to: string;
}
async function setField(gh: GitHub, project: string, w: Write, option: string): Promise<void> {
await gh.graphql(
`mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $project, itemId: $item, fieldId: $field
value: { singleSelectOptionId: $option }
}
) { projectV2Item { id } }
}`,
{ project, item: w.item, field: w.field, option },
);
}
const gh = await github();
const owner = gh.repo.split("/")[0]!;
const project = await readProject(gh, owner);
const writes: Write[] = [];
let problems = 0;
let skipped = 0;
for await (const item of readItems(gh, project.id)) {
if (!item.labels.includes("problem")) {
skipped++;
continue;
}
problems++;
for (const [field, mapping] of Object.entries(FIELDS)) {
const want = item.labels.map((l) => mapping[l]).find(Boolean);
if (!want) {
console.warn(`#${item.issue} has no ${field.toLowerCase()} label — left untouched`);
continue;
}
const from = item.current[field];
if (from === want) continue;
if (!project.fields[field]!.options[want]) {
throw new Error(`field "${field}" has no option "${want}"`);
}
writes.push({
item: item.id,
issue: item.issue,
title: item.title,
field: project.fields[field]!.id,
from: from ?? "(empty)",
to: want,
});
}
}
const rows: string[][] = [["ISSUE", "PROBLEM", "FIELD", "FROM", "TO"]];
for (const w of writes) {
const name = Object.entries(project.fields).find(([, f]) => f.id === w.field)![0];
rows.push([`#${w.issue}`, w.title.slice(0, 46), name, w.from, w.to]);
if (DRY) continue;
await setField(gh, project.id, w, project.fields[name]!.options[w.to]!);
}
if (writes.length) printTable(rows);
console.log(
`\n${DRY ? "would update" : "updated"} ${writes.length} field value(s) across ${problems} problem item(s); ${skipped} non-problem item(s) skipped`,
);
if (writes.length) await writeStepSummary(markdownTable(rows));
+271 -24
View File
@@ -1,21 +1,47 @@
#!/usr/bin/env bun
/**
* Sync work/ leetcode solutions into docs/solutions/ pages.
* Sync work/1 leetcode solutions into apps/docs/content/ pages.
*
* - New problems get a full page (frontmatter, badge, warning, examples,
* constraints, solution) in the gold-standard format.
* - Already-ported pages only get their `## Solution` section regenerated,
* so hand-curated prose is never touched.
* - Already-ported pages only get their script-owned parts regenerated — the
* frontmatter `icon:` (`PAGE_ICON`), the `<Badge>` line and the
* `## Solution` section — so hand-curated prose is never touched.
* - When a problem is solved in both JavaScript and Python the solution
* renders as a <CodeGroup> (Python first); single-language solutions
* render as a plain fence.
*
* Two classifications, two homes. The sidebar group is the campaign TOPIC
* from the README table (`tree-dfs/`, ordered by topic number via a
* script-owned `meta.ts`); the page badge is the data-structure CATEGORY the
* file sits under in `work/1/<Difficulty>/<Category>/`. A problem outside the
* plan (warm-ups) has no topic, so it groups under its category instead.
*
* Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`.
* The phase folder is a Blume `(group)` — a sidebar section with its own
* script-owned `meta.ts`, adding no route segment — while the topic folder is
* a real segment (`/tree-dfs/invert-binary-tree`), so a hand-written
* `index.mdx` in it is the topic's landing page at `/tree-dfs` and Blume links
* the sidebar group row to it. Sync never writes or moves an `index.mdx`, and
* never removes a folder that still holds one. An unplanned category group has
* no phase and sits at the content root.
*
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
* of problems whose page already exists (see work.ts), and a fresh scaffold
* there would otherwise wipe the page's `## Solution`.
*
* Every page also gets a short link: `/<num>` → `/<topic>/<slug>` (so
* `/leetcode/217` opens Contains Duplicate). The rules live in the generated
* `apps/docs/redirects.ts`, which `blume.config.ts` imports — Blume takes exact
* root-relative paths and prepends the deployment base itself.
*/
import { mkdir, rename } from "node:fs/promises";
import { basename, join } from "node:path";
import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
const ROOT = join(import.meta.dir, "..");
const WORK = join(ROOT, "work");
const DOCS = join(ROOT, "docs", "solutions");
const ROOT = join(import.meta.dir, "..", "..");
const WORK = join(ROOT, "work", "1");
const DOCS = join(ROOT, "apps", "docs", "content");
const REDIRECTS = join(ROOT, "apps", "docs", "redirects.ts");
type Lang = "js" | "py";
@@ -154,24 +180,142 @@ function parseHeader(header: string): Header {
// ── README topic map ─────────────────────────────────────────────
/** A campaign phase: `## Phase N — Name · Weeks …` in the README. */
interface Phase {
order: number;
name: string;
}
/** A campaign topic: `<strong>NN. Name</strong>` in the README table. */
interface Topic {
order?: number; // README topic number; absent for an unplanned problem's category group
name: string;
phase?: Phase; // absent for an unplanned category group
}
/** README phase names → the short labels the overview page uses. */
const PHASE_ALIASES: Record<string, string> = {
"Nodal & Grid Structures": "Nodal & Grid",
"Hierarchical Structures": "Hierarchical",
"Relational Structures": "Relational",
"Decision-Space Exploration": "Decision Space",
};
const ROMAN = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"];
const TOPIC_ALIASES: Record<string, string> = {
"Hash-Based Lookup": "Hash Table",
};
async function readmeTopics(): Promise<Map<number, string>> {
/**
* Sidebar icon per group, keyed by the (post-alias) topic name — the same key an
* unplanned problem's category group uses, so `Stack` the category and `Stack`
* the topic share one row. Lucide names; Blume resolves them at build time and
* reports a diagnostic for a typo, but an unmapped group is caught here first.
*/
const TOPIC_ICONS: Record<string, string> = {
// README topics, in curriculum order
"Hash Table": "hash",
"Prefix Sum": "sigma",
"Two Pointers": "arrow-left-right",
"Sliding Window": "app-window",
"Binary Search": "search",
Stack: "layers",
"Monotonic Stack": "trending-up",
"Linked List": "link",
"Hybrid Structures": "combine",
"Matrix Index Math": "grid-3x3",
"Tree DFS": "folder-tree",
"Tree BFS": "network",
"Binary Search Tree": "git-fork",
"Heap / Priority Queue": "triangle",
Trie: "spell-check",
"Graph DFS": "waypoints",
"Graph BFS": "radar",
"Topological Sort": "workflow",
"Union-Find": "merge",
Backtracking: "undo-2",
"1-D Dynamic Programming": "columns-3",
"Multi-D / Grid DP": "table",
Greedy: "coins",
Intervals: "calendar-range",
// work/1 categories that can surface as an unplanned group
Array: "brackets",
String: "type",
Tree: "network",
"Depth-First Search": "folder-tree",
};
/** LC number -> topic. A problem listed under two topics keeps the first. */
async function readmeTopics(): Promise<Map<number, Topic>> {
const md = await Bun.file(join(ROOT, "README.md")).text();
const map = new Map<number, string>();
let topic = "";
const map = new Map<number, Topic>();
let phase: Phase | undefined;
let topic: Topic | null = null;
for (const line of md.split("\n")) {
const t = line.match(/<strong>\d+\.\s*([^<]+)<\/strong>/);
if (t) topic = TOPIC_ALIASES[t[1]!.trim()] ?? t[1]!.trim();
const p = line.match(/^## Phase ([IVX]+) — (.+?) · /);
if (p) {
const name = p[2]!.replaceAll("&amp;", "&");
phase = { order: ROMAN.indexOf(p[1]!) + 1, name: PHASE_ALIASES[name] ?? name };
}
const t = line.match(/<strong>(\d+)\.\s*([^<]+)<\/strong>/);
if (t) topic = { order: Number(t[1]), name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim(), phase };
if (!topic) continue;
const n = line.match(/<td align="center">(\d+)<\/td>/);
if (n) map.set(Number(n[1]), topic);
if (n && !map.has(Number(n[1]))) map.set(Number(n[1]), topic);
}
return map;
}
/**
* Folder slug for a topic or phase name. Blume reads a leading `<digits>-` on a
* folder as a sidebar-order prefix and strips it from the route (`1-d-…` →
* `/d-…`), so a slug that would start that way keeps the digits glued to the
* next token (`1d-dynamic-programming`).
*/
function slugify(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.replace(/^(\d+)-/, "$1");
}
/**
* Content folder for a topic, relative to `DOCS`: `(0N-<phase>)/<topic>` for a
* planned topic (the phase is a `(group)`, so the route is still `/<topic>`),
* bare `<topic>` for an unplanned category group.
*/
function topicDir(topic: Topic): string {
const slug = slugify(topic.name);
return topic.phase ? `${phaseDir(topic.phase)}/${slug}` : slug;
}
function phaseDir(phase: Phase): string {
return `(${String(phase.order).padStart(2, "0")}-${slugify(phase.name)})`;
}
/** Blume `(group)` meta for a phase: the overview page's heading, in phase order. */
function phaseMeta(phase: Phase): string {
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: ${JSON.stringify(`Phase ${phase.order}: ${phase.name}`)}, order: ${phase.order} });\n`;
}
/**
* Blume folder meta for a topic: exact label (the folder slug loses `/` and
* case), curriculum order, the sidebar icon, and `display: "group"` so the
* topic collapses under its phase heading. An unplanned category group
* (warm-ups) has no README number and sorts at 0, ahead of phase 1, so the
* sidebar opens on the basics.
*/
function groupMeta(topic: Topic): string {
const icon = TOPIC_ICONS[topic.name];
if (!icon) console.warn(`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`);
const fields = [`title: ${JSON.stringify(topic.name)}`, `order: ${topic.order ?? 0}`];
if (icon) fields.push(`icon: ${JSON.stringify(icon)}`);
fields.push(`display: "group"`);
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ ${fields.join(", ")} });\n`;
}
// ── inline-code formatting heuristics ────────────────────────────
/** Identifiers worth backticking in prose, harvested from the code. */
@@ -273,7 +417,14 @@ function warningText(h: Header): string {
);
}
function renderPage(sol: Solution, topic: string): string {
/** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */
const PAGE_ICON = "dot";
function badgeLine(sol: Solution): string {
return `<Badge variant="accent">${sol.category}</Badge>`;
}
function renderPage(sol: Solution): string {
const h = sol.header;
const ids = identifiers(sol);
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
@@ -283,12 +434,13 @@ function renderPage(sol: Solution, topic: string): string {
"---",
`title: '${h.num}. ${h.title.replaceAll("'", "''")}'`,
/[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`,
`icon: ${PAGE_ICON}`,
"sidebar:",
` label: '${h.title.replaceAll("'", "''")}'`,
` badge: '${h.difficulty}'`,
"---",
"",
`<Badge variant="accent">${topic}</Badge>`,
badgeLine(sol),
"",
);
@@ -323,7 +475,7 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
const file = basename(rel);
const m = file.match(/^(\d+)\.(.+)\.(?:js|py)$/);
if (!m) {
console.warn(`skip (unrecognized name): work/${rel}`);
console.warn(`skip (unrecognized name): work/1/${rel}`);
continue;
}
const num = Number(m[1]);
@@ -346,6 +498,11 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
}
const topics = await readmeTopics();
// An unplanned problem whose work/ category is also a README topic name (a
// `Hash Table` warm-up) files under that topic, in its phase, not a second
// root-level `hash-table/`.
const topicByName = new Map<string, Topic>();
for (const topic of topics.values()) topicByName.set(topic.name, topic);
/** Insert a number-free `sidebar.label` into existing frontmatter when missing. */
function ensureLabel(page: string, h: Header): string {
@@ -355,6 +512,35 @@ function ensureLabel(page: string, h: Header): string {
return page.replace(/^sidebar:$/m, `sidebar:\n${label}`);
}
/** Set the frontmatter's top-level `icon:` to `PAGE_ICON`, inserting it before `sidebar:`. */
function ensureIcon(page: string): string {
const fm = page.match(/^---\n[\s\S]*?\n---/);
if (!fm) return page;
const line = `icon: ${PAGE_ICON}`;
const next = /^icon: .*$/m.test(fm[0])
? fm[0].replace(/^icon: .*$/m, line)
: fm[0].replace(/^sidebar:$/m, `${line}\nsidebar:`);
return next + page.slice(fm[0].length);
}
/**
* Offset just past the script-owned `## Solution` section: the next top-level
* heading that is not inside a code fence, or end of file. Sections a human
* appends after the solution (`## Explanation`, …) survive regeneration.
*/
function solutionEnd(page: string, from: number): number {
const lines = page.slice(from).split("\n");
let offset = from + lines[0]!.length + 1; // past the '## Solution' line itself
let fenced = false;
for (let i = 1; i < lines.length; i++) {
const line = lines[i]!;
if (line.startsWith("```")) fenced = !fenced;
else if (!fenced && line.startsWith("## ")) return offset;
offset += line.length + 1;
}
return page.length;
}
// Existing docs pages, keyed by leetcode number from the frontmatter title.
const pages = new Map<number, string>(); // num -> path relative to DOCS
for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).sort()) {
@@ -365,15 +551,24 @@ for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).s
const report: [string, string, string][] = [];
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
const groups = new Map<string, Topic>(); // topic folder -> topic, for meta.ts
const phases = new Map<string, Phase>(); // phase folder -> phase, for meta.ts
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
const redirects: string[] = []; // `{ from: "/<num>", to: "/<topic>/<slug>" }` rows, numeric order
for (const sol of ordered) {
const langs = Object.keys(sol.code).sort().join("+");
const workRef = `${sol.num}.${sol.slug} (${langs})`;
// (category) group folder + leetcode-number prefix: numeric sidebar order, both stripped from the URL.
const group = `(${sol.category.toLowerCase().replaceAll(" ", "-")})`;
const name = `${group}/${sol.num}-${sol.slug}.mdx`;
// (0N-<phase>)/<topic>/ folder (the topic is a real route segment, so its
// index.mdx is the landing page) + leetcode-number prefix: numeric sidebar
// order, prefix stripped from the URL.
const topic = topics.get(sol.num) ?? topicByName.get(sol.category) ?? { name: sol.category };
const dir = topicDir(topic);
groups.set(dir, topic);
if (topic.phase) phases.set(phaseDir(topic.phase), topic.phase);
const name = `${dir}/${sol.num}-${sol.slug}.mdx`;
const existing = pages.get(sol.num);
await mkdir(join(DOCS, group), { recursive: true });
await mkdir(join(DOCS, dir), { recursive: true });
if (existing) {
const page = await Bun.file(join(DOCS, existing)).text();
@@ -382,10 +577,13 @@ for (const sol of ordered) {
report.push([workRef, existing, "ERROR: no '## Solution' heading"]);
continue;
}
const next = ensureLabel(page.slice(0, at) + solutionSection(sol), sol.header);
const tail = page.slice(solutionEnd(page, at));
const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
const next = ensureIcon(ensureLabel(body, sol.header)).replace(/^<Badge variant="accent">.*<\/Badge>$/m, badgeLine(sol));
const actions: string[] = [];
if (existing !== name) {
await rename(join(DOCS, existing), join(DOCS, name));
vacated.add(dirname(existing));
actions.push("moved");
}
if (next !== page) {
@@ -394,10 +592,59 @@ for (const sol of ordered) {
}
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
} else {
const topic = topics.get(sol.num) ?? sol.category;
await Bun.write(join(DOCS, name), renderPage(sol, topic));
await Bun.write(join(DOCS, name), renderPage(sol));
report.push([workRef, name, "created"]);
}
// The route drops the phase `(group)` and the page's number prefix.
redirects.push(` { from: "/${sol.num}", to: ${JSON.stringify(`/${slugify(topic.name)}/${sol.slug}`)} },`);
}
// Script-owned short links, one per page; imported by blume.config.ts.
{
const module = [
"// Generated by `bun run sync` (apps/cli/sync.ts) — do not edit.",
"// `/leetcode/<num>` → that problem's page; exact paths, base added by Blume.",
"export default [",
...redirects,
"];",
"",
].join("\n");
if (!(await Bun.file(REDIRECTS).exists()) || (await Bun.file(REDIRECTS).text()) !== module) {
await Bun.write(REDIRECTS, module);
report.push([`${redirects.length} redirects`, "apps/docs/redirects.ts", "written"]);
}
}
// Script-owned folder metas — one per live phase and topic folder — rewritten
// only when they differ.
const metas: [label: string, dir: string, meta: string][] = [
...[...phases].map(([dir, phase]): [string, string, string] => [`phase ${phase.order}`, dir, phaseMeta(phase)]),
...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]),
];
for (const [label, dir, meta] of metas) {
const path = join(DOCS, dir, "meta.ts");
if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta) continue;
await Bun.write(path, meta);
report.push([label, `${dir}/meta.ts`, "written"]);
}
// A folder every page left behind is dead: drop its meta and the folder itself,
// then the phase folder above it if that emptied too. A folder that still holds
// anything else (a hand-written index.mdx, another topic) stops the climb.
for (const start of vacated) {
for (let dir = start; dir !== "."; dir = dirname(dir)) {
if (groups.has(dir) || phases.has(dir)) break;
let left: string[];
try {
left = await readdir(join(DOCS, dir));
} catch {
break; // already removed via a sibling's climb
}
if (left.some((f) => f !== "meta.ts")) break;
if (left.length) await unlink(join(DOCS, dir, "meta.ts"));
await rmdir(join(DOCS, dir));
report.push(["", dir, "removed (empty)"]);
}
}
const w0 = Math.max(...report.map((r) => r[0].length), 9);
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bun
/**
* Fuzzy-select a solution file from work/ and run leetcode-cli tests on it.
*/
import { runOnSolution } from "./solutions";
await runOnSolution("test", "Test which solution?");
+75
View File
@@ -0,0 +1,75 @@
/**
* Topic issues and their problem sub-issues: the ONE reading of "is this topic
* finished", shared by close-topics.ts (closes the topic issue) and
* release-topics.ts (publishes the topic's timever release) so the two can
* never disagree about when a topic was done.
*
* Rule: a topic is finished when it has a core set (`set:core` sub-issues) and
* every one of them is closed. Optional and deferred problems are extra credit
* and never hold a topic open. A topic with no core sub-issues is never
* finished — that would be indistinguishable from a mis-labelled problem set.
*
* Library: side-effect-free on import; every call takes the caller's github().
*/
import type { GitHub } from "./github.ts";
/** One problem sub-issue, narrowed from the sub_issues payload. */
export interface Problem {
number: number;
/** `LC <num> · <Name>`. */
title: string;
/** ISO instant the issue closed; null while it is open. */
closedAt: string | null;
labels: string[];
}
export interface Core {
/** Core sub-issues, closed and open alike, lowest number first. */
all: number[];
/** The core sub-issues still open — non-empty means the topic stays open. */
pending: number[];
/** When the last core sub-issue closed; null until the topic is finished. */
finishedAt: string | null;
}
/**
* Every sub-issue of a topic. The sub_issues payload carries the full issue
* objects (state + labels), so no per-problem follow-up request is needed.
*/
export async function readProblems(gh: GitHub, topic: number): Promise<Problem[]> {
const problems: Problem[] = [];
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
if (!raw || typeof raw !== "object") continue;
if (!("number" in raw) || typeof raw.number !== "number") continue;
if (!("title" in raw) || typeof raw.title !== "string") continue;
if (!("state" in raw) || typeof raw.state !== "string") continue;
if (!("labels" in raw) || !Array.isArray(raw.labels)) continue;
const closedAt =
raw.state === "closed" && "closed_at" in raw && typeof raw.closed_at === "string"
? raw.closed_at
: null;
const labels = raw.labels.flatMap((label: unknown) =>
label && typeof label === "object" && "name" in label && typeof label.name === "string"
? [label.name]
: [],
);
problems.push({ number: raw.number, title: raw.title, closedAt, labels });
}
return problems.sort((a, b) => a.number - b.number);
}
/** The value of a `prefix:value` label (`set:` → `core`), if the problem has one. */
export function labelValue(problem: Problem, prefix: string): string | undefined {
return problem.labels.find((l) => l.startsWith(prefix))?.slice(prefix.length);
}
export function coreSet(problems: Problem[]): Core {
const core = problems.filter((p) => labelValue(p, "set:") === "core");
const pending = core.filter((p) => !p.closedAt).map((p) => p.number);
// ISO instants from the API share one format, so string order is time order.
const finishedAt =
core.length && !pending.length
? core.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b))
: null;
return { all: core.map((p) => p.number), pending, finishedAt };
}
+214
View File
@@ -0,0 +1,214 @@
/**
* Terminal drawing primitives for the stats dashboard: colour, block-glyph
* charts, and the alternate-screen plumbing.
*
* Hand-rolled for the same reason apps/api/src/charts.ts and png.ts are — the
* repo carries near-zero runtime dependencies, and everything a progress
* dashboard needs is a bar, a sparkline and a coloured cell. The palette is
* charts.ts's, so the terminal, the README SVGs and the digest email are the
* same picture in three renderers.
*
* Side-effect-free on import except for reading the colour environment once;
* nothing is written to the terminal until openScreen() is called.
*/
// ── palette (apps/api/src/charts.ts) ─────────────────────────────
export const FG = "#fafafa"; // zinc-50 text
export const MUTED = "#a1a1aa"; // zinc-400 secondary text
export const LINE = "#27272a"; // zinc-800 structure / empty track
export const GREEN = "#16a34a"; // green-600 (core / pass)
export const BLUE = "#2563eb"; // blue-600 (optional)
export const YELLOW = "#eab308"; // yellow-500 (attention)
export const RED = "#f87171"; // red-400 (fail, on dark)
/** GitHub dark-mode contribution hues; level 0 is the empty cell. */
export const GREENS = ["#27272a", "#0e4429", "#006d32", "#26a641", "#39d353"];
// ── colour ───────────────────────────────────────────────────────
/**
* Honour NO_COLOR and pipes, and let FORCE_COLOR override both — the dashboard
* is legible without colour, and `bun run stats | less -R` should still be.
*/
export const COLOR =
process.env.FORCE_COLOR !== undefined
? process.env.FORCE_COLOR !== "0"
: process.env.NO_COLOR === undefined && process.stdout.isTTY === true;
const RESET = "\x1b[0m";
function channels(hex: string): string {
return `${parseInt(hex.slice(1, 3), 16)};${parseInt(hex.slice(3, 5), 16)};${parseInt(hex.slice(5, 7), 16)}`;
}
export function fg(hex: string, text: string): string {
return COLOR ? `\x1b[38;2;${channels(hex)}m${text}${RESET}` : text;
}
export function bg(hex: string, text: string): string {
return COLOR ? `\x1b[48;2;${channels(hex)}m${text}${RESET}` : text;
}
export function bold(text: string): string {
return COLOR ? `\x1b[1m${text}${RESET}` : text;
}
const ANSI = /\x1b\[[0-9;]*m/g;
/** Printable width — every glyph used here is one cell wide. */
export function width(text: string): number {
return text.replace(ANSI, "").length;
}
/** Pad or cut a *plain* string to exactly n cells; colour it afterwards. */
export function cell(text: string, n: number, align: "left" | "right" = "left"): string {
const clipped = text.length > n ? `${text.slice(0, Math.max(0, n - 1))}…` : text;
return align === "left" ? clipped.padEnd(n) : clipped.padStart(n);
}
/** Pad an already-coloured string to n cells. */
export function padTo(text: string, n: number): string {
return text + " ".repeat(Math.max(0, n - width(text)));
}
// ── charts ───────────────────────────────────────────────────────
const EIGHTHS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
const SPARKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
/**
* Filled and empty. The two differ by GLYPH as well as by colour, so a bar
* still reads under NO_COLOR or in a pipe — every chart here has to survive
* `bun run stats > notes.txt`.
*/
const FILL = "█";
const TRACK = "░";
/**
* One bar over a muted track, `cells` wide. Sub-cell remainders use the
* eighth-block glyphs, so one solved problem out of 161 still shows.
*/
export function bar(value: number, total: number, cells: number, color = GREEN): string {
const units = Math.round(Math.max(0, Math.min(1, total > 0 ? value / total : 0)) * cells * 8);
const filled = FILL.repeat(Math.floor(units / 8)) + EIGHTHS[units % 8]!;
return fg(color, filled) + fg(LINE, TRACK.repeat(Math.max(0, cells - filled.length)));
}
export interface Segment {
value: number;
color: string;
/** Defaults to the solid block; pass TRACK-ish glyphs for "not done yet". */
glyph?: string;
}
/**
* Segments laid end to end, `total` mapped to `cells`. Anything short of
* `total` is left BLANK, not tracked: bars sharing a scale must show their
* different lengths. A caller wanting a visible remainder passes it as a
* segment of its own.
*
* Non-zero segments are floored but never vanish; the overshoot that floor
* creates is taken back from the widest segment, which can spare it.
*/
export function stackedBar(segments: Segment[], total: number, cells: number): string {
if (total <= 0) return " ".repeat(cells);
const widths = segments.map((s) =>
s.value > 0 ? Math.max(1, Math.floor((s.value / total) * cells)) : 0,
);
let over = widths.reduce((a, b) => a + b, 0) - cells;
while (over > 0) {
const i = widths.indexOf(Math.max(...widths));
if (widths[i]! <= 1) break;
widths[i]!--;
over--;
}
const drawn = widths.map((n, i) => fg(segments[i]!.color, (segments[i]!.glyph ?? FILL).repeat(n)));
return drawn.join("") + " ".repeat(Math.max(0, cells - widths.reduce((a, b) => a + b, 0)));
}
/**
* One cell of the campaign heatmap. With colour it is the GREENS ramp the
* README and the digest use; without it, the same five levels as shading.
*/
export function heatCell(attempts: number, body: string): string {
const level = Math.min(attempts, GREENS.length - 1);
return COLOR ? bg(GREENS[level]!, body) : [" ", "·", "▒", "▓", "█"][level]!.repeat(body.length);
}
/** One glyph per value, scaled to the largest. All-zero renders as a floor. */
export function sparkline(values: number[], color = GREEN): string {
const peak = Math.max(...values, 0);
const line = values
.map((v) => (peak === 0 ? SPARKS[0]! : SPARKS[Math.min(7, Math.round((v / peak) * 7))]!))
.join("");
return fg(color, line);
}
// ── layout ───────────────────────────────────────────────────────
/** A section rule that fills the width: `── name ────────────`. */
export function rule(title: string, cells: number): string {
const label = ` ${title} `;
return (
fg(LINE, "──") + fg(MUTED, label) + fg(LINE, "─".repeat(Math.max(0, cells - 2 - label.length)))
);
}
// ── screen ───────────────────────────────────────────────────────
const ALT_ON = "\x1b[?1049h\x1b[?25l";
const ALT_OFF = "\x1b[?25h\x1b[?1049l";
export interface Screen {
readonly rows: number;
readonly columns: number;
/** Draw a frame, clipped to the terminal height. */
draw(lines: string[]): void;
close(): void;
}
/**
* Take over the terminal: alternate buffer, hidden cursor, raw keys. `onKey`
* receives the raw chunk (`"\t"`, `"\x1b[B"`, `"q"`, …) — decoding belongs to
* the app, which is the only thing that knows what a key means.
*
* The caller MUST close() in a finally: raw mode survives a thrown exception.
*/
export function openScreen(onKey: (chunk: string) => void, onResize: () => void): Screen {
const out = process.stdout;
const input = process.stdin;
out.write(ALT_ON);
input.setRawMode?.(true);
input.setEncoding("utf8");
input.resume();
input.on("data", onKey);
out.on("resize", onResize);
let closed = false;
return {
get rows() {
return out.rows ?? 24;
},
get columns() {
return out.columns ?? 80;
},
draw(lines: string[]): void {
const visible = lines.slice(0, this.rows);
// Home, then erase each line as it is rewritten plus everything below the
// last one. No full clear, so a redraw does not flash, and no trailing
// newline, which would scroll the alternate screen by a row per frame.
out.write(`\x1b[H${visible.map((l) => `${l}\x1b[K`).join("\r\n")}\x1b[J`);
},
close(): void {
if (closed) return;
closed = true;
input.off("data", onKey);
out.off("resize", onResize);
input.setRawMode?.(false);
input.pause();
out.write(ALT_OFF);
},
};
}
+173
View File
@@ -0,0 +1,173 @@
#!/usr/bin/env bun
/**
* Find the NeetCode explanation video for every docs page that has none.
*
* A finished solution page ends with `## Explanation` holding one
* `<YouTube id="…" title="…" />` — NeetCode's walkthrough of the problem.
* `sync.ts` never writes that section (it owns only `## Solution`), so pages
* accumulate without one. This script lists every numbered page lacking a
* `<YouTube` embed and looks each up on NeetCode's two channels, main channel
* first, accepting only a title that names the page's exact LC number
* (`… - Leetcode 226 - …`) and is not a #short. No YouTube API key: the
* channel search page embeds its results as `ytInitialData` JSON, which is
* all we parse. Ranking is the channel's own — the first exact-number match
* wins.
*
* bun apps/cli/videos.ts # report: page → video (or "no match")
* bun apps/cli/videos.ts --write # also append `## Explanation` + embed
*
* --write appends at end of file (below `## Solution`, where sync.ts leaves
* human sections alone) and adds the `## Explanation` heading only when the
* page lacks it. A page with no match is left untouched, so re-runs are
* no-ops once every page has its embed.
*/
import { join } from "node:path";
import { printTable } from "./report.ts";
import { ROOT } from "./work.ts";
const DOCS = join(ROOT, "apps", "docs", "content");
const WRITE = process.argv.includes("--write");
/** Channel handles searched in order; the first exact match wins. */
const CHANNELS = ["NeetCode", "NeetCodeIO"] as const;
interface Video {
id: string;
title: string;
channel: string;
}
// ── YouTube channel search (no API key) ──────────────────────────
const HEADERS = { "accept-language": "en-US,en;q=0.9", "user-agent": "Mozilla/5.0" };
/**
* The `var ytInitialData = {…};</script>` blob of a YouTube page, parsed.
* YouTube intermittently answers 200 with a page that lacks the blob or cuts
* it short, so a bad page is retried a few times before it is an error.
*/
async function initialData(url: string): Promise<unknown> {
let reason = "";
for (let attempt = 0; attempt < 3; attempt++) {
if (attempt) await Bun.sleep(500 * attempt);
const res = await fetch(url, { headers: HEADERS });
if (!res.ok) {
reason = `status ${res.status}`;
continue;
}
const html = await res.text();
// Slice rather than regex: the blob is ~1 MB, and a lazy `.*?` across it
// is what made the first cut of this script fail at random.
const open = html.search(/ytInitialData\s*=\s*\{/);
const close = open === -1 ? -1 : html.indexOf("</script>", open);
if (close === -1) {
reason = `no ytInitialData in ${html.length}-byte page`;
continue;
}
try {
return JSON.parse(html.slice(html.indexOf("{", open), close).replace(/;\s*$/, ""));
} catch {
reason = "truncated ytInitialData";
}
}
throw new Error(`GET ${url} -> ${reason}`);
}
/** Every videoRenderer in a channel search page, in the page's own order. */
async function channelSearch(channel: string, query: string): Promise<Video[]> {
const url = `https://www.youtube.com/@${channel}/search?query=${encodeURIComponent(query)}`;
const data = await initialData(url);
const videos: Video[] = [];
const walk = (node: unknown): void => {
if (!node || typeof node !== "object") return;
if ("videoRenderer" in node) {
const v = node.videoRenderer;
if (v && typeof v === "object" && "videoId" in v && typeof v.videoId === "string") {
const runs =
"title" in v && v.title && typeof v.title === "object" && "runs" in v.title
? v.title.runs
: undefined;
const title = Array.isArray(runs)
? runs.map((r) => (r && typeof r === "object" && "text" in r ? String(r.text) : "")).join("")
: "";
videos.push({ id: v.videoId, title, channel });
}
return;
}
for (const child of Object.values(node)) walk(child);
};
walk(data);
return videos;
}
/** The LC number a NeetCode title names (`… - Leetcode 226 - …`), if any. */
function titledNumber(title: string): number | undefined {
const m = title.match(/\bleetcode\s*#?\s*(\d+)\b/i);
return m ? Number(m[1]) : undefined;
}
async function findVideo(num: number): Promise<Video | undefined> {
for (const channel of CHANNELS) {
const hit = (await channelSearch(channel, `leetcode ${num}`)).find(
(v) => titledNumber(v.title) === num && !/#shorts\b/i.test(v.title),
);
if (hit) return hit;
}
return undefined;
}
// ── docs pages ───────────────────────────────────────────────────
interface Page {
num: number;
rel: string;
text: string;
}
/** Numbered solution pages without a `<YouTube` embed, by LC number. */
async function pagesMissingVideo(): Promise<Page[]> {
const pages: Page[] = [];
for (const rel of await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))) {
const text = await Bun.file(join(DOCS, rel)).text();
const t = text.match(/^title: '(\d+)\./m);
if (t && !text.includes("<YouTube")) pages.push({ num: Number(t[1]), rel, text });
}
return pages.sort((a, b) => a.num - b.num);
}
function withEmbed(page: Page, video: Video): string {
const embed = `<YouTube id="${video.id}" title="${video.title.replaceAll('"', "'")}" />`;
const heading = /^## Explanation\s*$/m.test(page.text) ? "" : "## Explanation\n\n";
return `${page.text.replace(/\s*$/, "\n")}\n${heading}${embed}\n`;
}
// ── main ─────────────────────────────────────────────────────────
const missing = await pagesMissingVideo();
if (missing.length === 0) {
console.log("every solution page has a video");
process.exit(0);
}
const rows: string[][] = [["docs page", "video", "title", WRITE ? "status" : ""]];
let found = 0;
for (const page of missing) {
const video = await findVideo(page.num);
if (!video) {
rows.push([page.rel, "—", "no NeetCode match", WRITE ? "skipped" : ""]);
continue;
}
found++;
let status = "";
if (WRITE) {
await Bun.write(join(DOCS, page.rel), withEmbed(page, video));
status = "written";
}
rows.push([page.rel, `https://youtu.be/${video.id}`, `${video.title} (${video.channel})`, status]);
}
if (!WRITE) for (const row of rows) row.pop();
printTable(rows);
console.log(`\n${found}/${missing.length} page(s) matched${WRITE ? `, ${found} written` : ""}`);
+197
View File
@@ -0,0 +1,197 @@
/**
* The work/ layout: one directory per spaced-repetition window.
*
* work/1/<Difficulty>/<Category>/<num>.<slug>.<ext> first solve
* work/3/<Difficulty>/<Category>/<num>.<slug>.<ext> 3-day blind re-solve
* work/7/<Difficulty>/<Category>/<num>.<slug>.<ext> 7-day blind re-solve
*
* The bucket name is the review window in days — the same +3/+7 windows the
* campaign's `Spaced Repetition — <date>` issues use (WINDOWS in
* spaced-repetition.ts). Below the bucket the layout is leetcode-cli's own
* `workDir/Difficulty/Category/` shape, so a re-solve lands beside its first
* solve under a different bucket.
*
* A window is *owed* when the first solve is at least that many days old and
* the bucket has no file for that problem yet. Reconciled from the filesystem
* on every run the way close-solved.ts reconciles issues: no state file, no
* bookkeeping, and a re-solve closes its window just by existing — presence,
* not content, so a stub in work/3 counts as done the same way `bun run pick`
* treats a scaffold under work/1 as picked. The first solve is the one place
* content is read: an empty scaffold in work/1 owes nothing, it is simply
* unsolved (isImplemented() in source.ts, the same gate close-solved.ts uses
* before it closes an issue).
*
* "First solve" is the ET date of the commit that first added the problem's
* work/ file — the same push that closes its problem issue and therefore
* starts the campaign's +3/+7 windows. An uncommitted solution has no date and
* is never owed a review yet: you just solved it. ET dates and the date
* arithmetic come from apps/api/src/srs.ts so the local picker and D1 cannot
* disagree about what day it is.
*
* Library: side-effect-free on import — nothing scans or shells out until a
* function is called.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { WINDOWS, type Window, daysBetween } from "../api/src/srs.ts";
import { isImplemented } from "./source.ts";
export const ROOT = join(import.meta.dir, "..", "..");
export const WORK = join(ROOT, "work");
// The windows are the SRS ladder's rungs: srs.ts defines them once and the
// bucket directories are named after them. Re-exported so the picker needs
// only this module to know the work/ layout.
export { WINDOWS, type Window };
/** work/1 is the first solve, the rest are the review windows. */
export const BUCKETS = [1, ...WINDOWS] as const;
export type Bucket = (typeof BUCKETS)[number];
const BUCKET_BY_DIR: Record<string, Bucket> = { "1": 1, "3": 3, "7": 7 };
export function bucketDir(bucket: Bucket): string {
return join(WORK, String(bucket));
}
export interface WorkFile {
lc: number;
slug: string;
bucket: Bucket;
/** Path relative to work/, bucket included. */
rel: string;
difficulty: string;
category: string;
}
/** `<num>.<slug>.<ext>` — the only file name the campaign scripts parse. */
function parseName(name: string): { lc: number; slug: string } | null {
const m = name.match(/^(\d+)\.(.+)\.[A-Za-z0-9]+$/);
return m ? { lc: Number(m[1]), slug: m[2]! } : null;
}
/** Parse `1/Medium/Array/15.3sum.py` — null for anything off-layout. */
export function parseWorkPath(rel: string): WorkFile | null {
const parts = rel.split("/");
const name = parts.pop();
const bucket = BUCKET_BY_DIR[parts[0] ?? ""];
if (name === undefined || bucket === undefined) return null;
const parsed = parseName(name);
if (!parsed) return null;
return { ...parsed, bucket, rel, difficulty: parts[1] ?? "", category: parts[2] ?? "" };
}
/** LC number -> its files in that bucket (a problem may have both js and py). */
export function scanBucket(bucket: Bucket): Map<number, WorkFile[]> {
const files = new Map<number, WorkFile[]>();
for (const name of new Bun.Glob("**/*.*").scanSync({ cwd: bucketDir(bucket) })) {
const file = parseWorkPath(`${bucket}/${name}`);
if (!file) continue;
const found = files.get(file.lc);
if (found) found.push(file);
else files.set(file.lc, [file]);
}
return files;
}
/**
* Is this file a real solution rather than an empty scaffold? The same call
* close-solved.ts makes before it closes an issue — a stub under work/1 is a
* problem still unsolved, and owes no review.
*/
export function isSolution(file: WorkFile): boolean {
const source = readFileSync(join(WORK, file.rel), "utf8");
return isImplemented(source, file.rel.endsWith(".py") ? "py" : "js");
}
/** Problems in a bucket with a solution on disk; scaffolds do not count. */
export function solvedInBucket(bucket: Bucket): number {
let solved = 0;
for (const files of scanBucket(bucket).values()) {
if (files.some(isSolution)) solved++;
}
return solved;
}
/**
* LC number -> ET date of the commit that first added a work/ file for it.
*
* One `git log` over the whole of work/, oldest commit first, so the first
* date seen for a problem wins. Keyed by LC number rather than by path, which
* makes every past reshuffle of the directory layout — including the move into
* work/1 — irrelevant: the original add still counts. TZ + `format-local`
* put the dates on the ET calendar srs.ts speaks.
*/
export function firstSolved(): Map<number, string> {
const git = Bun.spawnSync(
[
"git",
"log",
"--reverse",
"--no-merges",
"--diff-filter=A",
"--date=format-local:%Y-%m-%d",
"--format=%ad",
"--name-only",
"--",
"work",
],
{ cwd: ROOT, env: { ...process.env, TZ: "America/New_York" } },
);
if (git.exitCode !== 0) {
throw new Error(`git log failed: ${git.stderr.toString().trim()}`);
}
const dates = new Map<number, string>();
let date = "";
for (const line of git.stdout.toString().split("\n")) {
if (/^\d{4}-\d{2}-\d{2}$/.test(line)) {
date = line;
continue;
}
const parsed = line === "" ? null : parseName(line.slice(line.lastIndexOf("/") + 1));
if (parsed && !dates.has(parsed.lc)) dates.set(parsed.lc, date);
}
return dates;
}
export interface Owed extends WorkFile {
/** ET date of the first solve. */
solved: string;
/** Days since the first solve — at least the window. */
days: number;
}
/**
* Both review windows for `today`, oldest solve first.
*
* Uncapped, unlike the review issue's WINDOW_CAP: the issue is one day's
* assignment, this is everything still owed. A window skipped for a day stays
* on the list until its file exists.
*/
export function reviewQueues(today: string): Map<Window, Owed[]> {
const dates = firstSolved();
// An empty scaffold under work/1 is a problem still unsolved, not one owing
// a review.
const solved: { file: WorkFile; date: string }[] = [];
for (const files of scanBucket(1).values()) {
const date = dates.get(files[0]!.lc);
if (date === undefined) continue; // uncommitted: you only just solved it
const file = files.find(isSolution);
if (file) solved.push({ file, date });
}
const queues = new Map<Window, Owed[]>();
for (const window of WINDOWS) {
const done = scanBucket(window);
const owed: Owed[] = [];
for (const { file, date } of solved) {
if (done.has(file.lc)) continue;
const days = daysBetween(date, today);
if (days >= window) owed.push({ ...file, solved: date, days });
}
owed.sort((a, b) => b.days - a.days || a.lc - b.lc);
queues.set(window, owed);
}
return queues;
}
+1
View File
@@ -0,0 +1 @@
.blume-verify/
+64
View File
@@ -0,0 +1,64 @@
import { defineConfig } from "blume";
import { filesystem, githubReleases } from "blume/sources";
import redirects from "./redirects";
// Blume's defaults (`inter`, `ibm-plex-mono`) are fetched from fonts.gstatic.com
// on every build, which has timed out on the Pages runner and, fully offline,
// silently ships no fonts at all. The same two families from Fontsource, as
// local files, keep the build network-free. Blume resolves `src` from this
// directory; the workspace links these packages under ./node_modules.
const inter = {
name: "Inter",
variants: [
{
src: "./node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2",
weight: "100..900",
},
],
};
const plexMono = {
name: "IBM Plex Mono",
variants: [
{
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-400-normal.woff2",
weight: 400,
},
{
src: "./node_modules/@fontsource/ibm-plex-mono/files/ibm-plex-mono-latin-500-normal.woff2",
weight: 500,
},
],
};
export default defineConfig({
title: "prdlk",
description: "Documentation for my LeetCode solutions",
logo: "./public/logo.svg",
deployment: {
site: "https://lc.prd.lk",
},
github: {
owner: "prdlk",
repo: "leetcode",
},
lastModified: "git",
redirects,
content: {
sources: [
filesystem({ root: "content" }),
githubReleases({
prefix: "changelog",
owner: "prdlk",
repo: "leetcode",
}),
],
},
theme: {
fonts: { display: inter, body: inter, mono: plexMono },
},
navigation: {
sidebar: {
display: "flat", // "flat" | "group" | "page"
},
},
});
+13
View File
@@ -0,0 +1,13 @@
/**
* Project MDX components, available on every page with no import. Static
* `.astro` files ship no framework JS (a custom element carries any
* behaviour); interactive React lives in `islands/` instead.
*/
import { defineComponents } from "blume";
export default defineComponents({
mdx: {
Carousel: "./components/Carousel.astro",
CarouselImage: "./components/CarouselImage.astro",
},
});
+170
View File
@@ -0,0 +1,170 @@
---
/**
* A horizontal carousel of <CarouselImage> slides, built the way Blume's own
* content components are: static markup, a custom element for the behaviour,
* no framework. The track is a CSS scroll-snap strip (so it also swipes on
* touch and the browser does the animation); prev/next sit outside it and
* the play toggle sits below with a "n / N" counter.
*
* `time` is seconds per slide while playing (default 1.25); `autoplay` starts
* playing on load. Play wraps around, pauses whenever the tab is hidden, and
* never starts on its own for a reader who prefers reduced motion — the
* button still works for them. Manual navigation (buttons, arrow keys, swipe)
* does not stop playback; it only restarts the interval so a step is never
* doubled.
*/
import Icon from "blume/components/Icon.astro";
interface Props {
/** Seconds each slide stays while playing. */
time?: number;
autoplay?: boolean;
}
const { time = 1.25, autoplay = false } = Astro.props;
const buttonClass =
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors hover:border-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40";
---
<blume-carousel
class="not-prose my-6 block"
data-time={String(time)}
data-autoplay={autoplay ? "" : undefined}
role="region"
aria-roledescription="carousel"
>
<div class="flex items-center gap-3">
<button class={buttonClass} data-prev type="button" aria-label="Previous slide">
<Icon class="rtl:-scale-x-100" name="chevron-left" size={18} />
</button>
<div
class="flex min-w-0 flex-1 snap-x snap-mandatory overflow-x-auto scroll-smooth rounded-blume [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
data-track
tabindex="0"
aria-live="polite"
>
<slot />
</div>
<button class={buttonClass} data-next type="button" aria-label="Next slide">
<Icon class="rtl:-scale-x-100" name="chevron-right" size={18} />
</button>
</div>
<div class="mt-3 flex items-center justify-center gap-3">
<button class={buttonClass} data-play type="button" aria-label="Play" aria-pressed="false">
<Icon class="[[aria-pressed=true]_&]:hidden" name="play" size={16} />
<Icon class="[[aria-pressed=false]_&]:hidden" name="pause" size={16} />
</button>
<span class="text-muted-foreground text-xs tabular-nums leading-5" data-count></span>
</div>
</blume-carousel>
<script>
class BlumeCarousel extends HTMLElement {
#track!: HTMLElement;
#play!: HTMLButtonElement;
#count!: HTMLElement;
#timer: number | null = null;
#index = 0;
connectedCallback() {
this.#track = this.querySelector("[data-track]")!;
this.#play = this.querySelector("[data-play]")!;
this.#count = this.querySelector("[data-count]")!;
const prev = this.querySelector<HTMLButtonElement>("[data-prev]")!;
const next = this.querySelector<HTMLButtonElement>("[data-next]")!;
const slides = this.#slides().length;
if (slides < 2) {
for (const b of [prev, next, this.#play]) b.disabled = true;
}
this.#render();
prev.addEventListener("click", () => this.#step(-1));
next.addEventListener("click", () => this.#step(1));
this.#play.addEventListener("click", () => (this.#timer === null ? this.#start() : this.#stop()));
this.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft") this.#step(-1);
else if (event.key === "ArrowRight") this.#step(1);
else return;
event.preventDefault();
});
// Swipes and trackpad scrolls move the track without #step; follow them
// once the scroll settles. Settle-only, not per event: a long smooth
// scroll from #step passes over intermediate slides and must not be
// read back as the reader's position mid-flight.
let settle: number | undefined;
this.#track.addEventListener(
"scroll",
() => {
window.clearTimeout(settle);
settle = window.setTimeout(() => this.#syncFromScroll(), 150);
},
{ passive: true }
);
document.addEventListener("visibilitychange", this.#onVisibility);
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
if (this.hasAttribute("data-autoplay") && slides > 1 && !reduced) this.#start();
}
disconnectedCallback() {
this.#stop();
document.removeEventListener("visibilitychange", this.#onVisibility);
}
#slides(): HTMLElement[] {
return [...this.#track.querySelectorAll<HTMLElement>(":scope > blume-carousel-image")];
}
#step(delta: number) {
const slides = this.#slides();
this.#index = (this.#index + delta + slides.length) % slides.length;
slides[this.#index]!.scrollIntoView({ block: "nearest", inline: "start", behavior: "smooth" });
this.#render();
if (this.#timer !== null) this.#start(); // restart the interval from this slide
}
#syncFromScroll() {
const at = Math.round(this.#track.scrollLeft / this.#track.clientWidth);
if (at !== this.#index && Number.isFinite(at)) {
this.#index = Math.min(Math.max(at, 0), this.#slides().length - 1);
this.#render();
}
}
#start() {
this.#stop();
const ms = Number(this.dataset.time) * 1000;
this.#timer = window.setInterval(() => this.#step(1), ms > 0 ? ms : 1250);
this.#play.setAttribute("aria-pressed", "true");
this.#play.setAttribute("aria-label", "Pause");
}
#stop() {
if (this.#timer !== null) window.clearInterval(this.#timer);
this.#timer = null;
this.#play.setAttribute("aria-pressed", "false");
this.#play.setAttribute("aria-label", "Play");
}
// A background tab keeps its timers but not its layout; resume on return.
#wasPlaying = false;
#onVisibility = () => {
if (document.hidden) {
this.#wasPlaying = this.#timer !== null;
this.#stop();
} else if (this.#wasPlaying) this.#start();
};
#render() {
const slides = this.#slides();
this.#count.textContent = `${this.#index + 1} / ${slides.length}`;
slides.forEach((s, i) => s.setAttribute("aria-hidden", i === this.#index ? "false" : "true"));
}
}
if (!customElements.get("blume-carousel")) {
customElements.define("blume-carousel", BlumeCarousel);
}
</script>
+45
View File
@@ -0,0 +1,45 @@
---
/**
* One slide of a <Carousel>: Blume's own <Frame> around an <img>, so a slide
* is pixel-identical to a framed image elsewhere on the site — `alt` doubles
* as the frame's hint line and `caption` is Markdown, exactly as in Frame.
*
* `src` accepts what MDX authors already write for images: a URL, a
* root-relative `/file.png` (served from public/, prefixed with the deploy
* base), or the page-relative `../../public/file.png` form. A relative path
* that does not pass through public/ has nothing to serve it, so it is a
* build error rather than a silent 404.
*/
import Frame from "blume/components/content/Frame.astro";
import { withBase } from "blume/components/islands/base-path.ts";
interface Props {
src: string;
alt?: string;
caption?: string;
}
const { src, alt, caption } = Astro.props;
const resolveSrc = (value: string): string => {
if (URL.canParse(value)) return value;
if (value.startsWith("/")) return withBase(value);
const at = value.indexOf("public/");
if (at === -1) {
throw new Error(
`<CarouselImage src="${value}">: a relative src must point into public/ (or use /file.png)`
);
}
return withBase(`/${value.slice(at + "public/".length)}`);
};
---
<blume-carousel-image
class="block w-full shrink-0 snap-center [&_blume-frame]:my-0"
role="group"
aria-roledescription="slide"
>
<Frame caption={caption} hint={alt}>
<img alt={alt ?? ""} src={resolveSrc(src)} loading="lazy" draggable="false" />
</Frame>
</blume-carousel-image>
@@ -0,0 +1,75 @@
---
title: '153. Find Minimum in Rotated Sorted Array'
description: 'Suppose an array of length n sorted in ascending order is rotated between 1 and n times. For example, the array nums = [0,1,2,4,5,6,7] might become:'
icon: dot
sidebar:
label: 'Find Minimum in Rotated Sorted Array'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
::::warning
Notice that rotating an array [a[0], a[1], a[2], ..., a[n-1]] 1 time results in the array [a[n-1], a[0], a[1], a[2], ..., a[n-2]].
::::
### Example 1:
- Input: `nums = [3,4,5,1,2]`
- Output: `1`
- Explanation: The original array was `[1,2,3,4,5]` rotated `3` times.
### Example 2:
- Input: `nums = [4,5,6,7,0,1,2]`
- Output: `0`
- Explanation: The original array was `[0,1,2,4,5,6,7]` and it was rotated `4` times.
### Example 3:
- Input: `nums = [11,13,15,17]`
- Output: `11`
- Explanation: The original array was `[11,13,15,17]` and it was rotated `4` times.
### Constraints:
- `n == nums.length`
- `1 <= n <= 5000`
- `-5000 <= nums[i] <= 5000`
- All the integers of `nums` are unique.
- `nums` is sorted and rotated between 1 and `n` times.
## Approach
```mermaid
flowchart TD
S(["findMin(nums)"]) --> I["l = 0, r = n-1, lowest_index = -1"]
I --> W{"l <= r?"}
W -- no --> E(["return nums[lowest_index]"])
W -- yes --> M["m = (l + r) // 2"]
M --> C{"nums[m] <= nums[-1]?"}
C -- yes --> A["in the right sorted run: record lowest_index = m, r = m - 1"]
A --> W
C -- no --> B["still in the left sorted run: l = m + 1"]
B --> W
```
## Solution
```py
class Solution:
def findMin(self, nums: List[int]) -> int:
l, r = 0, len(nums) - 1
lowest_index = -1
while l <= r:
m = (l + r) // 2
if nums[m] <= nums[-1]:
lowest_index = m
r = m - 1
else:
l = m + 1
return nums[lowest_index]
```
## Explanation
<YouTube id="nIVW4P8b1VA" title="Find Minimum in Rotated Sorted Array - Binary Search - Leetcode 153 - Python" />
@@ -1,12 +1,13 @@
---
title: '33. Search in Rotated Sorted Array'
description: There is an integer array nums sorted in ascending order (with distinct values)
icon: dot
sidebar:
label: 'Search in Rotated Sorted Array'
badge: 'Medium'
---
<Badge variant="accent">Binary Search</Badge>
<Badge variant="accent">Array</Badge>
::::warning
You must write an algorithm with O(log n) runtime complexity.
@@ -32,6 +33,29 @@ You must write an algorithm with O(log n) runtime complexity.
- `nums` is an ascending array that is possibly rotated.
- `-10^4 <= target <= 10^4`
## Approach
```mermaid
flowchart TD
S(["search(nums, target)"]) --> I["l = 0, r = n - 1"]
I --> W{"l <= r?"}
W -- no --> E(["return -1"])
W -- yes --> M["mid = floor((l + r) / 2)"]
M --> F{"nums[mid] == target?"}
F -- yes --> R(["return mid"])
F -- no --> H{"nums[l] <= nums[mid]? — left half sorted"}
H -- yes --> A{"target outside [nums[l], nums[mid]]?"}
A -- yes --> A1["l = mid + 1 — search the right half"]
A -- no --> A2["r = mid - 1"]
H -- no --> B{"target outside [nums[mid], nums[r]]?"}
B -- yes --> B1["r = mid - 1 — search the left half"]
B -- no --> B2["l = mid + 1"]
A1 --> W
A2 --> W
B1 --> W
B2 --> W
```
## Solution
```js
@@ -69,3 +93,7 @@ var search = function(nums, target) {
return -1;
};
```
## Explanation
<YouTube id="U8XENwh8Oy8" title="Search in rotated sorted array - Leetcode 33 - Python" />
@@ -0,0 +1,72 @@
---
title: '35. Search Insert Position'
description: Given a sorted array of distinct integers and a target value, return the index if the target is found. If not, return the index where it would be if it were inserted in order
icon: dot
sidebar:
label: 'Search Insert Position'
badge: 'Easy'
---
<Badge variant="accent">Array</Badge>
::::warning
You must write an algorithm with O(log n) runtime complexity.
::::
### Example 1:
- Input: `nums = [1,3,5,6], target = 5`
- Output: `2`
### Example 2:
- Input: `nums = [1,3,5,6], target = 2`
- Output: `1`
### Example 3:
- Input: `nums = [1,3,5,6], target = 7`
- Output: `4`
### Constraints:
- `1 <= nums.length <= 10^4`
- `-10^4 <= nums[i] <= 10^4`
- `nums` contains distinct values sorted in ascending order.
- `-10^4 <= target <= 10^4`
## Approach
```mermaid
flowchart TD
S(["searchInsert(nums, target)"]) --> I["l = 0, r = n - 1"]
I --> W{"l <= r?"}
W -- yes --> M["m = (l + r) // 2"]
M --> Q{"nums[m] vs target"}
Q -- equal --> R(["return m"])
Q -- "nums[m] < target" --> L["l = m + 1"]
L --> W
Q -- "nums[m] > target" --> H["r = m - 1"]
H --> W
W -- no --> E(["return (l + r) // 2 + 1 — the insert slot"])
```
## Solution
```py
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
l, r = 0, len(nums) - 1
while l <= r:
m = (l + r) // 2
if nums[m] == target:
return m
elif nums[m] < target:
l = m + 1
elif nums[m] > target:
r = m - 1
return (l + r) // 2 + 1
```
## Explanation
<YouTube id="K-RYzDZkzCI" title="Search Insert Position - Binary Search - Leetcode 35 - Python" />
@@ -1,12 +1,13 @@
---
title: '704. Binary Search'
description: Given an array of integers nums which is sorted in ascending order, and an integer target, write a function to search target in nums. If target exists, then return its index. Otherwise, return -1
icon: dot
sidebar:
label: 'Binary Search'
badge: 'Easy'
---
<Badge variant="accent">Binary Search</Badge>
<Badge variant="accent">Array</Badge>
::::warning
You must write an algorithm with O(log n) runtime complexity.
@@ -29,6 +30,22 @@ You must write an algorithm with O(log n) runtime complexity.
- All the integers in `nums` are unique.
- `nums` is sorted in ascending order.
## Approach
```mermaid
flowchart TD
S(["search(nums, target)"]) --> I["left = 0, right = n - 1"]
I --> W{"left <= right?"}
W -- no --> E(["return -1"])
W -- yes --> M["mid = left + floor((right - left) / 2)"]
M --> Q{"nums[mid] vs target"}
Q -- equal --> R(["return mid"])
Q -- "nums[mid] < target" --> L["left = mid + 1"]
L --> W
Q -- "nums[mid] > target" --> H["right = mid - 1"]
H --> W
```
## Solution
```js
@@ -54,3 +71,7 @@ var search = function(nums, target) {
return -1;
};
```
## Explanation
<YouTube id="s4DPM8ct1pI" title="Binary Search - Leetcode 704 - Python" />
@@ -0,0 +1,92 @@
---
title: '74. Search a 2D Matrix'
description: 'You are given an m x n integer matrix matrix with the following two properties:'
icon: dot
sidebar:
label: 'Search a 2D Matrix'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
::::warning
You must write a solution in O(log(m * n)) time complexity.
::::
### Example 1:
- Input: `matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 3`
- Output: `true`
### Example 2:
- Input: `matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 13`
- Output: `false`
### Constraints:
- `m == matrix.length`
- `n == matrix[i].length`
- `1 <= m, n <= 100`
- `-10^4 <= matrix[i][j], target <= 10^4`
## Approach
```mermaid
flowchart TD
S(["searchMatrix(matrix, target)"]) --> I["top = 0, bot = ROWS - 1"]
I --> W1{"top <= bot?"}
W1 -- yes --> R1["row = (top + bot) // 2"]
R1 --> C1{"target vs that row's range"}
C1 -- "above matrix[row][-1]" --> U["top = row + 1"]
C1 -- "below matrix[row][0]" --> V["bot = row - 1"]
C1 -- inside --> B(["break — row found"])
U --> W1
V --> W1
W1 -- no --> F(["return False — no row can hold it"])
B --> P["row = (top + bot) // 2; l = 0, r = COLS - 1"]
P --> W2{"l <= r?"}
W2 -- no --> G(["return False"])
W2 -- yes --> M["m = (l + r) // 2"]
M --> C2{"target vs matrix[row][m]"}
C2 -- greater --> X["l = m + 1"]
C2 -- smaller --> Y["r = m - 1"]
C2 -- equal --> T(["return True"])
X --> W2
Y --> W2
```
## Solution
```py
class Solution:
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
ROWS, COLS = len(matrix), len(matrix[0])
top, bot = 0, ROWS - 1
while top <= bot:
row = (top + bot) // 2
if target > matrix[row][-1]:
top = row + 1
elif target < matrix[row][0]:
bot = row - 1
else:
break
if not (top <= bot):
return False
row = (top + bot) // 2
l, r = 0, COLS - 1
while l <= r:
m = (l + r) // 2
if target > matrix[row][m]:
l = m + 1
elif target < matrix[row][m]:
r = m - 1
else:
return True
return False
```
## Explanation
<YouTube id="Ber2pi2C0j0" title="Search a 2D Matrix - Leetcode 74 - Python" />
@@ -1,12 +1,13 @@
---
title: '875. Koko Eating Bananas'
description: Koko loves to eat bananas. There are n piles of bananas, the i^th pile has piles[i] bananas. The guards have gone and will come back in h hours
icon: dot
sidebar:
label: 'Koko Eating Bananas'
badge: 'Medium'
---
<Badge variant="accent">Binary Search</Badge>
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `piles = [3,6,7,11], h = 8`
@@ -26,6 +27,22 @@ sidebar:
- `piles.length <= h <= 10^9`
- `1 <= piles[i] <= 10^9`
## Approach
```mermaid
flowchart TD
S(["minEatingSpeed(piles, h)"]) --> I["l = 1, r = max(piles)"]
I --> W{"l < r?"}
W -- no --> E(["return l — the smallest workable speed"])
W -- yes --> M["mid = floor((l + r) / 2)"]
M --> K["kWorks(mid): hours = sum of ceil(p / mid)"]
K --> Q{"hours <= h?"}
Q -- yes --> A["r = mid — keep mid, try slower"]
Q -- no --> B["l = mid + 1 — too slow, speed up"]
A --> W
B --> W
```
## Solution
```js
@@ -60,3 +77,7 @@ var minEatingSpeed = function(piles, h) {
return l;
};
```
## Explanation
<YouTube id="U2SozAs9RzA" title="Koko Eating Bananas - Binary Search - Leetcode 875 - Python" />
@@ -0,0 +1,5 @@
---
title: Binary Search
description: 'Half of a sorted range is removed at each step until the target is found.'
hidden: true
---
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Binary Search", order: 5, icon: "search", display: "group" });
@@ -0,0 +1,66 @@
---
title: '1. Two Sum'
description: You are given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target
icon: dot
sidebar:
label: 'Two Sum'
badge: 'Easy'
---
<Badge variant="accent">Array</Badge>
::::warning
Can you come up with an algorithm that is less than O(n^2) time complexity?
::::
### Example 1:
- Input: `nums = [2,7,11,15], target = 9`
- Output: `[0,1]`
- Explanation: Because `nums[0] + nums[1]` == `9`, we return [0, 1].
### Example 2:
- Input: `nums = [3,2,4], target = 6`
- Output: `[1,2]`
### Example 3:
- Input: `nums = [3,3], target = 6`
- Output: `[0,1]`
### Constraints:
- `2 <= nums.length <= 10^4`
- `-10^9 <= nums[i] <= 10^9`
- `-10^9 <= target <= 10^9`
- Only one valid answer exists.
## Approach
```mermaid
flowchart TD
S(["twoSum(nums, target)"]) --> I["seen = {} — value to index"]
I --> L{"more (i, num) in nums?"}
L -- no --> E(["return []"])
L -- yes --> C["complement = target - num"]
C --> H{"complement in seen?"}
H -- yes --> R(["return [seen[complement], i]"])
H -- no --> W["seen[num] = i"]
W --> L
```
## Solution
```py
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
seen = {}
for i, num in enumerate(nums):
complement = target - num
if complement in seen:
return [seen[complement], i]
seen[num] = i
return []
```
## Explanation
<YouTube id="KLlXCFG5TnA" title="Two Sum - Leetcode 1 - HashMap - Python" />
@@ -1,6 +1,7 @@
---
title: '1832. Check if the Sentence Is Pangram'
description: A pangram is a sentence where every letter of the English alphabet appears at least once
icon: dot
sidebar:
label: 'Check if the Sentence Is Pangram'
badge: 'Easy'
@@ -22,6 +23,19 @@ sidebar:
- `1 <= sentence.length <= 1000`
- `sentence` consists of lowercase English letters.
## Approach
```mermaid
flowchart TD
S(["checkIfPangram(sentence)"]) --> I["freq = {}"]
I --> L{"more char c?"}
L -- yes --> C["freq[c] = (freq[c] or 0) + 1"]
C --> L
L -- no --> Q{"distinct keys in freq == 26?"}
Q -- yes --> R(["return true"])
Q -- no --> F(["return false"])
```
## Solution
```js
@@ -1,12 +1,13 @@
---
title: '217. Contains Duplicate'
description: Given an integer array nums, return true if any value appears at least twice in the array, and return false if every element is distinct
icon: dot
sidebar:
label: 'Contains Duplicate'
badge: 'Easy'
---
<Badge variant="accent">Hash Table</Badge>
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `nums = [1,2,3,1]`
@@ -27,21 +28,27 @@ sidebar:
- `1 <= nums.length <= 10^5`
- `-10^9 <= nums[i] <= 10^9`
## Approach
```mermaid
flowchart TD
S(["containsDuplicate(nums)"]) --> I["freq = {}"]
I --> L{"more n in nums?"}
L -- no --> E(["return false"])
L -- yes --> C["freq[n] = (freq[n] or 0) + 1"]
C --> Q{"freq[n] >= 2?"}
Q -- yes --> R(["return true"])
Q -- no --> L
```
## Solution
```js
/**
* @param {number[]} nums
* @return {boolean}
*/
var containsDuplicate = function(nums) {
const freq = {};
for (let n of nums) {
freq[n] = (freq[n] || 0) + 1;
if (freq[n] >= 2) {
return true;
}
}
return false;
};
```py
class Solution:
def containsDuplicate(self, nums: List[int]) -> bool:
return len(set(nums)) != len(nums)
```
## Explanation
<YouTube id="3OamzN90kPg" title="Contains Duplicate - Leetcode 217 - Python" />
@@ -1,6 +1,7 @@
---
title: '242. Valid Anagram'
description: Given two strings s and t, return true if t is an anagram of s, and false otherwise
icon: dot
sidebar:
label: 'Valid Anagram'
badge: 'Easy'
@@ -25,6 +26,19 @@ What if the inputs contain Unicode characters? How would you adapt your solution
- `1 <= s.length, t.length <= 5 * 10^4`
- `s` and `t` consist of lowercase English letters.
## Approach
```mermaid
flowchart TD
S(["isAnagram(s, t)"]) --> I["freq: count every char of s"]
I --> L{"more char c in t?"}
L -- no --> E(["return true"])
L -- yes --> Q{"freq[c] missing or 0?"}
Q -- yes --> R(["return false — t has a char s cannot cover"])
Q -- no --> D["freq[c] -= 1"]
D --> L
```
## Solution
```js
@@ -46,3 +60,7 @@ var isAnagram = function(s, t) {
return true;
};
```
## Explanation
<YouTube id="9UtInBqnCgA" title="Valid Anagram - Leetcode 242 - Python" />
@@ -1,12 +1,13 @@
---
title: '347. Top K Frequent Elements'
description: Given an integer array nums and an integer k, return the k most frequent elements. You may return the answer in any order
icon: dot
sidebar:
label: 'Top K Frequent Elements'
badge: 'Medium'
---
<Badge variant="accent">Hash Table</Badge>
<Badge variant="accent">Array</Badge>
::::warning
Your algorithm's time complexity must be better than O(n log n), where n is the array's size.
@@ -31,6 +32,16 @@ Your algorithm's time complexity must be better than O(n log n), where n is the
- `k` is in the range `[1, the number of unique elements in the array]`.
- It is guaranteed that the answer is unique.
## Approach
```mermaid
flowchart TD
S(["topKFrequent(nums, k)"]) --> F["freq: count every value in nums"]
F --> K["keys of freq, cast to Number"]
K --> O["sort by freq[b] - freq[a] — most frequent first"]
O --> R(["return slice(0, k)"])
```
## Solution
```js
@@ -49,3 +60,7 @@ var topKFrequent = function (nums, k) {
.slice(0, k);
};
```
## Explanation
<YouTube id="YPTqKIgVk-k" title="Top K Frequent Elements - Bucket Sort - Leetcode 347 - Python" />
@@ -1,6 +1,7 @@
---
title: '387. First Unique Character in a String'
description: Given a string s, find the first non-repeating character in it and return its index. If it does not exist, return -1
icon: dot
sidebar:
label: 'First Unique Character in a String'
badge: 'Easy'
@@ -26,6 +27,18 @@ sidebar:
- `1 <= s.length <= 10^5`
- `s` consists of only lowercase English letters.
## Approach
```mermaid
flowchart TD
S(["firstUniqChar(s)"]) --> F["Pass 1 — freq: count every char of s"]
F --> L{"Pass 2 — more i in 0..len-1?"}
L -- no --> E(["return -1"])
L -- yes --> Q{"freq[s[i]] == 1?"}
Q -- yes --> R(["return i — leftmost wins because the scan is in order"])
Q -- no --> L
```
## Solution
```js
@@ -45,3 +58,7 @@ var firstUniqChar = function (s) {
return -1;
};
```
## Explanation
<YouTube id="rBENYgWy3xU" title="First Unique Character in a String - Leetcode 387 - Python" />
@@ -1,6 +1,7 @@
---
title: '451. Sort Characters By Frequency'
description: Given a string s, sort it in decreasing order based on the frequency of the characters. The frequency of a character is the number of times it appears in the string
icon: dot
sidebar:
label: 'Sort Characters By Frequency'
badge: 'Medium'
@@ -28,6 +29,18 @@ sidebar:
- `1 <= s.length <= 5 * 10^5`
- `s` consists of uppercase and lowercase English letters and digits.
## Approach
```mermaid
flowchart TD
S(["frequencySort(s)"]) --> F["freq: count every char of s"]
F --> C["split s and sort with a two-key comparator"]
C --> K1["primary: freq[b] - freq[a] — most frequent first"]
C --> K2["tie-break: a.localeCompare(b) — alphabetical"]
K1 --> R(["join and return"])
K2 --> R
```
## Solution
```js
@@ -47,3 +60,7 @@ var frequencySort = function (s) {
.join("");
};
```
## Explanation
<YouTube id="OXdXc9HTrIg" title="Sort Characters By Frequency - Leetcode 451 - Python" />
@@ -1,12 +1,13 @@
---
title: '49. Group Anagrams'
description: Given an array of strings strs, group the anagrams together. You can return the answer in any order
icon: dot
sidebar:
label: 'Group Anagrams'
badge: 'Medium'
---
<Badge variant="accent">Hash Table</Badge>
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `strs = ["eat","tea","tan","ate","nat","bat"]`
@@ -27,6 +28,21 @@ sidebar:
- `0 <= strs[i].length <= 100`
- `strs[i]` consists of lowercase English letters.
## Approach
```mermaid
flowchart TD
S(["groupAnagrams(strs)"]) --> K["sorted[i] = letters of strs[i], sorted — the anagram key"]
K --> I["anagrams = {}"]
I --> L{"more i in 0..n-1?"}
L -- no --> E(["return Object.values(anagrams)"])
L -- yes --> Q{"anagrams has sorted[i]?"}
Q -- no --> N["anagrams[sorted[i]] = [strs[i]]"]
Q -- yes --> P["anagrams[sorted[i]].push(strs[i])"]
N --> L
P --> L
```
## Solution
```js
@@ -49,3 +65,7 @@ var groupAnagrams = function(strs) {
return Object.values(anagrams);
};
```
## Explanation
<YouTube id="vzdNOK2oB2E" title="Group Anagrams - Categorize Strings by Count - Leetcode 49" />
@@ -0,0 +1,5 @@
---
title: Hash Table
description: 'A key maps to a value in constant time, which removes the need for a full scan.'
hidden: true
---
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Hash Table", order: 1, icon: "hash", display: "group" });
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Phase 1: Linear Structures", order: 1 });
@@ -0,0 +1,60 @@
---
title: '739. Daily Temperatures'
description: Given an array of integers temperatures represents the daily temperatures, return an array answer such that answer[i] is the number of days you have to wait after the i^th day to get a warmer temperature. If there is no future day for which this is possible, keep answer[i] == 0 instead
icon: dot
sidebar:
label: 'Daily Temperatures'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `temperatures = [73,74,75,71,69,72,76,73]`
- Output: `[1,1,4,2,1,1,0,0]`
### Example 2:
- Input: `temperatures = [30,40,50,60]`
- Output: `[1,1,1,0]`
### Example 3:
- Input: `temperatures = [30,60,90]`
- Output: `[1,1,0]`
### Constraints:
- `1 <= temperatures.length <= 10^5`
- `30 <= temperatures[i] <= 100`
## Approach
```mermaid
flowchart TD
S(["dailyTemperatures(temperatures)"]) --> I["ans = zeros(n), stack = [] — indices of days still waiting"]
I --> L{"more (i, temp)?"}
L -- no --> E(["return ans"])
L -- yes --> W{"stack non-empty and temperatures[stack[-1]] < temp?"}
W -- yes --> P["prev = stack.pop(); ans[prev] = i - prev — today resolves that day"]
P --> W
W -- no --> A["stack.append(i)"]
A --> L
```
## Solution
```py
class Solution:
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
ans = [0] * len(temperatures)
stack = []
for i, temp in enumerate(temperatures):
while stack and temperatures[stack[-1]] < temp:
prev = stack.pop()
ans[prev] = i - prev
stack.append(i)
return ans
```
## Explanation
<YouTube id="cTBiBSnjO3c" title="Daily Temperatures - Monotonic Stack - Leetcode 739 - Python" />
@@ -0,0 +1,68 @@
---
title: '853. Car Fleet'
description: There are n cars at given miles away from the starting mile 0, traveling to reach the mile target
icon: dot
sidebar:
label: 'Car Fleet'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `target = 12, position = [10,8,0,5,3], speed = [2,4,1,1,3]`
- Output: `3`
- Explanation: • The cars starting at `10 (speed 2`) and `8 (speed 4`) become a fleet, meeting each other at `12`. The fleet forms at `target`. • The car starting at `0 (speed 1`) does not catch up to any other car, so it is a fleet by itself. • The cars starting at `5 (speed 1`) and `3 (speed 3`) become a fleet, meeting each other at `6`. The fleet moves at `speed 1` until it reaches `target`.
### Example 2:
- Input: `target = 10, position = [3], speed = [3]`
- Output: `1`
- Explanation: There is only one car, hence there is only one fleet.
### Example 3:
- Input: `target = 100, position = [0,2,4], speed = [4,2,1]`
- Output: `1`
- Explanation: • The cars starting at `0 (speed 4`) and `2 (speed 2`) become a fleet, meeting each other at `4`. The car starting at `4 (speed 1`) travels to `5`. • Then, the fleet at `4 (speed 2`) and the car at `position 5 (speed 1`) become one fleet, meeting each other at `6`. The fleet moves at `speed 1` until it reaches `target`.
### Constraints:
- `n == position.length == speed.length`
- `1 <= n <= 10^5`
- `0 < target <= 10^6`
- `0 <= position[i] < target`
- All the values of `position` are unique.
- `0 < speed[i] <= 10^6`
## Approach
```mermaid
flowchart TD
S(["carFleet(target, position, speed)"]) --> O["zip and sort by position, descending — nearest the target first"]
O --> I["stack = [] — arrival time of each fleet's leader"]
I --> L{"more (pos, spd)?"}
L -- no --> E(["return len(stack) — one entry per fleet"])
L -- yes --> T["time = (target - pos) / spd"]
T --> Q{"stack empty or time > stack[-1]?"}
Q -- yes --> A["stack.append(time) — slower, so it starts a new fleet"]
Q -- no --> B["discard — it catches the car ahead and joins that fleet"]
A --> L
B --> L
```
## Solution
```py
class Solution:
def carFleet(self, target: int, position: List[int], speed: List[int]) -> int:
stack = []
for pos, spd in sorted(zip(position, speed), reverse=True):
time = (target - pos) / spd
if not stack or time > stack[-1]:
stack.append(time)
return len(stack)
```
## Explanation
<YouTube id="Pr6T-3yB9RM" title="Car Fleet - Leetcode 853 - Python" />
@@ -0,0 +1,5 @@
---
title: Monotonic Stack
description: 'A stack keeps its items in sorted order, which finds the next larger or smaller item in one pass.'
hidden: true
---
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Monotonic Stack", order: 7, icon: "trending-up", display: "group" });
@@ -1,12 +1,13 @@
---
title: '238. Product of Array Except Self'
description: Given an integer array nums, return an array answer such that answer[i] is equal to the product of all the elements of nums except nums[i]
icon: dot
sidebar:
label: 'Product of Array Except Self'
badge: 'Medium'
---
<Badge variant="accent">Prefix Sum</Badge>
<Badge variant="accent">Array</Badge>
::::warning
You must write an algorithm that runs in O(n) time and without using the division operation.
@@ -27,6 +28,17 @@ Can you solve the problem in O(1) extra space complexity? (The output array does
- `-30 <= nums[i] <= 30`
- The input is generated such that `answer[i]` is guaranteed to fit in a 32-bit integer.
## Approach
```mermaid
flowchart TD
S(["productExceptSelf(nums)"]) --> I["answer = ones(n), rightArr = ones(n)"]
I --> P1["Pass 1 — left to right: answer[i] = nums[i-1] * answer[i-1]"]
P1 --> P2["Pass 2 — right to left: rightArr[i] = nums[i+1] * rightArr[i+1]"]
P2 --> P3["Pass 3: answer[i] *= rightArr[i]"]
P3 --> E(["return answer — no division needed"])
```
## Solution
```js
@@ -51,3 +63,7 @@ var productExceptSelf = function(nums) {
return answer;
};
```
## Explanation
<YouTube id="bNvIQI2wAjk" title="Product of Array Except Self - Leetcode 238 - Python" />
@@ -1,12 +1,13 @@
---
title: '303. Range Sum Query - Immutable'
description: Given an integer array nums, handle multiple queries of the following type
icon: dot
sidebar:
label: 'Range Sum Query - Immutable'
badge: 'Easy'
---
<Badge variant="accent">Prefix Sum</Badge>
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `["NumArray", "sumRange", "sumRange", "sumRange"]` `[[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]]`
@@ -20,6 +21,21 @@ sidebar:
- `0 <= left <= right < nums.length`
- At most `10^4` calls will be made to `sumRange`.
## Approach
```mermaid
flowchart TD
subgraph build["constructor(nums) — O(n) once"]
A["prefix = [0]"] --> B{"more n in nums?"}
B -- yes --> C["prefix.push(last + n)"]
C --> B
end
build --> Q
subgraph Q["sumRange(left, right) — O(1) per call"]
D(["return prefix[right + 1] - prefix[left]"])
end
```
## Solution
```js
@@ -50,3 +66,7 @@ class NumArray {
* var param_1 = obj.sumRange(left,right)
*/
```
## Explanation
<YouTube id="2pndAmo_sMA" title="Range Sum Query Immutable - Leetcode 303 - Python" />
@@ -1,12 +1,13 @@
---
title: '560. Subarray Sum Equals K'
description: Given an array of integers nums and an integer k, return the total number of subarrays whose sum equals to k
icon: dot
sidebar:
label: 'Subarray Sum Equals K'
badge: 'Medium'
---
<Badge variant="accent">Prefix Sum</Badge>
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `nums = [1,1,1], k = 2`
@@ -22,6 +23,21 @@ sidebar:
- `-1000 <= nums[i] <= 1000`
- `-10^7 <= k <= 10^7`
## Approach
```mermaid
flowchart TD
S(["subarraySum(nums, k)"]) --> I["map = {0: 1} — the empty prefix; sum = 0, count = 0"]
I --> L{"more n in nums?"}
L -- no --> E(["return count"])
L -- yes --> A["sum += n"]
A --> Q{"map has sum - k?"}
Q -- yes --> C["count += map.get(sum - k) — every earlier prefix that closes a window"]
Q -- no --> W["map.set(sum, count of sum + 1)"]
C --> W
W --> L
```
## Solution
```js
@@ -49,3 +65,7 @@ var subarraySum = function(nums, k) {
return count
};
```
## Explanation
<YouTube id="fFVZt-6sgyo" title="Subarray Sum Equals K - Prefix Sums - Leetcode 560 - Python" />
@@ -0,0 +1,5 @@
---
title: Prefix Sum
description: 'A running total is stored at each index, so the sum of any range is one subtraction.'
hidden: true
---
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Prefix Sum", order: 2, icon: "sigma", display: "group" });
@@ -1,12 +1,13 @@
---
title: '121. Best Time to Buy and Sell Stock'
description: You are given an array prices where prices[i] is the price of a given stock on the i^th day
icon: dot
sidebar:
label: 'Best Time to Buy and Sell Stock'
badge: 'Easy'
---
<Badge variant="accent">Sliding Window</Badge>
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `prices = [7,1,5,3,6,4]`
@@ -23,6 +24,24 @@ sidebar:
- `1 <= prices.length <= 10^5`
- `0 <= prices[i] <= 10^4`
## Approach
::::warning
The diagram traces the code below literally. That code does not run: `curr` and `ans` are never initialised, so the first `curr > left` test raises `NameError`, and `left` holds a price rather than an index, so `prices[left]` indexes by value.
::::
```mermaid
flowchart TD
S(["maxProfit(prices)"]) --> I["left = min(prices)"]
I --> F{"more right in range(len(prices))?"}
F -- no --> E(["return ans"])
F -- yes --> W{"curr > left?"}
W -- yes --> P["curr -= prices[left]; left += 1"]
P --> W
W -- no --> M["ans = max(ans, curr)"]
M --> F
```
## Solution
```py
@@ -36,3 +55,7 @@ class Solution:
ans = max(ans, curr)
return ans
```
## Explanation
<YouTube id="1pkOgXD63yU" title="Sliding Window: Best Time to Buy and Sell Stock - Leetcode 121 - Python" />
@@ -0,0 +1,67 @@
---
title: '3. Longest Substring Without Repeating Characters'
description: Given a string s, find the length of the longest substring without duplicate characters
icon: dot
sidebar:
label: 'Longest Substring Without Repeating Characters'
badge: 'Medium'
---
<Badge variant="accent">Hash Table</Badge>
### Example 1:
- Input: `s = "abcabcbb"`
- Output: `3`
- Explanation: The answer is "abc", with the length of `3`. Note that "bca" and "cab" are also correct answers.
### Example 2:
- Input: `s = "bbbbb"`
- Output: `1`
- Explanation: The answer is "b", with the length of `1`.
### Example 3:
- Input: `s = "pwwkew"`
- Output: `3`
- Explanation: The answer is "wke", with the length of `3`. Notice that the answer must be a substring, "pwke" is a subsequence and not a substring.
### Constraints:
- `0 <= s.length <= 10^5`
- `s` consists of English letters, digits, symbols and spaces.
## Approach
```mermaid
flowchart TD
S(["lengthOfLongestSubstring(s)"]) --> I["left = 0, ans = 0, window = set()"]
I --> L{"more (right, c) in s?"}
L -- no --> E(["return ans"])
L -- yes --> W{"c already in window?"}
W -- yes --> P["window.remove(s[left]); left += 1 — shrink until c is free"]
P --> W
W -- no --> A["window.add(c)"]
A --> M["ans = max(ans, right - left + 1)"]
M --> L
```
## Solution
```py
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
left = 0
ans = 0
window = set()
for right, c in enumerate(s):
while c in window:
window.remove(s[left])
left += 1
window.add(c)
ans = max(ans, right - left + 1)
return ans
```
## Explanation
<YouTube id="wiGpQwVHdE0" title="Longest Substring Without Repeating Characters - Leetcode 3 - Python" />
@@ -0,0 +1,67 @@
---
title: '424. Longest Repeating Character Replacement'
description: You are given a string s and an integer k. You can choose any character of the string and change it to any other uppercase English character. You can perform this operation at most k times
icon: dot
sidebar:
label: 'Longest Repeating Character Replacement'
badge: 'Medium'
---
<Badge variant="accent">Hash Table</Badge>
### Example 1:
- Input: `s = "ABAB", k = 2`
- Output: `4`
- Explanation: Replace the two 'A's with two 'B's or vice versa.
### Example 2:
- Input: `s = "AABABBA", k = 1`
- Output: `4`
- Explanation: Replace the one 'A' in the middle with 'B' and form "AABBBBA". The substring "BBBB" has the longest repeating letters, which is `4`. There may exists other ways to achieve this answer too.
### Constraints:
- `1 <= s.length <= 10^5`
- `s` consists of only uppercase English letters.
- `0 <= k <= s.length`
## Approach
```mermaid
flowchart TD
S(["characterReplacement(s, k)"]) --> I["count = {}, res = 0, l = 0, maxF = 0"]
I --> L{"more (r, c) in s?"}
L -- no --> E(["return res"])
L -- yes --> A["count[c] += 1; maxF = max(maxF, count[c])"]
A --> W{"(r - l + 1) - maxF > k? — too many chars to replace"}
W -- yes --> P["count[s[l]] -= 1; l += 1 — shrink from the left"]
P --> W
W -- no --> M["res = max(res, r - l + 1)"]
M --> L
```
## Solution
```py
class Solution:
def characterReplacement(self, s: str, k: int) -> int:
count = {}
res = 0
l = 0
maxF = 0
for r, c in enumerate(s):
count[c] = 1 + count.get(c, 0)
maxF = max(maxF, count[c])
while (r - l + 1) - maxF > k:
count[s[l]] -= 1
l += 1
res = max(res, r - l + 1)
return res
```
## Explanation
<YouTube id="gqXU1UyA8pk" title="Longest Repeating Character Replacement - Leetcode 424 - Python" />
@@ -0,0 +1,74 @@
---
title: '567. Permutation in String'
description: Given two strings s1 and s2, return true if s2 contains a permutation of s1, or false otherwise
icon: dot
sidebar:
label: 'Permutation in String'
badge: 'Medium'
---
<Badge variant="accent">Hash Table</Badge>
### Example 1:
- Input: `s1 = "ab", s2 = "eidbaooo"`
- Output: `true`
- Explanation: `s2` contains one permutation of `s1` ("ba").
### Example 2:
- Input: `s1 = "ab", s2 = "eidboaoo"`
- Output: `false`
### Constraints:
- `1 <= s1.length, s2.length <= 10^4`
- `s1` and `s2` consist of lowercase English letters.
## Approach
```mermaid
flowchart TD
S(["checkInclusion(s1, s2)"]) --> G{"k = len(s1) > len(s2)?"}
G -- yes --> X(["return False"])
G -- no --> I["need = Counter(s1), window = Counter()"]
I --> L{"more (right, c) in s2?"}
L -- no --> E(["return False"])
L -- yes --> A["window[c] += 1"]
A --> B{"right >= k? — window is now longer than k"}
B -- yes --> C["drop s2[right - k] from window, deleting the key at 0"]
B -- no --> Q
C --> Q{"window == need?"}
Q -- yes --> R(["return True"])
Q -- no --> L
```
## Solution
```py
from collections import Counter
class Solution:
def checkInclusion(self, s1: str, s2: str) -> bool:
k = len(s1)
if k > len(s2):
return False
need = Counter(s1)
window = Counter()
for right, c in enumerate(s2):
window[c] += 1
if right >= k:
left_char = s2[right - k]
window[left_char] -= 1
if window[left_char] == 0:
del window[left_char]
if window == need:
return True
return False
```
## Explanation
<YouTube id="UbyhOgBN834" title="Permutation in String - Leetcode 567 - Python" />
@@ -0,0 +1,104 @@
---
title: Sliding Window
description: 'Sliding window is a specialized two-pointer family where the pointers define a contiguous interval and you maintain information about that entire interval as it moves.'
hidden: true
---
:::warning
This is the family where invariants matter the most
:::
## What Makes Sliding Window Different?
Many problems have two moving boundaries. That alone does not make them sliding window problems.
A problem belongs to this family when:
- the answer depends on the subarray or substring between left and right
- you can update the window state incrementally as elements enter or leave
- you maintain a condition that tells you whether the window is valid
## What is an Invariant?
An invariant is a statement that stays true before and after every iteration.
For sliding window, the invariant often looks like one of these:
- "the current window is valid"
- "the current window contains no duplicates"
- "the current window sum is at most the target"
- "the frequency map matches the characters currently inside the window"
The invariant gives the loop its structure:
- expand the window by moving right
- update the window state
- if the invariant breaks, shrink from the left until it holds again
- update the answer only when you know the invariant is satisfied
## Why Invariants Matter
Without an invariant, shrinking the window becomes arbitrary. You no longer know:
- when the window is valid
- when to update the answer
- why the left pointer should stop moving
- The invariant is the reason the algorithm is correct.
## The Main Subtypes
### 1. Fixed-size windows
:::tip
The window size never changes. The main task is updating the window state efficiently when one element enters and one leaves.
:::
![Sliding Window Fixed Size](../../../public/sliding-window-fixed-size.svg)
### 2. Flexible-size windows with a validity rule
:::tip
Here the window expands until invalid, then shrinks until valid again.
:::
![Flexible-size windows](../../../public/sliding-window-flexible.svg)
### 3. Frequency-tracking windows
:::tip
The difficulty comes from keeping the bookkeeping correct as characters enter and leave.
:::
![Frequency-tracking windows](../../../public/sliding-window-frequency.svg)
## How Sliding Window Differs From Other Two-Pointer Families
Compare it with Container With Most Water:
- In sliding window, the interval itself has meaning.
- In container, the interval is just the remaining search space.
Compare it with Remove Duplicates:
- In sliding window, both pointers describe a live window.
- In compaction, the left part of the array is already the answer prefix.
## A Practical Template
A common variable-size sliding window has this shape:
1. Add the new rightmost element.
2. Update the window state.
3. While the invariant is broken, remove elements from the left.
4. Once the invariant is restored, update the answer.
The exact data structure changes from problem to problem, but the logic stays the same.
As you work through this section, keep asking:
:::warning
What invariant does my window maintain, and at what moment is it safe to update the answer?
:::
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Sliding Window", order: 4, icon: "app-window", display: "group" });
@@ -0,0 +1,75 @@
---
title: '150. Evaluate Reverse Polish Notation'
description: You are given an array of strings tokens that represents an arithmetic expression in a Reverse Polish Notation
icon: dot
sidebar:
label: 'Evaluate Reverse Polish Notation'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `tokens = ["2","1","+","3","*"]`
- Output: `9`
- Explanation: `((2 + 1`) * `3`) = `9`
### Example 2:
- Input: `tokens = ["4","13","5","/","+"]`
- Output: `6`
- Explanation: `(4 + (13 / 5`)) = `6`
### Example 3:
- Input: `tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"]`
- Output: `22`
- Explanation: `((10 * (6 / ((9 + 3`) * `-11`))) + `17`) + `5 = ((10 * (6 / (12 * -11`))) + `17`) + `5 = ((10 * (6 / -132`)) + `17`) + `5 = ((10 * 0`) + `17`) + `5 = (0 + 17`) + `5 = 17 + 5 = 22`
### Constraints:
- `1 <= tokens.length <= 10^4`
- `tokens[i]` is either an operator: "+", "-", "*", or "/", or an integer in the range [-200, 200].
## Approach
```mermaid
flowchart TD
S(["evalRPN(tokens)"]) --> I["stack = [], operations = + - * /"]
I --> L{"more token c?"}
L -- no --> E(["return stack[0]"])
L -- yes --> Q{"c is an operator?"}
Q -- no --> N["stack.append(int(c))"]
N --> L
Q -- yes --> P["y = stack.pop(), x = stack.pop() — order matters"]
P --> C["stack.append(operations[c](x, y))"]
C --> L
```
## Solution
```py
class Solution:
def evalRPN(self, tokens: List[str]) -> int:
stack = []
operations = {
"+": lambda x, y: int(x + y),
"-": lambda x, y: int(x - y),
"*": lambda x, y: int(x * y),
"/": lambda x, y: int(x / y),
}
for c in tokens:
if c in operations:
y = stack.pop()
x = stack.pop()
calc_func = operations[c]
result = calc_func(x, y)
stack.append(result)
else:
stack.append(int(c))
return stack[0]
```
## Explanation
<YouTube id="iu0082c4HDE" title="Evaluate Reverse Polish Notation - Leetcode 150 - Python" />
@@ -0,0 +1,83 @@
---
title: '155. Min Stack'
description: Design a stack that supports push, pop, top, and retrieving the minimum element in constant time
icon: dot
sidebar:
label: 'Min Stack'
badge: 'Medium'
---
<Badge variant="accent">Stack</Badge>
::::warning
You must implement a solution with O(1) time complexity for each function.
::::
### Example 1:
- Input: ``
- Output: ``
- Explanation: Input `["MinStack","push","push","push","getMin","pop","top","getMin"]` [[],[-2],[0],[-3],[],[],[],[]] Output `[null,null,null,null,-3,null,0,-2]` Explanation MinStack minStack = new MinStack(); minStack.push(-2); minStack.push(0); minStack.push(-3); minStack.getMin(); // return `-3` minStack.pop(); minStack.top(); // return `0` minStack.getMin(); // return `-2`
### Constraints:
- `-2^31 <= val <= 2^31 - 1`
- Methods pop, top and getMin operations will always be called on non-empty stacks.
- At most 3 * 10^4 calls will be made to push, pop, top, and getMin.
## Approach
```mermaid
flowchart TD
subgraph state["two parallel stacks — same depth, always"]
A["stack — the values"]
B["minStack — the minimum as of that depth"]
end
state --> P
subgraph P["push(value)"]
P1["stack.append(value)"] --> P2["minStack.append(min(value, minStack[-1]))"]
end
state --> O
subgraph O["pop()"]
O1["pop both stacks together"]
end
state --> R
subgraph R["top() / getMin() — O(1)"]
R1(["stack[-1] / minStack[-1]"])
end
```
## Solution
```py
class MinStack:
def __init__(self):
self.stack = []
self.minStack = []
def push(self, value: int) -> None:
self.stack.append(value)
value = min(value, self.minStack[-1] if self.minStack else value)
self.minStack.append(value)
def pop(self) -> None:
self.stack.pop()
self.minStack.pop()
def top(self) -> int:
return self.stack[-1]
def getMin(self) -> int:
return self.minStack[-1]
# Your MinStack object will be instantiated and called as such:
# obj = MinStack()
# obj.push(value)
# obj.pop()
# param_3 = obj.top()
# param_4 = obj.getMin()
```
## Explanation
<YouTube id="qkLl7nAwDPo" title="Design Min Stack - Amazon Interview Question - Leetcode 155 - Python" />
@@ -0,0 +1,77 @@
---
title: '20. Valid Parentheses'
description: Given a string s containing just the characters '(', ')', '{', '}', '[' and ']', determine if the input string is valid
icon: dot
sidebar:
label: 'Valid Parentheses'
badge: 'Easy'
---
<Badge variant="accent">String</Badge>
### Example 1:
- Input: `s = "()"`
- Output: `true`
### Example 2:
- Input: `s = "()[]{}"`
- Output: `true`
### Example 3:
- Input: `s = "(]"`
- Output: `false`
### Example 4:
- Input: `s = "([])"`
- Output: `true`
### Example 5:
- Input: `s = "([)]"`
- Output: `false`
### Constraints:
- `1 <= s.length <= 10^4`
- `s` consists of parentheses only '()[]{}'.
## Approach
```mermaid
flowchart TD
S(["isValid(s)"]) --> I["stack = [], pairs = closer to opener"]
I --> L{"more char c?"}
L -- no --> Z{"stack empty?"}
Z -- yes --> T(["return True"])
Z -- no --> U(["return False — unclosed openers left"])
L -- yes --> Q{"c is a closer?"}
Q -- no --> A["stack.append(c) — it is an opener"]
A --> L
Q -- yes --> M{"stack non-empty and pairs[c] == stack[-1]?"}
M -- yes --> P["stack.pop() — matched"]
P --> L
M -- no --> F(["return False — mismatch"])
```
## Solution
```py
class Solution:
def isValid(self, s: str) -> bool:
stack = []
pairs = {")": "(", "]": "[", "}": "{"}
for c in s:
if c in pairs:
if stack and pairs[c] == stack[-1]:
stack.pop()
else:
return False
else:
stack.append(c)
return True if not stack else False
```
## Explanation
<YouTube id="WTzjTskDFMg" title="Valid Parentheses - Stack - Leetcode 20 - Python" />
@@ -0,0 +1,5 @@
---
title: Stack
description: 'Items are added and removed from one end, so the last item added is the first item removed.'
hidden: true
---
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Stack", order: 6, icon: "layers", display: "group" });
@@ -0,0 +1,69 @@
---
title: '11. Container With Most Water'
description: You are given an integer array height of length n. There are n vertical lines drawn such that the two endpoints of the i^th line are (i, 0) and (i, height[i])
icon: dot
sidebar:
label: 'Container With Most Water'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
::::warning
Notice that you may not slant the container.
::::
### Example 1:
- Input: `height = [1,8,6,2,5,4,8,3,7]`
- Output: `49`
- Explanation: The above vertical lines are represented by array `[1,8,6,2,5,4,8,3,7]`. In this case, the max area of water (blue section) the container can contain is `49`.
### Example 2:
- Input: `height = [1,1]`
- Output: `1`
### Constraints:
- `n == height.length`
- `2 <= n <= 10^5`
- `0 <= height[i] <= 10^4`
## Approach
```mermaid
flowchart TD
S(["maxArea(height)"]) --> I["res = 0, left = 0, right = len - 1"]
I --> W{"left < right?"}
W -- no --> E(["return res"])
W -- yes --> A["area = (right - left) * min(height[left], height[right])"]
A --> M["res = max(res, area)"]
M --> C{"height[left] < height[right]?"}
C -- yes --> L["left += 1 — drop the shorter wall"]
C -- no --> R["right -= 1 — drop the shorter wall"]
L --> W
R --> W
```
## Solution
```py
class Solution:
def maxArea(self, height: List[int]) -> int: # noqa: F821
res = 0
left = 0
right = len(height) - 1
while left < right:
area = (right - left) * min(height[left], height[right])
res = max(res, area)
if height[left] < height[right]:
left += 1
else:
right -= 1
return res
```
## Explanation
<YouTube id="UuiTKBwPgAo" title="Container with Most Water - Leetcode 11 - Python" />
Loaded 100 of 343 files, more files were not shown because too many files have changed in this diff. Show more