Compare commits

...
Author SHA1 Message Date
Prad Nukala 0a4b8d8b49 feat(work): add LeetCode solutions for problems 380, 82, 92 2026-10-13 16:51:41 -04:00
Prad Nukala f1a952f2bd docs: add new problem documentation pages and update redirects 2026-10-13 16:51:38 -04:00
Prad Nukala ea44a99c40 feat(work): add solution for LeetCode 36 Valid Sudoku 2026-10-13 15:34:15 -04:00
Prad Nukala 00fa322a14 feat(work): add solution for LeetCode 19 Remove Nth Node From End of List 2026-10-13 15:14:50 -04:00
Prad Nukala bf56e170a4 chore: remove sd 2026-10-13 14:29:10 -04:00
Prad Nukala 4cf976f683 feat(work): add solution for LeetCode 876 Middle of the Linked List 2026-10-13 14:25:36 -04:00
Prad Nukala ace1944786 docs: clarify /progress page header tab and fix apps/docs table formatting 2026-10-13 09:16:41 -04:00
Prad Nukala 13734e2e27 docs: add /progress page with live stats cards and update navigation and component imports 2026-10-13 09:16:30 -04:00
Prad Nukala cb4a03b6fa feat: integrate system design 2026-10-07 14:18:14 -04:00
Prad Nukala e14432d32a docs(docs): improve system design guide and simplify custom page tabs 2026-10-07 14:17:38 -04:00
Prad Nukala 8843cb776c docs(AGENTS): fix apps/docs table row formatting 2026-10-07 13:45:26 -04:00
Prad Nukala 0e58b63d4d docs(docs): add header tabs for Changelog and Appendix pages 2026-10-07 13:45:21 -04:00
Prad Nukala b5c3f1a159 docs(readme): fix formatting of apps/docs entry in AGENTS.md 2026-10-07 13:00:01 -04:00
Prad Nukala 37d3d9ceaf feat(work): add Minimum Window Substring & Basic Calculator solutions and implement StockSpanner algorithm 2026-10-07 12:59:02 -04:00
Prad Nukala 5627d0ca64 docs(docs): add new problem documentation pages and source selector component 2026-10-07 12:58:59 -04:00
Prad Nukala 598688977a feat(work): add 901 online stock span solution and implement removeDuplicates algorithm 2026-10-06 13:47:23 -04:00
Prad Nukala a3e846c606 docs(docs): add documentation for Next Greater Element I and Remove Duplicates from Sorted Array II, update redirects 2026-10-06 13:47:20 -04:00
Prad Nukala 52d9654ade feat(array): add solution for problem 80 remove duplicates from sorted array ii 2026-10-06 13:16:50 -04:00
Prad Nukala f202c3a0a1 docs(docs): add two‑pointer guide with decision tree and illustrations 2026-10-06 13:16:45 -04:00
Prad Nukala 030887c7df docs(docs): add detailed stack overview with operation examples and SVG illustrations 2026-10-06 12:58:52 -04:00
Prad Nukala 733c71d364 feat(work): add solution for Next Greater Element I 2026-10-06 12:53:56 -04:00
Prad Nukala 115b901fbe docs(docs): add YouTube video and detailed monotonic stack explanation with Python example 2026-10-06 12:53:26 -04:00
Prad Nukala ceaa9a2e75 feat(work): implement binary search solution for median of two sorted arrays 2026-10-05 15:47:05 -04:00
Prad Nukala 7c7019ec0c docs: restructure problem paths, change section display to groups, and improve documentation content 2026-10-05 15:47:02 -04:00
Prad Nukala cd5f416939 style(cli): reformat sync script for readability and consistency 2026-10-05 15:46:55 -04:00
Prad Nukala 6247915ae5 feat: refactored docs structure 2026-10-05 14:22:28 -04:00
Prad Nukala 31a1fcfdbd docs(docs): add MDX pages for Median of Two Sorted Arrays and Largest Rectangle in Histogram and update site redirects 2026-10-05 14:01:30 -04:00
Prad Nukala 66affbe128 feat(work): implement largestRectangleArea solution and fix formatting 2026-10-05 14:01:18 -04:00
Prad Nukala 9de25fecff feat: refactored docs structure 2026-10-05 13:53:14 -04:00
Prad Nukala d388b3000e feat(work): add new LeetCode solutions for median, largest rectangle, and simplify path 2026-10-05 13:51:47 -04:00
Prad Nukala f14dcf2723 docs(skills): update paths to docs directory in problem-approach skill 2026-10-05 13:51:38 -04:00
Prad Nukala d171fe1565 chore(config): remove obsolete docs scripts and .gitignore entries 2026-10-05 13:51:17 -04:00
Prad Nukala e9d40020f4 refactor(cli): generate docs/site.json manifest and update docs site references 2026-10-05 13:51:14 -04:00
Prad Nukala 0b004fcba2 ci(deploy): remove Pages build steps and limit permissions to worker deployment 2026-10-05 13:51:10 -04:00
Prad Nukala 33022c806d docs(docs): update problem page paths to use (problems)/<num>_<slug>.mdx format and adjust related examples 2026-10-05 13:12:48 -04:00
Prad Nukala d59b9dccd9 feat(work): add Find Peak Element solution (162) in Python 2026-10-05 13:08:59 -04:00
Prad Nukala 17c691c481 docs(redirects): add new redirects for problems 128, 162, and 643 2026-10-05 13:08:51 -04:00
Prad Nukala ea62cda955 feat(content): add new problem pages, reorganize docs into (problems) subfolders, and update meta display to page 2026-10-05 13:07:56 -04:00
Prad Nukala 74939c31d7 refactor(cli): adjust sync for (problems) group and page display meta 2026-10-05 13:07:52 -04:00
Prad Nukala 0d5c40ac4a style(docs): remove leading hyphens from AGENTS.md table rows 2026-10-05 12:34:59 -04:00
Prad Nukala 4a1838ab28 feat(work): add sliding window solution for maximum average subarray and implement longest consecutive sequence 2026-10-05 12:34:54 -04:00
Prad Nukala c2a375fffc feat(docs): add Appendix glossary and page for undocumented tags 2026-10-05 12:34:34 -04:00
Prad Nukala 0e9e06eb8d docs(content): revamp sliding-window examples and purge empty appendix pages 2026-10-05 12:34:30 -04:00
Prad Nukala 937266b629 feat(config): add appendix redirects and navigation entries, remove list schema, update comments 2026-10-05 12:34:27 -04:00
Prad Nukala 88c0c9b898 refactor(components): add appendix tag icons and drop list badge 2026-10-05 12:34:23 -04:00
Prad Nukala 099123539e refactor(cli): drop guide page creation and streamline appendix term handling in sync script 2026-10-05 12:34:17 -04:00
Prad Nukala 8c39b3ac23 feat(work): add solution for LeetCode 643 Maximum Average Subarray I 2026-10-05 11:24:01 -04:00
Prad Nukala 7ead021ee2 docs(docs): fix cursor handling and hover tip refactor in docs UI components 2026-10-05 11:23:57 -04:00
Prad Nukala 0d066b8104 chore(config): bump dependencies to latest versions 2026-10-05 11:23:52 -04:00
Prad Nukala 9af5ab1fbb docs(content): add YouTube embed to sliding window page 2026-10-05 11:01:17 -04:00
Prad Nukala f770408ea2 feat(work): add solution for LeetCode 1004 Max Consecutive Ones III 2026-10-02 17:19:55 -04:00
Prad Nukala cf6f09825a docs(docs): add redirect for LeetCode 1004 Max Consecutive Ones III page 2026-10-02 17:19:51 -04:00
Prad Nukala ab09e40a2e docs(content): add Max Consecutive Ones III page and update Array badge count 2026-10-02 17:19:34 -04:00
Prad Nukala 91dad73ebf feat(work): add solution for LeetCode 189 Rotate Array 2026-10-02 16:01:45 -04:00
Prad Nukala 400bd5abf5 docs(docs): add redirect for LeetCode 189 Rotate Array 2026-10-02 16:01:42 -04:00
Prad Nukala a258b46cf0 docs(content): add Rotate Array page and bump array/math badges 2026-10-02 16:01:39 -04:00
Prad Nukala 4875fd5139 feat(work): add solution for LeetCode 525 Contiguous Array 2026-10-02 13:03:24 -04:00
Prad Nukala 7b14673e95 docs(redirects): add redirect for Contiguous Array problem 2026-10-02 13:03:21 -04:00
Prad Nukala 1c45e03ddd docs(content): add Contiguous Array page and update array badge count 2026-10-02 13:03:18 -04:00
Prad Nukala cbc858b667 feat(work): add 290.word-pattern solution 2026-10-02 12:42:01 -04:00
Prad Nukala c890fd35a7 docs(redirects): add redirect for Word Pattern problem 2026-10-02 12:41:58 -04:00
Prad Nukala 3e62d6fa6f docs(content): add 290 Word Pattern page and fix String badge count 2026-10-02 12:41:52 -04:00
Prad Nukala 017f111594 docs(content): improve hash table page formatting and headings 2026-10-01 16:51:07 -04:00
Prad Nukala 9899813281 feat(work): add solutions for problems 42, 209, and 34 2026-10-01 16:40:53 -04:00
Prad Nukala a19fd95cc4 docs(docs): add binary search images and new problem redirects 2026-10-01 16:40:49 -04:00
Prad Nukala 799dc2bb80 docs(content): add new problem pages, improve binary‑search docs, fix image syntax, update appendix badge 2026-10-01 16:40:47 -04:00
Prad Nukala 978589b53e feat(work): add solution for LeetCode 974 Subarray Sums Divisible by K 2026-10-01 14:21:24 -04:00
Prad Nukala 876868f2a4 docs: add hash table operations SVG, prefix‑sum PNG, and redirect for LC 974 2026-10-01 14:21:20 -04:00
Prad Nukala 16317c8645 docs(content): enhance linear-structures docs with detailed explanations, new problem page, updated images, and corrected array badge count 2026-10-01 14:21:18 -04:00
Prad Nukala fdd54e4a1c feat(work): add Isomorphic Strings solution 2026-10-01 13:48:08 -04:00
Prad Nukala 24ef28d8b1 docs(docs): add redirect for LeetCode 205 Isomorphic Strings) 2026-10-01 13:48:05 -04:00
Prad Nukala b461d9cab7 docs(content): add Isomorphic Strings page and correct String badge count 2026-10-01 13:47:54 -04:00
Prad Nukala fd57f53d34 docs(changelog): link problems using short redirects instead of issue URLs 2026-09-30 18:02:19 -04:00
Prad Nukala 4b8496c113 feat(docs): add docs link fallback and inline difficulty badge in changelog page 2026-09-30 18:01:07 -04:00
Prad Nukala 7f6a7d4776 refactor(cli): skip issue URL lookup for unplanned problems 2026-09-30 18:00:58 -04:00
Prad Nukala 45bd3f771d feat(docs): add changelog page and switch tag guide paths to appendix 2026-09-30 18:00:35 -04:00
Prad Nukala c3c482d954 docs(content): add issue URLs to problem pages and restructure guides into appendix 2026-09-30 18:00:32 -04:00
Prad Nukala 65f5965a12 chore(config): simplify Blume config and add issue frontmatter schema 2026-09-30 18:00:20 -04:00
Prad Nukala a20be5182f feat(components): add GitHub issue badge to problem badges 2026-09-30 18:00:16 -04:00
Prad Nukala c05e7f0ef2 feat(cli): add GitHub issue URLs to problem pages and rename guides to appendix 2026-09-30 17:58:06 -04:00
Prad Nukala 76c2a64fc6 ci(ci): update deploy workflow comments to reflect changelog page source 2026-09-30 17:58:03 -04:00
Prad Nukala eb1002727b feat(config): add Changelog tab to navigation 2026-09-30 17:38:42 -04:00
Prad Nukala 41c8c2036e docs(project): update campaign timeline and schedule references in documentation 2026-09-30 17:33:41 -04:00
Prad Nukala 31054c01a7 feat(config): add repo link and actions to docs navigation 2026-09-30 17:33:35 -04:00
Prad Nukala 239c7013f8 feat(api): add review day handling and dynamic campaign length 2026-09-30 17:33:32 -04:00
Prad Nukala 7e9ea8d7c2 docs(agents): clarify release job includes Review N in architecture diagram 2026-09-30 16:31:22 -04:00
Prad Nukala 4548289ecf feat(config): tag changelog releases by Topic or Review category 2026-09-30 16:31:18 -04:00
Prad Nukala d6b483f84e feat(cli): add completion helper and handle review issue releases/closures 2026-09-30 16:31:15 -04:00
Prad Nukala a64a6c2379 ci(ci): clarify release job handling for finished Review issues 2026-09-30 16:31:11 -04:00
Prad Nukala ffa8a11f51 feat(api): expose review markdown body in Review objects 2026-09-30 16:31:06 -04:00
Prad Nukala d722feda1c docs(agents): clarify topic flow and review issue handling in architecture diagram 2026-09-30 16:23:58 -04:00
Prad Nukala 9c7d48aad8 feat(cli): close review issues and support moved problems 2026-09-30 16:23:54 -04:00
Prad Nukala 1d77c5a8b5 ci(ci): update Close Topics workflow to handle Review issues and clarify descriptions 2026-09-30 16:23:52 -04:00
Prad Nukala b2dffa4615 feat(api): sync catalog with problems moved to review issues 2026-09-30 16:23:50 -04:00
Prad Nukala f8ff12b55e docs(redirects): add redirect for LC 435 non‑overlapping intervals 2026-09-30 15:53:11 -04:00
Prad Nukala c791974271 docs(content): add 435 non‑overlapping intervals doc, condense meta, bump guide badges 2026-09-30 15:53:08 -04:00
Prad Nukala e1c8f1a0f1 feat(work): implement eraseOverlapIntervals solution for non-overlapping intervals problem 2026-09-30 15:52:50 -04:00
Prad Nukala 57a5917e09 chore(config): remove unused narration flag 2026-09-30 15:40:31 -04:00
Prad Nukala f8e90d7f55 ci(ci): add OPENROUTER_API_KEY secret to deploy workflow and docs 2026-09-30 15:34:43 -04:00
Prad Nukala a39336f7ed refactor(ask): replace hardcoded model with ASK_MODEL env variable 2026-09-30 15:32:05 -04:00
Prad Nukala 6396f1fbec docs(content): set display to group for binary search and hash table pages 2026-09-30 15:32:00 -04:00
Prad Nukala 82fe05b225 feat(config): add ASK_MODEL env var and update docs assistant endpoint to SRS API 2026-09-30 15:31:56 -04:00
Prad Nukala d7fbe88eb8 ci(ci): add OpenRouter API key secret to deploy workflow 2026-09-30 15:31:31 -04:00
Prad Nukala 856ab0667a feat(api): add docs assistant endpoint with OpenRouter integration 2026-09-30 15:31:10 -04:00
Prad Nukala 2c574018fb feat(docs): add OpenRouter AI SDK provider dependency and update @vercel/oidc version 2026-09-30 15:27:57 -04:00
Prad Nukala 1f6413f3d8 feat(docs): add OpenRouter AI provider and improved tag color algorithm 2026-09-30 15:27:52 -04:00
Prad Nukala 3b9079f87f feat(components): enhance tag color computation for problem badges 2026-09-30 15:27:34 -04:00
Prad Nukala d5496581df feat(config): enable narration, SEO, and agents settings in Blume config 2026-09-30 15:19:01 -04:00
Prad Nukala e931391200 feat(cli): add guide icon validation and warnings in sync script 2026-09-30 15:17:39 -04:00
Prad Nukala 790e256127 feat(work): add solution stub for LeetCode 435 Non-overlapping Intervals 2026-09-30 15:17:35 -04:00
Prad Nukala a8bd4fbcd7 docs(docs): add unique tag colours and pill‑styled badges for problem tags. 2026-09-30 15:17:31 -04:00
Prad Nukala a7737502e1 docs(content): set meta display to page and improve interval documentation formatting 2026-09-30 15:17:24 -04:00
Prad Nukala 2ac54e9930 chore(config): bump blume to ^2.1.0 2026-09-30 15:16:54 -04:00
Prad Nukala ef91c0d1fe feat(work): add merge intervals solution 2026-09-30 12:22:34 -04:00
Prad Nukala 910e83fa64 docs(redirects): add redirect for merge intervals problem 2026-09-30 12:22:31 -04:00
Prad Nukala 3c9feb33fe docs(content): add Merge Intervals page, quicksort guide, and bump guide badges 2026-09-30 12:22:29 -04:00
Prad Nukala b76b199b17 feat(work): implement insert interval solution 2026-09-30 12:17:47 -04:00
Prad Nukala cd9aef1229 docs(docs): add intervals_merge-formula.svg illustration and redirect for problem 57 2026-09-30 12:17:43 -04:00
Prad Nukala 34b944d32d docs(content): add Insert Interval page, expand intervals guide, and update array badge 2026-09-30 12:17:40 -04:00
Prad Nukala b710c777c3 feat(work): add insert-interval solution 2026-09-30 11:25:43 -04:00
Prad Nukala 19dedc52ce docs(docs): add interval overlap condition and formula SVG illustrations 2026-09-30 11:25:39 -04:00
Prad Nukala d9dc90e433 docs(content): add detailed interval explanation with overlap condition and merging pattern 2026-09-30 11:25:16 -04:00
Prad Nukala 9c2aaf5969 chore(config): add .openlogs to .gitignore 2026-09-30 11:25:03 -04:00
Prad Nukala a30e593202 docs(agents): fix bullet list formatting in AGENTS.md 2026-09-29 17:45:06 -04:00
Prad Nukala d194cedf7a docs: add toggle UI components, submission chart, and update stats schema 2026-09-29 17:45:01 -04:00
Prad Nukala 5d2d1dff3c chore(config): add class-variance-authority and radix-ui dependencies 2026-09-29 17:44:57 -04:00
Prad Nukala dde06daea3 feat(api): add difficulty‑segmented solved data to yearly stats 2026-09-29 17:44:54 -04:00
Prad Nukala 6e9c407698 docs(agents): refine AGENTS.md descriptions for apps/docs and stats sections 2026-09-29 17:29:03 -04:00
Prad Nukala 63f0a81af3 feat(docs): add progress dashboard components and styling 2026-09-29 17:28:59 -04:00
Prad Nukala 5ed33057ed chore(config): add charting and utility dependencies to docs package.json 2026-09-29 17:28:54 -04:00
Prad Nukala f4fc0fd0e7 feat(api): add recent solves, yearly stats, and miss count to API 2026-09-29 17:28:49 -04:00
Prad Nukala f2a2d2d7de docs(greedy): add Walk and Carry and Sort and Pick sections to Greedy Skeleton 2026-09-29 17:15:21 -04:00
Prad Nukala fa74adeda7 docs(agents): clarify badge row placement description 2026-09-29 17:03:38 -04:00
Prad Nukala e11c9723f8 feat(work): implement Gas Station solution and adjust formatting 2026-09-29 17:03:33 -04:00
Prad Nukala d2c645bef8 docs(docs): add wrapper badge component, make backlinks collapsible, and include solution code for Gas Station problem 2026-09-29 17:03:28 -04:00
Prad Nukala 2f94b02e15 docs(cli): clarify badge rendering uses MDX wrapper instead of PageHeader slot 2026-09-29 17:03:25 -04:00
Prad Nukala 6f1cb93395 docs(agents): remove leading space before apps/docs table row 2026-09-29 15:38:14 -04:00
Prad Nukala b310f410a2 docs(guides): add icons and sidebar badges to guide pages 2026-09-29 15:37:25 -04:00
Prad Nukala 436ff3080e feat(cli): add backlink badge generation for guides 2026-09-29 15:37:22 -04:00
Prad Nukala d3f7b1c6f9 docs(AGENTS): prepend plus sign to apps/docs table row 2026-09-29 15:19:51 -04:00
Prad Nukala 8a331f4b54 docs(docs): restructure guides into numbered groups and add meta files 2026-09-29 15:19:47 -04:00
Prad Nukala bc6eb3e66b feat(cli): improve guide file placement and handling in sync script 2026-09-29 15:19:43 -04:00
Prad Nukala dd929cca6c docs(docs): rename phase titles to numeric format and add redirect for /134 2026-09-29 15:14:29 -04:00
Prad Nukala 5987fdce7d chore(videos): delete Remotion video app and adjust docs description 2026-09-29 15:13:03 -04:00
Prad Nukala 6879afa61c feat(work): add solution for Gas Station problem 2026-09-29 15:12:57 -04:00
Prad Nukala 40e965aa06 docs(docs): add BacklinksList component and shared tagTarget logic for badge routing, update docs pages 2026-09-29 15:12:54 -04:00
Prad Nukala eea8d242e0 refactor(cli): adjust phase meta handling and update documentation comments 2026-09-29 15:12:52 -04:00
Prad Nukala 50b42108eb feat(work): add Jump Game solution 2026-09-29 14:11:42 -04:00
Prad Nukala 6f375e6c14 docs(docs): add Jump Game documentation page and redirect entry 2026-09-29 14:11:40 -04:00
Prad Nukala 667bc06965 docs(docs): add O(n) Kadane implementation for Maximum Subarray solution 2026-09-29 13:22:24 -04:00
Prad Nukala e63d517c9a chore(githooks): remove pre-commit hook script 2026-09-29 13:22:14 -04:00
Prad Nukala 4c297b4524 feat(work): add Maximum Subarray solution (LeetCode 53) 2026-09-29 13:22:10 -04:00
Prad Nukala 32dadf5928 docs(docs): add Maximum Subarray problem page and redirect entry 2026-09-29 13:22:07 -04:00
Prad Nukala 5b6a33088c feat(work): add Minimum Path Sum & Longest Common Subsequence solutions; refactor Unique Paths DP implementation 2026-09-28 13:57:07 -04:00
Prad Nukala e9f76a32be feat: complete lc-62 2026-09-28 12:39:28 -04:00
Prad Nukala 951f958bd9 docs(docs): clarify phase-based content paths and frontmatter ownership in documentation 2026-09-28 11:20:55 -04:00
Prad Nukala d2888360e2 docs(docs): add ProblemBadges component, layout slot, and enrich pages with topics/video frontmatter, plus guide placeholders and topic‑landing map. 2026-09-28 11:20:41 -04:00
Prad Nukala b035d1ffa0 feat(config): add optional frontmatter schemas and Guides tab 2026-09-28 11:19:36 -04:00
Prad Nukala 0473a3651f feat(cli): sync LeetCode topic tags, generate guide placeholders, and write video frontmatter instead of embeds 2026-09-28 11:19:32 -04:00
Prad Nukala a9550769f0 docs(agents): correct bullet prefix for finished topic rule 2026-09-25 14:15:49 -04:00
Prad Nukala 51c8c771c6 feat(config): backdate GitHub releases via custom fetch implementation 2026-09-25 14:15:44 -04:00
Prad Nukala 7cedd235b5 docs(cli): clarify changelog dating uses timever tag instead of API publish date 2026-09-25 14:15:41 -04:00
Prad Nukala 107de04587 docs(agents): clarify release body wording for finished topic rule 2026-09-25 14:07:07 -04:00
Prad Nukala 8787246db7 refactor(cli): simplify release topic naming and summary generation 2026-09-25 14:06:09 -04:00
Prad Nukala 85b54b565e feat(config): add Changelog tab to docs navigation 2026-09-25 14:06:05 -04:00
Prad Nukala 8e8522a773 feat(cli): add release name handling for topic releases 2026-09-25 14:05:52 -04:00
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
338 changed files with 14746 additions and 3301 deletions

No files matched your search

+7 -8
View File
@@ -5,14 +5,13 @@ description: Write the `## Approach` section — a Mermaid flowchart that traces
# Problem approach diagrams
Every solution page (`apps/docs/content/(<category>)/<num>-<slug>.mdx`) has three
hand-owned sections around the script-owned one:
Every solution page (`apps/docs/content/(<phase>)/<topic>/(problems)/<num>_<slug>.mdx`) has a
hand-owned section before the script-owned one:
```
frontmatter → <Badge> → ::::warning (maybe) → ### Example N / ### Constraints
frontmatter → ::::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
@@ -101,7 +100,7 @@ flowchart TD
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
edges — see `hybrid-structures/(problems)/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
@@ -123,7 +122,7 @@ flowchart TD
### 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`):
callout **before** the fence naming exactly why it fails (`sliding-window/(problems)/121_best-time-to-buy-and-sell-stock.mdx`):
````md
## Approach
@@ -144,7 +143,7 @@ there (then `bun run sync`).
- 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 touch `## Solution` or anything after it, nor the frontmatter `topics:` /
`video:` keys (`bun run sync` / `bun run videos --write` own them).
- Do not add an approach to `reference/` pages or `index.mdx` — only numbered
solution pages (`title: 'NN. …'`) carry one.
+41 -1
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
inputs:
dry_run:
description: Report matches without closing anything
description: Report matches and would-be releases without closing or releasing anything
type: boolean
default: false
@@ -61,3 +61,43 @@ jobs:
exit 0
fi
bun apps/cli/sync-project-fields.ts
# One timever release per finished topic (every core sub-issue closed) and
# per finished `Review <N> — <Phase>` issue (every sub-issue closed), tagged
# at the instant the last required problem closed. It reads the same rules
# close-topics uses (apps/cli/topics.ts) straight off the sub-issues, so the
# push that finishes a topic or review releases it in this run — no waiting
# for Close Topics, which chains after this workflow. A problem re-parented
# into a Review issue still belongs to its topic (apps/api/src/reviews.ts),
# so moving it never rewrites a topic's release. Reconciling: a run releases
# everything finished and still missing a release, so a manual dispatch
# backfills and a problem closed by hand in the UI is picked up by the next
# run. The docs changelog (deploy.yml) tags each release Topic or Review.
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 }}
+11 -6
View File
@@ -1,9 +1,14 @@
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.
# Closes finished topics AND finished `Review <N> — <Phase>` issues (every
# sub-issue closed). Problems moved from a topic into a Review issue still
# count toward that topic (apps/cli/topics.ts).
#
# No D1 push here, unlike Close Solved: D1 stores no topic or review
# completion column. Phase progress, the gate pools and the charts all derive
# from the problem rows, which close-solved already logs — a topic or review
# is finished exactly when its problems are, so there is nothing left to
# reconcile.
on:
# Close Solved closes problem issues with GITHUB_TOKEN, and token-driven
@@ -19,7 +24,7 @@ on:
workflow_dispatch:
inputs:
dry_run:
description: Report finished topics without closing anything
description: Report finished topics and reviews without closing anything
type: boolean
default: false
@@ -34,7 +39,7 @@ concurrency:
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.
# problem sub-issue can ever complete a topic's core set or a review.
if: github.event_name != 'issues' || contains(github.event.issue.labels.*.name, 'problem')
runs-on: ubuntu-latest
steps:
+16 -2
View File
@@ -30,7 +30,13 @@ jobs:
- uses: oven-sh/setup-bun@v2 # skip if you use npm/pnpm
- run: bun install --frozen-lockfile
# pages/changelog.astro reads this repo's GitHub releases at build time
# (apps/docs/lib/releases.ts). 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:
@@ -48,8 +54,9 @@ jobs:
# 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.
# Needs repo secrets CLOUDFLARE_API_TOKEN (Workers Scripts: Edit),
# CLOUDFLARE_ACCOUNT_ID, and OPENROUTER_API_KEY (the docs assistant's
# POST /ask; the Actions secret is the one copy, mirrored into the Worker).
deploy-api:
runs-on: ubuntu-latest
steps:
@@ -62,6 +69,13 @@ jobs:
# DST cron guards must hold before the Worker ships.
- run: bun run api:test
- run: printf %s "$OPENROUTER_API_KEY" | bunx wrangler secret put OPENROUTER_API_KEY
working-directory: apps/api
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
- run: bun run api:deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1
View File
@@ -1,5 +1,6 @@
# dependencies (bun install)
node_modules
.openlogs
tmp/
*.log
.crush
+24 -16
View File
@@ -2,7 +2,7 @@
## 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.
LeetCode interview-prep campaign (2026-08-17 → Nov 3: eight weeks of topics, then the Review-issue phase; the end is the last date in `apps/api/data/schedule.json` — `CAMPAIGN_END`/`CAMPAIGN_DAYS` in `apps/api/src/srs.ts` are derived from it, never declared) 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
@@ -13,7 +13,8 @@ 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-topics.yml| TI[topic + Review N issues]
PI -->|close-solved.yml release job| Rel[timever release per finished topic / Review N]
GH[GitHub issues = catalog] -->|nightly reconcile| D1
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
Mail -->|GET /log| D1
@@ -37,6 +38,9 @@ flowchart LR
- **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, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). 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 has its name (the topic title) and body rewritten whenever the render changes. The docs `/changelog` is ONE custom page, `apps/docs/pages/changelog.astro` (a NeonUI-style timeline: date left, a card per release right, no per-release subpages, so nothing of it enters the sidebar or prev/next), linked from the header by a `navigation.tabs` entry (beside `/appendix`'s). `apps/docs/lib/releases.ts` fetches the releases at build (`GITHUB_TOKEN` in `deploy.yml`; a failed fetch fails the build), dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are the page's contract with `render()`. There is no changelog RSS feed.
- **Review issues re-parent problems; topics still own them.** The transfer phase's 20 `Review <N> — <Phase>` issues (label `review`, milestone `Review — Transfer & Mocks`; not the Worker's `Review — Week N` gate issues) hold open optional/deferred problems as sub-issues in solve order, moved there with `replace_parent`. `apps/api/src/reviews.ts` is the ONE recovery of ownership — `readReviews()` (one GraphQL request per 50 `review` issues, cheap enough for the Worker's cron subrequest budget) reads each moved problem's topic off its last `parent_issue_removed` event naming a topic, `movedFromTopics()` groups them — shared by the Worker's catalog reconcile (a moved problem's D1 row keeps syncing its labels and `topic_issue`) and `apps/cli/topics.ts` (`readProblems()` returns a topic's sub-issues plus its moved problems, so the finished rule and existing release bodies do not change when a problem moves). A Review issue finishes by `completion()` in `topics.ts` — every sub-issue closed, any set — which `close-topics.ts` closes it on and `release-topics.ts` releases it on, exactly like a topic: timever of the last close, same `<!-- release-topics: #N -->` marker (issue numbers never collide), body fields from the review's `**Scheduled:**`/`**Phase:**`/`**Load:**` lines.
- **Review days are plan days.** `schedule.json` books each Review issue on its own date — Mon/Tue/Thu/Fri only, Oct 1 → Nov 3 — as `review: { issue, title, problems }` (solve order). The digest renders it as a linked "Today's review" section with the issue's day rules (`REVIEW_RULES` in `email.tsx`, one sentence for HTML and text), its problems join `RESERVED` so drills and sampled gates never draw them early, and `gate: []` skips a Saturday's checkpoint (Oct 3). The issue's sub-issues stay the truth: the catalog reconcile compares every booked review's title and problem list with GitHub and returns any mismatch as `reviewDrift` (also logged), so a re-arranged Review issue shows up the next sync instead of silently disagreeing with the email. Move a review by editing its `schedule.json` date AND the issue's `**Scheduled:**` line (the release body reads it).
## Key Directories
@@ -44,9 +48,10 @@ flowchart LR
|---|---|
| `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/`, `islands/`, `public/`). `content/<topic>/<num>-<slug>.mdx` plus one `meta.ts` per topic folder, all generated by sync: the 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` carries the exact label and topic number so the sidebar reads in curriculum order, and the page badge is the `work/` data-structure category. A hand-written `<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 |
| `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, Worker → Cloudflare, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `pages/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/(problems)/<num>_<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): 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, and `(problems)` is a second `(group)` inside it, so `tree-dfs/(problems)/226_invert-binary-tree.mdx` routes to `/tree-dfs/invert-binary-tree` (Blume sorts by the `<num>_` prefix and strips it from the route). The `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order; every topic meta is `display: "page"`, so a topic is one sidebar row whose panel holds its landing page and a "Problems" section. A problem page's badge row (between the description and the body) is rendered, not written: the MDX `wrapper` (`components/ProblemBadges.astro`, registered as `mdx.wrapper` in `components.ts` — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off `Astro.url`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (filled), any other tag to its card on the Appendix glossary `/appendix#<slug>` (outlined; LeetCode's problem list if the tag has no term, gray and iconless), and the video as a red `Explanation` badge. Every badge in the row is a pill. A tag badge carries the icon its target shows (a landing's: the topic `meta.ts` icon read off `data.navigation`; an Appendix term's: its `icon` in `appendix.ts`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a landing or a term so tags that share problem pages sit furthest apart in OKLab, in a lightness band that keeps ≥ 3:1 contrast in both themes (Blume's Badge takes one hex for both). The reverse direction is the `PageFooter` slot (`components/BacklinksList.astro`): an Obsidian-style `<num> <name>` list under every topic landing page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, `hybrid-structures`, still lists its problems), in a native `<details>` open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their `<num>. <name>` title and render no list. The sidebar is Blume's default nav tree (no `Sidebar` slot override). The custom pages `/progress`, `/changelog` and `/appendix` are header `navigation.tabs` (on mobile, Blume lists them at the top of the drawer); never `navigation.featured`, which Blume renders above the nav tree. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), and where a badge points is ONE function beside it, `tagTarget()` — read by both components and the Appendix page (so badges, backlinks and term cards cannot disagree) and the map by sync. Every other tag a problem carries is ONE hand-written table, `apps/docs/appendix.ts` (`APPENDIX`: slug → `{ name, icon, category, definition }`), rendered by ONE custom page, `pages/appendix.astro` — a glossary with an A–Z letter index and a filter box (an `<appendix-filter>` custom element over static markup; no JS, the full list), one card per term (icon tinted the tag's colour, category, definition, LeetCode list link, and a `<details>` "Solved" list of the problem pages whose badge `tagTarget()` sends there, paged `PAGE_SIZE` (5) rows at a time by numbered buttons under it — a `<solved-pages>` custom element; without JS every row shows and the buttons stay hidden), each card `id`'d by slug and highlighted as `:target`. It is no content folder, so it enters neither the sidebar tree nor prev/next: its header tab is its link. Sync writes nothing for it — it warns about a carried tag with no term, a term whose tag a topic now teaches (delete it), and a term `icon` that is a `TOPIC_ICONS` value or another term's. The former per-term pages `/appendix/<slug>` are exact redirects to `/appendix#<slug>` in `blume.config.ts`, generated from `APPENDIX`'s keys. 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; `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigaLine truncated
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | review | 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 with the `OPENROUTER_API_KEY` Actions secret put into it first, 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
@@ -66,13 +71,15 @@ bun run test # run ONE solution against LeetCode's judge (not a test
# 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 (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 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 `video:` frontmatter → NeetCode video per page
# (channel search scraped, no API key; --write adds the
# `video:` key; "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
@@ -102,20 +109,21 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
## 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/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`, and `OPENROUTER_API_KEY`, whose one copy is the repo's Actions secret (deploy.yml puts it before every deploy — rotate it there).
- `apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
- `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/<CAMPAIGN_DAYS>) LeetCode Daily Digest` — 79 with the plan ending Nov 3; booking a later day in `schedule.json` lengthens it, and the heatmaps and `/api/stats` `heat` with it.
- `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, unordered, after the topics) and the badge is the `work/1/<Difficulty>/<Category>/` category — topic folders and their `meta.ts` are rewritten every run, a page is `rename`d when its topic changes, and a folder every page has left is deleted unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one). Never use `(group)` folders here: they add no route segment, so a topic `index.mdx` inside one collides with the root `/`. 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/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is 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 `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`, with every problem page in the topic's `(problems)/` group as `<num>_<slug>.mdx` (a page is found by its frontmatter title, wherever it sits, so a layout change is one sync of `rename`s). The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "page"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. 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 through an emptied `(problems)` and topic 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 hand-owned parts 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); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added 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`; `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`).
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` / `apps/docs/lib/stats.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs `/progress` islands and `bun run stats`, so its shape is their contract: change the Worker and both readers together (additive fields a reader ignores are safe). `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 is a custom page, `apps/docs/pages/progress.astro` (no content folder, so outside the sidebar and prev/next; its header tab is its link), mounting five LeetCode-style React cards from `components/progress/` with `client:load` — imported by the page, never used from MDX, so they are not in `islands/` — `SolvedRing` (difficulty gauge + "Attempting"), `SubmissionHeatmap` and `SubmissionChart` sharing one row (the heatmap: the last six months in month blocks, the README's colour ramp as theme.css `--heat-0..4`, its attempts / active days / max streak counted from the cells it draws; the chart: this calendar year as stacked Easy/Med./Hard bars per month or week, first solves or every attempt, via two shadcn `ToggleGroup`s), `TopicBubbles` (the campaign topics circle-packed by `d3-hierarchy`, filled by solved share), `RecentSolves` (the ten most recent, linked through `redirects.ts`) — composed from shadcn/ui (`components/ui/{card,chart,skeleton,toggle,toggle-group}.tsx`, Recharts under `chart`, Radix under the toggles) on Blume's tokens: `apps/docs/theme.css` maps shadcn's names onto `--blume-*` (never `accent`, which is Blume's brand colour — vendored components that use it, like `toggle.tsx`, are retokened to `background`/`muted`) and gives shadcn's `data-slot` parts their border colour. They share `lib/stats.ts` (`useStats()`), so the page makes ONE `/api/stats` request however many cards hydrate. shadcn sources live outside `islands/` (every `.tsx` there becomes an island) and import `lib/utils` relatively; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones (house style) — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume turns every tsconfig `paths` target into a Vite alias AND a dev dep-scan glob (`<target>/**/*.astro`…), so `paths` maps only `@/components`, `@/lib`, `@/hooks` — each bare and `/*`, since `components.json` names the bare forms — never `@/*` → `./*`: that glob reaches through `node_modules/blume`, where Vite's scanner reads a `<script>` inside a `RootLayout.astro` comment as a tag and `blume dev` skips dependency pre-bundling. Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do.
- `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.
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. Two exceptions, each confined to one surface. 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`. And the docs `/progress` islands: shadcn/ui (`recharts`, `radix-ui`, `class-variance-authority`, `clsx`, `tailwind-merge`) plus `d3-hierarchy` for the bubble packing, client-side in `apps/docs` only. Do not extend either exception to any other module.
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
## Testing & QA
+14 -14
View File
@@ -5,25 +5,25 @@
<p align="center">
<a href="https://x.com/basedprad"><img alt="follow" src="https://shieldcn.dev/x/follow/basedprad.svg" /></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.prad.nu"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></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>
Charts are live from the SRS Worker — the daily digest email drives the schedule.</sub>
<sub>Interview-prep fight camp: eight weeks of topics, then a review phase to Nov 3. 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>
<p align="center">
<img alt="phase progress" src="https://shieldcn.dev/chart/npm/zod.svg" />
</p>
<p align="center">
<img alt="SRS ladder" src="https://shieldcn.dev/chart/npm/zod.svg" />
</p>
<p align="center">
<img alt="attempt heatmap" src="https://shieldcn.dev/chart/npm/zod.svg" />
</p>
<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
+1
View File
@@ -1,3 +1,4 @@
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
OPENROUTER_API_KEY=sk-or-v1_key_for_the_docs_assistant
+6 -3
View File
@@ -17,9 +17,12 @@ Live at `https://srs-api.prdlk.workers.dev`.
- **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.
fixed numbers), `review` (a `Review <N> — <Phase>` issue with its problems
in solve order; Mon/Tue/Thu/Fri only), `gate` (Saturday's blind set,
verbatim; `[]` skips that week's checkpoint), or `sweep` (redo every miss,
nothing new). Numbers a `gate`, `problems` or `review` entry names are
`RESERVED`: the drill and gate samplers never draw them. The catalog
reconcile reports any drift between a `review` entry and its issue.
- 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 —
+21 -3
View File
@@ -22,7 +22,25 @@
"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] }
"2026-10-01": { "review": { "issue": 225, "title": "Review 1 — Linear Structures", "problems": [205, 974, 209, 34, 42] } },
"2026-10-02": { "review": { "issue": 226, "title": "Review 2 — Linear Structures", "problems": [290, 525, 189, 1004, 84] }, "sweep": true },
"2026-10-03": { "gate": [] },
"2026-10-05": { "review": { "issue": 227, "title": "Review 3 — Linear Structures", "problems": [643, 128, 162, 71, 4] } },
"2026-10-06": { "review": { "issue": 228, "title": "Review 4 — Linear Structures", "problems": [496, 80, 901, 76, 224] } },
"2026-10-08": { "review": { "issue": 229, "title": "Review 5 — Nodal & Grid", "problems": [876, 19, 36] } },
"2026-10-09": { "review": { "issue": 230, "title": "Review 6 — Nodal & Grid", "problems": [92, 82, 380] } },
"2026-10-12": { "review": { "issue": 231, "title": "Review 7 — Nodal & Grid", "problems": [143, 86, 289] } },
"2026-10-13": { "review": { "issue": 232, "title": "Review 8 — Nodal & Grid", "problems": [61, 138] } },
"2026-10-15": { "review": { "issue": 233, "title": "Review 9 — Hierarchical", "problems": [100, 112, 530, 1046, 103, 124] } },
"2026-10-16": { "review": { "issue": 234, "title": "Review 10 — Hierarchical", "problems": [572, 543, 703, 105, 295] } },
"2026-10-19": { "review": { "issue": 235, "title": "Review 11 — Hierarchical", "problems": [101, 637, 173, 621, 502] } },
"2026-10-20": { "review": { "issue": 236, "title": "Review 12 — Hierarchical", "problems": [108, 129, 373, 648, 212] } },
"2026-10-22": { "review": { "issue": 237, "title": "Review 13 — Relational", "problems": [1971, 695, 547, 433] } },
"2026-10-23": { "review": { "issue": 238, "title": "Review 14 — Relational", "problems": [130, 261, 909, 1462] } },
"2026-10-26": { "review": { "issue": 239, "title": "Review 15 — Relational", "problems": [286, 1254, 721, 399] } },
"2026-10-27": { "review": { "issue": 240, "title": "Review 16 — Relational", "problems": [1091, 802, 269] } },
"2026-10-29": { "review": { "issue": 241, "title": "Review 17 — Decision Space", "problems": [746, 228, 17, 77, 213, 63, 122] } },
"2026-10-30": { "review": { "issue": 242, "title": "Review 18 — Decision Space", "problems": [22, 90, 139, 120, 45, 253, 72] } },
"2026-11-02": { "review": { "issue": 243, "title": "Review 19 — Decision Space", "problems": [79, 131, 91, 494, 763, 986, 920] } },
"2026-11-03": { "review": { "issue": 244, "title": "Review 20 — Decision Space", "problems": [300, 647, 97, 221, 918, 452, 52] } }
}
+289
View File
@@ -0,0 +1,289 @@
/**
* POST /ask — the docs assistant's backend.
*
* The docs are a static GitHub Pages site, and Blume's own assistant route is
* server-rendered (it reads the provider key per request), so the site points
* `ai.assistant.endpoint` here instead. This speaks the Blume island's wire
* contract (`blume/components/islands/hooks.ts`):
*
* - request: `{ messages: [{ role, content }], page: { path } }` as JSON,
* `path` the base-stripped route the reader is on;
* - response: the answer as a plain UTF-8 text stream; any non-2xx shows the
* island's error notice, and a 503 whose body starts "The assistant is not
* configured" shows that body verbatim.
*
* Validation matches Blume's generated route: 1–40 user/assistant messages of
* string content, ≤ 24 000 chars serialized, a 64 KB body, and only role and
* content ever reach the model, so the endpoint cannot be repurposed as an
* open LLM proxy with a caller's own system prompt.
*
* Grounding reads what the site already publishes: `llms-full.txt`, every
* page's Markdown under `# Title` / `Source: <url>`. The page the reader is on
* goes in first, then the best keyword matches, within Blume's default budget
* (6 pages, 2 000 chars each, 10 000 total), under Blume's grounded system
* prompt — answer only from the excerpts, cite pages as `[Title](/route)`.
*
* The model call is OpenRouter's OpenAI-compatible chat endpoint over plain
* `fetch` (no SDK — see the repo's near-zero-dependency rule), streamed, with
* `reasoning.effort: "none"`, for the model the `ASK_MODEL` var names (a
* wrangler.jsonc change swaps it); its SSE deltas are re-emitted as bare text. The
* reader closing the panel aborts the request, which aborts the model call.
*/
const BODY_LIMIT = 64 * 1024;
const MAX_MESSAGES = 40;
const MAX_CHARS = 24_000;
const MAX_RESULTS = 6;
const EXCERPT_CHARS = 2000;
const CONTEXT_BUDGET = 10_000;
/** Lead-in kept before the matched region of a long page. */
const EXCERPT_LEAD = 160;
/** How long one isolate reuses the parsed corpus (the edge caches the fetch as long). */
const CORPUS_TTL_MS = 5 * 60_000;
const SYSTEM_PROMPT =
"You are a helpful documentation assistant for this project, a LeetCode interview-prep campaign: solved problems grouped by topic, with guides to the techniques behind them. Answer the user's question using ONLY the documentation excerpts below. Each excerpt is headed by its page as `## Page Title (/route)`. If the answer is not covered by the excerpts, say you don't know and suggest where in the docs to look — do not invent details. Always cite the pages you drew from, and write every citation as a Markdown link to that page using its route, e.g. [Page Title](/route).";
/** Words that match everything and rank nothing. */
const STOPWORDS = new Set(
"a an and are as at be but by can do does for from how i if in is it its me my of on or so that the this to was what when where which who why with you your".split(
" ",
),
);
interface AskMessage {
role: "user" | "assistant";
content: string;
}
interface Section {
title: string;
route: string;
body: string;
/** Lower-cased title + body, searched per query term. */
text: string;
titleText: string;
}
// ── corpus ───────────────────────────────────────────────────────
let corpus: { at: number; sections: Section[] } | undefined;
/** `llms-full.txt` split into pages, cached per isolate for `CORPUS_TTL_MS`. */
async function sections(docsUrl: string): Promise<Section[]> {
if (corpus && Date.now() - corpus.at < CORPUS_TTL_MS) return corpus.sections;
const res = await fetch(`${docsUrl}/llms-full.txt`, {
cf: { cacheTtl: CORPUS_TTL_MS / 1000, cacheEverything: true },
});
if (!res.ok) throw new Error(`GET llms-full.txt -> ${res.status}`);
const full = await res.text();
const heads = [...full.matchAll(/^# (.+)\nSource: (\S+)$/gm)];
const out = heads.map((head, i): Section => {
const start = head.index + head[0].length;
const end = heads[i + 1]?.index ?? full.length;
const body = full.slice(start, end).replace(/\n-{3,}\s*$/, "").trim();
const title = head[1]!.trim();
return {
title,
route: new URL(head[2]!).pathname.replace(/(.)\/+$/, "$1"),
body,
text: `${title}\n${body}`.toLowerCase(),
titleText: title.toLowerCase(),
};
});
corpus = { at: Date.now(), sections: out };
return out;
}
// ── retrieval ────────────────────────────────────────────────────
function terms(text: string): string[] {
return [...new Set(text.toLowerCase().match(/[a-z0-9][a-z0-9+#-]*/g) ?? [])].filter(
(t) => !STOPWORDS.has(t) && (t.length > 1 || /\d/.test(t)),
);
}
function occurrences(haystack: string, needle: string): number {
let count = 0;
for (let at = haystack.indexOf(needle); at !== -1; at = haystack.indexOf(needle, at + needle.length)) count++;
return count;
}
/**
* The pages to inject, best first: term frequency damped by log and weighted
* by rarity across pages (idf), a title hit counting triple — so "217" or
* "sliding window" lands on the page named that rather than every page that
* mentions it in passing.
*/
function rank(all: Section[], query: string[]): Section[] {
const idf = new Map(
query.map((t) => [t, Math.log(1 + all.length / (1 + all.filter((s) => s.text.includes(t)).length))]),
);
return all
.map((section) => {
let score = 0;
for (const t of query) {
const tf = occurrences(section.text, t);
if (tf) score += idf.get(t)! * (Math.log(1 + tf) + (section.titleText.includes(t) ? 3 : 0));
}
return { section, score };
})
.filter((r) => r.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, MAX_RESULTS)
.map((r) => r.section);
}
/** A page whole when it fits, else `chars` from just before its first query hit. */
function excerpt(section: Section, query: string[], chars: number): string {
if (section.body.length <= chars) return section.body;
const lower = section.body.toLowerCase();
const hits = query.map((t) => lower.indexOf(t)).filter((i) => i !== -1);
const start = hits.length ? Math.max(0, Math.min(...hits) - EXCERPT_LEAD) : 0;
return `${start > 0 ? "…" : ""}${section.body.slice(start, start + chars)}…`;
}
/** The system prompt: the current page, then the ranked pages, within the budget. */
async function instructions(env: Env, messages: AskMessage[], path: string | undefined): Promise<string> {
const all = await sections(env.DOCS_URL);
// A follow-up ("and in Python?") leans on the question before it.
const asked = messages.filter((m) => m.role === "user").slice(-2).map((m) => m.content);
const query = terms(asked.join(" "));
const current = path ? all.find((s) => s.route === path) : undefined;
const picked = [...(current ? [current] : []), ...rank(all, query).filter((s) => s !== current)];
const parts: string[] = [];
let budget = CONTEXT_BUDGET;
for (const section of picked) {
if (budget < 200) break;
const text = excerpt(section, query, Math.min(EXCERPT_CHARS, budget));
parts.push(`## ${section.title} (${section.route})\n\n${text}`);
budget -= text.length;
}
return parts.length ? `${SYSTEM_PROMPT}\n\n# Documentation excerpts\n\n${parts.join("\n\n")}` : SYSTEM_PROMPT;
}
// ── request handling ─────────────────────────────────────────────
/** The validated conversation, or a reason it was turned away. */
function readMessages(body: unknown): AskMessage[] | undefined {
if (!body || typeof body !== "object" || !("messages" in body) || !Array.isArray(body.messages)) return;
const raw: unknown[] = body.messages;
if (raw.length === 0 || raw.length > MAX_MESSAGES || JSON.stringify(raw).length > MAX_CHARS) return;
const messages: AskMessage[] = [];
for (const m of raw) {
if (!m || typeof m !== "object" || !("role" in m) || !("content" in m)) return;
if ((m.role !== "user" && m.role !== "assistant") || typeof m.content !== "string") return;
messages.push({ role: m.role, content: m.content });
}
return messages;
}
function readPath(body: unknown): string | undefined {
if (!body || typeof body !== "object" || !("page" in body)) return;
const page = body.page;
if (!page || typeof page !== "object" || !("path" in page) || typeof page.path !== "string") return;
return page.path.replace(/(.)\/+$/, "$1");
}
/** OpenRouter's SSE (`data: {…choices[0].delta.content…}`) → the answer's bare text. */
function textStream(sse: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let buffer = "";
return sse.pipeThrough(
new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue; // `: OPENROUTER PROCESSING` keep-alives
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
let event: unknown;
try {
event = JSON.parse(data);
} catch {
continue;
}
if (event && typeof event === "object" && "error" in event) {
// After the 200: the stream just ends, and the island shows its
// error notice for an empty answer.
console.error(`ask: provider error mid-stream: ${JSON.stringify(event.error)}`);
continue;
}
if (!event || typeof event !== "object" || !("choices" in event) || !Array.isArray(event.choices)) continue;
const choice: unknown = event.choices[0];
if (!choice || typeof choice !== "object" || !("delta" in choice)) continue;
const delta = choice.delta;
if (!delta || typeof delta !== "object" || !("content" in delta)) continue;
const content = delta.content;
if (typeof content === "string" && content) controller.enqueue(encoder.encode(content));
}
},
}),
);
}
export async function handleAsk(request: Request, env: Env): Promise<Response> {
// The docs serve from the custom domain and the Pages origin; reflect the
// requesting origin only when it is on the allowlist (as /api/stats does).
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": "POST",
"access-control-allow-headers": "content-type",
"access-control-max-age": "86400",
vary: "Origin",
};
const text = (body: string, status: number) =>
new Response(body, { status, headers: { ...cors, "content-type": "text/plain; charset=utf-8" } });
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: cors });
if (request.method !== "POST") return text("POST only.", 405);
const raw = await request.arrayBuffer();
if (raw.byteLength > BODY_LIMIT) return text("Request too large: the body must be at most 64 KB.", 413);
let body: unknown;
try {
body = JSON.parse(new TextDecoder().decode(raw));
} catch {
body = undefined;
}
const messages = readMessages(body);
if (!messages) return text("Invalid request: send 1-40 user/assistant messages with string content.", 400);
if (!env.OPENROUTER_API_KEY) return text("The assistant is not configured: set OPENROUTER_API_KEY.", 503);
let system: string;
try {
system = await instructions(env, messages, readPath(body));
} catch (err) {
// Ungrounded beats unanswered: the prompt still forbids inventing details.
console.warn(`ask: grounding unavailable: ${err}`);
system = SYSTEM_PROMPT;
}
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
authorization: `Bearer ${env.OPENROUTER_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({
model: env.ASK_MODEL,
stream: true,
reasoning: { effort: "none" },
messages: [{ role: "system", content: system }, ...messages],
}),
signal: request.signal,
});
if (!res.ok || !res.body) {
console.error(`ask: POST /chat/completions -> ${res.status} ${await res.text().catch(() => "")}`);
return text("Failed to generate a response.", res.status === 429 ? 429 : 502);
}
return new Response(textStream(res.body), {
headers: { ...cors, "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" },
});
}
+53 -7
View File
@@ -9,9 +9,16 @@
* 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.
*
* A topic's problems are its sub-issues PLUS those re-parented into a
* `Review <N> — <Phase>` issue (reviews.ts recovers which topic each came
* from). Walking topic sub-issues alone would silently freeze a moved
* problem's row: upserts only, so nothing breaks — its labels just stop
* syncing.
*/
import { DIFFICULTIES, SETS, workingDay } from "./srs.ts";
import { DIFFICULTIES, SCHEDULE, SETS, workingDay } from "./srs.ts";
import type { GitHub } from "./github.ts";
import { movedFromTopics, readReviews, type Review } from "./reviews.ts";
const TITLE_RE = /^LC (\d+) · (.+)$/;
@@ -21,6 +28,37 @@ const DEFER_FROM = "2026-09-28";
export interface ReconcileReport {
topics: number;
problems: number;
/** Where schedule.json's review days disagree with the Review issues; empty when in step. */
reviewDrift: string[];
}
/**
* schedule.json copies each review day's issue, title and problems so the
* digest and RESERVED need no GitHub call; the issues stay the truth. Compared
* here because this is the one place that already holds both.
*/
function reviewDrift(reviews: Review[]): string[] {
const drift: string[] = [];
const booked = new Map<number, string>();
for (const [date, day] of Object.entries(SCHEDULE)) {
if (!day.review) continue;
const { issue, title, problems } = day.review;
booked.set(issue, date);
const review = reviews.find((r) => r.number === issue);
if (!review) {
drift.push(`${date}: #${issue} is not a "Review <N> — <Phase>" issue`);
continue;
}
if (review.title !== title) drift.push(`${date}: #${issue} is titled "${review.title}", schedule says "${title}"`);
const actual = review.problems.map((p) => Number(p.title.match(TITLE_RE)?.[1]));
if (actual.join(",") !== problems.join(",")) {
drift.push(`${date}: #${issue} holds LC ${actual.join(", ")}, schedule says LC ${problems.join(", ")}`);
}
}
for (const review of reviews) {
if (!booked.has(review.number)) drift.push(`#${review.number} "${review.title}" has no day in schedule.json`);
}
return drift;
}
/** Label names on one issue payload, narrowed with guards rather than cast. */
@@ -72,24 +110,32 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
);
}
const reviews = await readReviews(gh);
const drift = reviewDrift(reviews);
for (const line of drift) console.warn(`catalog: review drift — ${line}`);
const moved = movedFromTopics(reviews);
let problems = 0;
let deferredSeen = 0;
for (const t of topics) {
const members: { number: number; title: string; labels: string[] }[] = [];
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);
members.push({ number: raw.number, title: raw.title, labels: labelNames(raw) });
}
members.push(...(moved.get(t.number) ?? []));
for (const { number, title, labels } of members) {
if (!labels.includes("problem")) continue; // non-curriculum sub-issue
const m = raw.title.match(TITLE_RE);
const m = title.match(TITLE_RE);
if (!m) {
console.warn(`catalog: #${raw.number} is a problem but not titled "LC <n> · <name>"`);
console.warn(`catalog: #${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`);
console.warn(`catalog: #${number} is missing a diff:/set: label — skipped`);
continue;
}
const lc = Number(m[1]);
@@ -106,7 +152,7 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
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),
.bind(lc, number, t.number, m[2]!, difficulty, set, defer),
);
problems++;
}
@@ -116,5 +162,5 @@ export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<Reco
for (let i = 0; i < statements.length; i += 50) {
await db.batch(statements.slice(i, i + 50));
}
return { topics: topics.length, problems };
return { topics: topics.length, problems, reviewDrift: drift };
}
+17 -4
View File
@@ -8,8 +8,9 @@
* 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)
* learning day's topics (core, then the optional "if time" list), a set
* day's problems, and a review day's `Review <N>` issue — 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
@@ -23,6 +24,7 @@
*/
import {
CAMPAIGN_DAYS,
MISS_WHERE,
type ProblemRow,
RESERVED,
REVIEW_CAP,
@@ -84,8 +86,7 @@ async function openMisses(db: D1Database): Promise<ProblemRow[]> {
.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'
WHERE ${MISS_WHERE}
ORDER BY a.date, p.lc_number`,
)
.all<ProblemRow>();
@@ -150,6 +151,18 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
data.assigned = rows.map((p) => topicRow(env, p));
}
// A review day: the `Review <N> — <Phase>` issue's problems, in its solve
// order, linked like a set day's and headed by the issue itself.
if (plan.review) {
const { rows, missing } = await lookupProblems(db, plan.review.problems);
if (missing.length) console.warn(`digest ${date}: review problems not in the catalog: ${missing.join(", ")}`);
data.review = {
title: plan.review.title,
url: `https://github.com/${env.REPO}/issues/${plan.review.issue}`,
rows: 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) {
+41 -8
View File
@@ -71,6 +71,13 @@ const LAST_SEEN: Record<string, string> = {
retired: "retired",
};
// A review day's rules, the same ones its GitHub issue carries — one sentence
// here so the HTML and the plain-text alternative cannot word them apart.
const REVIEW_RULES =
"90 minutes, top to bottom — easy to hard; skip what is already solved. " +
"Before each submit, say the time and space complexity out loud and trace one edge case. " +
"Whatever is unfinished stays open.";
// ── data model ───────────────────────────────────────────────────
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
@@ -101,7 +108,7 @@ export interface TopicSection {
export interface DigestData {
/** "Tuesday, August 25" */
day: string;
/** "Day 9 of 56 · Week 2" */
/** "Day 9 of 79 · Week 2" — the length is CAMPAIGN_DAYS, derived from the plan. */
progress: string;
streak: number;
rest: boolean;
@@ -109,6 +116,8 @@ export interface DigestData {
topics: TopicSection[];
/** A set day's problems, assigned by number rather than by topic. */
assigned?: TopicRow[];
/** A review day: the `Review <N> — <Phase>` issue and its problems in solve order. */
review?: { title: string; url: string; rows: TopicRow[] };
reviews: DigestRow[];
/** Reviews past the daily cap; levelled onto the coming days. */
carried: number;
@@ -293,11 +302,13 @@ 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"
: "";
: data.review
? `, starting with ${data.review.title}`
: 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;
@@ -350,6 +361,19 @@ function DigestEmail({ data }: { data: DigestData }) {
</Section>
))}
{data.review ? (
<Section style={{ marginTop: "28px" }}>
<Text style={label}>Today's review</Text>
<Text style={{ fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
<Link href={data.review.url} style={{ color: INK, textDecoration: "underline" }}>
{data.review.title}
</Link>
</Text>
<Text style={hint}>{REVIEW_RULES}</Text>
<TopicTable rows={data.review.rows} />
</Section>
) : null}
{data.assigned ? (
<Section style={{ marginTop: "28px" }}>
<Text style={label}>Today's set</Text>
@@ -407,14 +431,14 @@ function DigestEmail({ data }: { data: DigestData }) {
</>
)}
{/* Eight weeks at a glance — the one thing here that is pure
{/* The whole campaign 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"
alt="Attempt heatmap across the whole campaign"
width="552"
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
/>
@@ -473,6 +497,15 @@ function plainDigest(data: DigestData): string {
out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked));
}
}
if (data.review) {
out.push(
"",
`TODAY'S REVIEW — ${data.review.title}`,
` ${data.review.url}`,
REVIEW_RULES,
...data.review.rows.map(linked),
);
}
if (data.assigned) {
out.push(
"",
+3 -1
View File
@@ -5,7 +5,8 @@
* 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
* `/done` on an uncatalogued number can never be logged; an empty list skips
* the week's checkpoint outright. 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
@@ -77,6 +78,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
// 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?.length === 0) return { created: false, reason: `the plan skips the week ${week} checkpoint` };
if (planned) {
const { rows, missing } = await lookupProblems(db, planned);
if (missing.length) {
+6 -2
View File
@@ -9,6 +9,7 @@
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
* admin routes); everything else is read-only public.
*/
import { handleAsk } from "./ask.ts";
import { reconcileCatalog } from "./catalog.ts";
import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts";
import { sendDigest } from "./digest.ts";
@@ -17,6 +18,7 @@ import { type GitHub, github } from "./github.ts";
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
import { type Mirror, projectMirror } from "./mirror.ts";
import {
CAMPAIGN_DAYS,
CAMPAIGN_START,
type LogOutcome,
type Stage,
@@ -322,6 +324,8 @@ export default {
if (path.startsWith("/admin/") && request.method === "POST") {
return await handleAdmin(request, env, path);
}
// The docs assistant (the static Pages site's `ai.assistant.endpoint`).
if (path === "/ask") return await handleAsk(request, env);
if (path === "/chart/progress.svg") {
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
@@ -330,10 +334,10 @@ export default {
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 });
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, CAMPAIGN_DAYS), { headers: SVG_HEADERS });
}
if (path === "/chart/heatmap.png") {
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, 56), { headers: PNG_HEADERS });
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, CAMPAIGN_DAYS), { headers: PNG_HEADERS });
}
if (path === "/badge/gate.svg") {
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
+140
View File
@@ -0,0 +1,140 @@
/**
* `Review <N> — <Phase>` issues: the transfer phase's day blocks. Problems were
* re-parented into them from their topics (`replace_parent` — a GitHub issue
* has exactly one parent), so a topic's sub-issue list no longer names every
* problem it owns. This module is the ONE recovery of that ownership: each
* moved problem's topic is its last `parent_issue_removed` event naming a
* topic, which GitHub records and no human edits.
*
* Two consumers must agree on it: the Worker's catalog reconcile
* (catalog.ts — else a moved problem's D1 row stops syncing its labels) and
* the CLI's topic rules (apps/cli/topics.ts — else close-topics and the
* release bodies change when a problem moves). Hence it lives here, the lower
* layer, the way apps/cli already imports srs.ts.
*
* Library: side-effect-free on import; the caller passes its GitHub client —
* the Worker's or the CLI's, which share the two members used here.
*/
import type { GitHub } from "./github.ts";
/** `Review 7 — Nodal & Grid`, never the Worker's weekly `Review — Week N` gate. */
const REVIEW_TITLE = /^Review \d+ — /;
const TOPIC_TITLE = /^Topic \d+ — /;
/** One problem issue as a topic or review lists it. */
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 Review {
number: number;
title: string;
open: boolean;
/** Markdown body: `**Scheduled:**` / `**Phase:**` / `**Load:**` lines the release reads. */
body: string;
/** Sub-issues in the order GitHub lists them (the solve order). */
problems: (Problem & { fromTopic: number | null })[];
}
/**
* Every `Review <N> — <Phase>` issue, open and closed, with its sub-issues.
* One GraphQL request per 50 `review`-labelled issues (the Worker's weekly
* gate issues share the label and are dropped by title): the sub-issues and
* the timeline filter ride along, so recovering each problem's source topic
* costs no extra request. That matters in the Worker, whose nightly cron
* spends its subrequest budget on the catalog walk and the digest.
*/
export async function readReviews(gh: Pick<GitHub, "repo" | "graphql">): Promise<Review[]> {
const [owner, name] = gh.repo.split("/");
const reviews: Review[] = [];
for (let after: string | null = null; ; ) {
// Shape fixed by the query below; GraphQL errors throw in gh.graphql().
const data = (await gh.graphql(
`query($owner: String!, $name: String!, $after: String) {
repository(owner: $owner, name: $name) {
issues(labels: ["review"], first: 50, after: $after, orderBy: { field: CREATED_AT, direction: ASC }) {
pageInfo { hasNextPage endCursor }
nodes {
number title state body
subIssues(first: 50) {
pageInfo { hasNextPage }
nodes {
number title state closedAt
labels(first: 20) { nodes { name } }
timelineItems(itemTypes: [PARENT_ISSUE_REMOVED_EVENT], last: 10) {
nodes { ... on ParentIssueRemovedEvent { parent { number title } } }
}
}
}
}
}
}
}`,
{ owner, name, after },
)) as {
repository: {
issues: {
pageInfo: { hasNextPage: boolean; endCursor: string | null };
nodes: {
number: number;
title: string;
state: "OPEN" | "CLOSED";
body: string;
subIssues: {
pageInfo: { hasNextPage: boolean };
nodes: {
number: number;
title: string;
state: "OPEN" | "CLOSED";
closedAt: string | null;
labels: { nodes: { name: string }[] };
timelineItems: { nodes: { parent: { number: number; title: string } | null }[] };
}[];
};
}[];
};
};
};
const page = data.repository.issues;
for (const issue of page.nodes) {
if (!REVIEW_TITLE.test(issue.title)) continue;
// A review is one day's handful; more than a page means a mis-linked issue.
if (issue.subIssues.pageInfo.hasNextPage) throw new Error(`review #${issue.number} has over 50 sub-issues`);
reviews.push({
number: issue.number,
title: issue.title,
open: issue.state === "OPEN",
body: issue.body,
problems: issue.subIssues.nodes.map((p) => ({
number: p.number,
title: p.title,
closedAt: p.state === "CLOSED" ? p.closedAt : null,
labels: p.labels.nodes.map((l) => l.name),
// Oldest first, so the last topic it left is its owner; a later
// review-to-review move names a review and is skipped.
fromTopic:
p.timelineItems.nodes.findLast((e) => e.parent && TOPIC_TITLE.test(e.parent.title))?.parent?.number ?? null,
})),
});
}
if (!page.pageInfo.hasNextPage) break;
after = page.pageInfo.endCursor;
}
return reviews.sort((a, b) => a.number - b.number);
}
/** Topic -> the problems moved out of it into Review issues, in review order. */
export function movedFromTopics(reviews: Review[]): Map<number, Problem[]> {
const moved = new Map<number, Problem[]>();
for (const review of reviews) {
for (const { fromTopic, ...problem } of review.problems) {
if (fromTopic !== null) moved.set(fromTopic, [...(moved.get(fromTopic) ?? []), problem]);
}
}
return moved;
}
+16
View File
@@ -134,6 +134,8 @@ describe("the plan", () => {
expect(isRestDay(date)).toBe(false);
if (day.gate) expect(weekdayOf(date)).toBe(6);
if (day.topics) expect(weekdayOf(date)).toBeLessThan(6);
// Reviews run Mon/Tue and Thu/Fri; Wednesday and the weekend stay free.
if (day.review) expect([1, 2, 4, 5]).toContain(weekdayOf(date));
}
});
@@ -150,6 +152,20 @@ describe("the plan", () => {
expect(topicDate(999)).toBeUndefined();
});
test("a review is booked once, and no problem sits in two reviews", () => {
const issues = new Set<number>();
const problems = new Set<number>();
for (const day of Object.values(SCHEDULE)) {
if (!day.review) continue;
expect(issues.has(day.review.issue)).toBe(false);
issues.add(day.review.issue);
for (const lc of day.review.problems) {
expect(problems.has(lc)).toBe(false);
problems.add(lc);
}
}
});
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 ?? [])]) {
+42 -9
View File
@@ -22,8 +22,8 @@ import scheduleJson from "../data/schedule.json";
// ── dates (America/New_York) ─────────────────────────────────────
/** Day 1. The last day is not a constant: see CAMPAIGN_END below the schedule. */
export const CAMPAIGN_START = "2026-08-17";
export const CAMPAIGN_DAYS = 56;
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
timeZone: "America/New_York",
@@ -130,15 +130,26 @@ export function prettyDate(date: string): string {
* 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.
* a transfer-phase review 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. */
/**
* A `Review <N> — <Phase>` day (Mon/Tue/Thu/Fri only): the issue, its title
* and its problems in solve order. The issue's sub-issues are the truth; this
* copy is what the digest and RESERVED read without calling GitHub, and the
* catalog reconcile reports any drift between the two.
*/
review?: { issue: number; title: string; problems: number[] };
/**
* Saturday: the blind set, fixed by the plan rather than sampled. An empty
* list skips that week's checkpoint — no issue is created.
*/
gate?: number[];
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
sweep?: boolean;
@@ -146,6 +157,15 @@ export interface ScheduledDay {
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
/**
* The last day the plan books anything — the campaign ends there. Derived,
* not declared: booking a later day (the review phase ran the plan past its
* original eight weeks) moves the digest's "Day N/M", the stats' `heat` and
* the heatmap charts with it, and nothing can disagree about when it ends.
*/
export const CAMPAIGN_END = Object.keys(SCHEDULE).sort().at(-1)!;
export const CAMPAIGN_DAYS = daysBetween(CAMPAIGN_START, CAMPAIGN_END) + 1;
/** 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)) {
@@ -161,13 +181,17 @@ export function topicWeek(topic: number): number | undefined {
}
/**
* 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.
* Every problem the plan names outright — a gate's blind set, a set day's
* list, or a review day's. 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 or review day's problems wait for their day.
*/
export const RESERVED: ReadonlySet<number> = new Set(
Object.values(SCHEDULE).flatMap((day) => [...(day.gate ?? []), ...(day.problems ?? [])]),
Object.values(SCHEDULE).flatMap((day) => [
...(day.gate ?? []),
...(day.problems ?? []),
...(day.review?.problems ?? []),
]),
);
// ── deterministic sampling ───────────────────────────────────────
@@ -310,6 +334,15 @@ export async function lookupProblems(
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)`;
/**
* What an open miss means, in one place: a problem `p` whose most recent
* attempt (highest id) failed and that has not retired. The sweep day works
* these and the stats' "attempting" count is how many there are, so the two
* cannot disagree. Expects `problems p` joined to `attempts a` on lc_number.
*/
export const MISS_WHERE = `a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number)
AND a.result = 'fail' AND p.stage != 'retired'`;
/** 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
+90 -3
View File
@@ -8,12 +8,17 @@
* 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.
*
* `recent`, `attempting` and `year` feed the docs page's LeetCode-style cards
* (recently solved, the solved ring, the heatmap and the submissions chart).
* They are additive: the terminal dashboard ignores them.
*/
import {
CAMPAIGN_DAYS,
CAMPAIGN_START,
DIFFICULTIES,
DUE_WHERE,
MISS_WHERE,
addDays,
campaignDay,
campaignWeek,
@@ -54,6 +59,12 @@ interface TopicStat {
temperature: Temperature;
}
/** Days in `year`: the 365 ending today, which always covers the current calendar year to date. */
const YEAR_DAYS = 365;
/** How many problems `recent` lists. */
const RECENT_LIMIT = 10;
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.
@@ -195,9 +206,9 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
);
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.
// Attempts per campaign day, every day present. This is the README heatmap's
// data model — the terminal dashboard lays it out by campaign week and reads
// its tail for "last 14 days" rather than asking a second question.
const end = addDays(CAMPAIGN_START, CAMPAIGN_DAYS - 1);
const { results: heatRows } = await db
.prepare(
@@ -215,16 +226,92 @@ export async function buildStats(db: D1Database, today: string): Promise<object>
return { date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 };
});
// The ten problems most recently solved, dated by each one's FIRST passing
// attempt — when it was completed, not when it was last re-solved (LeetCode's
// "Recent AC"). Same-day ties fall back to log order.
const { results: recentRows } = await db
.prepare(
`SELECT p.lc_number AS lc, p.title AS title, p.difficulty AS difficulty,
MIN(a.date) AS date, MIN(a.id) AS seq
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
WHERE a.result = 'pass'
GROUP BY p.lc_number
ORDER BY date DESC, seq DESC
LIMIT ?1`,
)
.bind(RECENT_LIMIT)
.all<{ lc: number; title: string; difficulty: string; date: string; seq: number }>();
const recent = recentRows.map(({ lc, title, difficulty, date }) => ({ lc, title, difficulty, date }));
// Problems still standing on a failure — the sweep day's miss log, counted.
const missRow = await db
.prepare(
`SELECT COUNT(*) AS n FROM problems p
JOIN attempts a ON a.lc_number = p.lc_number
WHERE ${MISS_WHERE}`,
)
.first<{ n: number }>();
// The past year, one entry per day (zeros included), split by difficulty two
// ways: every logged attempt, and every first solve — the same first-pass
// date `recent` uses. `heat` stays campaign-scoped (the terminal dashboard
// lays it out by campaign week); this is the calendar-scoped view the docs
// heatmap and submissions chart window and bucket for themselves.
const yearStart = addDays(today, -(YEAR_DAYS - 1));
const { results: attemptRows } = await db
.prepare(
`SELECT a.date AS date, p.difficulty AS difficulty,
COUNT(*) AS n,
SUM(CASE WHEN a.result = 'pass' THEN 1 ELSE 0 END) AS passes
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
WHERE a.date BETWEEN ?1 AND ?2
GROUP BY a.date, p.difficulty`,
)
.bind(yearStart, today)
.all<{ date: string; difficulty: string; n: number; passes: number | null }>();
const { results: solvedRows } = await db
.prepare(
`SELECT date, difficulty, COUNT(*) AS n
FROM (SELECT MIN(a.date) AS date, p.difficulty AS difficulty
FROM attempts a JOIN problems p ON p.lc_number = a.lc_number
WHERE a.result = 'pass'
GROUP BY p.lc_number)
WHERE date BETWEEN ?1 AND ?2
GROUP BY date, difficulty`,
)
.bind(yearStart, today)
.all<{ date: string; difficulty: string; n: number }>();
const year = Array.from({ length: YEAR_DAYS }, (_, i) => ({
date: addDays(yearStart, i),
passes: 0,
attempts: Object.fromEntries(DIFFICULTIES.map((d) => [d, 0])),
solved: Object.fromEntries(DIFFICULTIES.map((d) => [d, 0])),
}));
const yearIndex = new Map(year.map((d, i) => [d.date, i]));
for (const row of attemptRows) {
const day = year[yearIndex.get(row.date) ?? -1];
if (!day || !(row.difficulty in day.attempts)) continue;
day.attempts[row.difficulty] = row.n;
day.passes += row.passes ?? 0;
}
for (const row of solvedRows) {
const day = year[yearIndex.get(row.date) ?? -1];
if (day && row.difficulty in day.solved) day.solved[row.difficulty] = row.n;
}
return {
generated: new Date().toISOString(),
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
phases: [...phases.values()],
ladder,
difficulty,
attempting: missRow?.n ?? 0,
topics: await buildTopics(db, today),
gates,
streak: await streak(db, today),
queue,
heat,
recent,
year,
};
}
+8 -3
View File
@@ -32,10 +32,15 @@
"REPO": "prdlk/leetcode",
"FROM_EMAIL": "srs@prdlk.com",
"TO_EMAIL": "prnk28@gmail.com",
"DOCS_URL": "https://prdlk.github.io/leetcode",
"DOCS_ORIGIN": "https://prdlk.github.io,https://lc.prad.nu",
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev"
"DOCS_URL": "https://lc.prd.lk",
"DOCS_ORIGIN": "https://lc.prd.lk,https://prdlk.github.io",
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev",
// The docs assistant's OpenRouter model (POST /ask). `:free` variants share
// an upstream pool and answer 429 when it is saturated.
"ASK_MODEL": "qwen/qwen3.8-27b:free"
}
// Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY.
// OPENROUTER_API_KEY (the docs assistant, POST /ask) is the repo's Actions
// secret of that name, put by deploy.yml before every deploy.
}
+60 -47
View File
@@ -1,24 +1,34 @@
#!/usr/bin/env bun
/**
* Close the GitHub issue for every topic whose required (core) problems are done.
* Close the GitHub issue for every topic whose required (core) problems are
* done, and for every `Review <N> — <Phase>` issue whose problems all are.
*
* 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").
* Problems re-parented into Review issues still count for their topic. The
* rules live in topics.ts, shared with release-topics.ts.
*
* A Review issue is a fixed day's assignment, so it closes when every one of
* its sub-issues is closed, whatever their set. Closing it fires the Worker's
* `review`-label webhook, which scores only issues with a gate record — these
* have none, so that is a no-op.
*
* 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.
* open topics/reviews are touched, so reopening a problem never reopens either.
*
* bun apps/cli/close-topics.ts # close finished topics
* bun apps/cli/close-topics.ts # close finished topics and reviews
* 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 { movedFromTopics, readReviews } from "../api/src/reviews.ts";
import { github } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
import { completion, coreSet, readProblems } from "./topics.ts";
const DRY =
process.argv.includes("--dry-run") ||
@@ -53,44 +63,8 @@ for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=ope
}
topics.sort((a, b) => a.number - b.number);
// ── core sub-issue state per topic ───────────────────────────────
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[];
}
/**
* Read a topic's core sub-issue state. The sub_issues payload carries the full
* issue objects (state + labels), so no per-problem follow-up request is needed.
*/
async function readCore(topic: number): Promise<Core> {
const all: number[] = [];
const pending: number[] = [];
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 (!("state" in raw) || typeof raw.state !== "string") continue;
if (!("labels" in raw) || !Array.isArray(raw.labels)) continue;
const core = raw.labels.some(
(label) =>
label &&
typeof label === "object" &&
"name" in label &&
label.name === "set:core",
);
if (!core) continue;
all.push(raw.number);
if (raw.state === "open") pending.push(raw.number);
}
all.sort((a, b) => a - b);
pending.sort((a, b) => a - b);
return { all, pending };
}
const reviews = await readReviews(gh);
const moved = movedFromTopics(reviews);
// ── reconcile ────────────────────────────────────────────────────
@@ -98,7 +72,7 @@ const report: string[][] = [];
let closed = 0;
for (const topic of topics) {
const core = await readCore(topic.number);
const core = coreSet(await readProblems(gh, topic.number, moved));
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,
@@ -132,19 +106,58 @@ for (const topic of topics) {
closed++;
}
// ── reviews ──────────────────────────────────────────────────────
const reviewReport: string[][] = [];
let reviewsClosed = 0;
for (const review of reviews.filter((r) => r.open)) {
const set = completion(review.problems);
const progress = `${set.all.length - set.pending.length}/${set.all.length}`;
// An empty review would read as done; leave it for a human, like a topic
// with no core set.
if (set.all.length === 0) {
reviewReport.push([`#${review.number}`, review.title, progress, "no sub-issues"]);
continue;
}
if (set.pending.length > 0) {
reviewReport.push([`#${review.number}`, review.title, progress, `open: ${set.pending.map((n) => `#${n}`).join(" ")}`]);
continue;
}
if (DRY) {
reviewReport.push([`#${review.number}`, review.title, progress, "would close"]);
continue;
}
await gh.closeIssue(
review.number,
`Review complete — all ${set.all.length} problems closed ` +
`(${set.all.map((n) => `#${n}`).join(", ")}).\n\n` +
"Closed automatically by `close-topics`.",
);
reviewReport.push([`#${review.number}`, review.title, progress, "closed"]);
reviewsClosed++;
}
// ── report ───────────────────────────────────────────────────────
const rows = [["topic", "title", "core", "status"], ...report];
printTable(rows);
const reviewRows = [["review", "title", "closed", "status"], ...reviewReport];
console.log();
printTable(reviewRows);
const finished = report.filter((r) => r[3] === "closed" || r[3] === "would close");
const finished = new Set(["closed", "would close"]);
const topicsDone = report.filter((r) => finished.has(r[3]!)).length;
const reviewsDone = reviewReport.filter((r) => finished.has(r[3]!)).length;
console.log(
`\n${topics.length} open topics · ` +
`${DRY ? `would close ${finished.length}` : `closed ${closed}`}`,
`\n${topics.length} open topics · ${DRY ? `would close ${topicsDone}` : `closed ${closed}`}` +
`\n${reviewReport.length} open reviews · ${DRY ? `would close ${reviewsDone}` : `closed ${reviewsClosed}`}`,
);
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"),
`${topics.length} open topic issues, ${topicsDone} with a complete core set.\n\n` +
(report.length ? `${markdownTable(rows)}\n` : "No open topics.\n") +
`\n${reviewReport.length} open review issues, ${reviewsDone} with every problem closed.\n\n` +
(reviewReport.length ? `${markdownTable(reviewRows)}\n` : "No open reviews.\n"),
);
+2 -1
View File
@@ -10,7 +10,8 @@
"sync": "bun ./sync.ts",
"videos": "bun ./videos.ts",
"close-solved": "bun ./close-solved.ts",
"close-topics": "bun ./close-topics.ts"
"close-topics": "bun ./close-topics.ts",
"release-topics": "bun ./release-topics.ts"
},
"devDependencies": {
"@clack/prompts": "^1.7.0",
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env bun
/**
* Publish one GitHub release per finished topic and per finished
* `Review <N> — <Phase>` issue, versioned by timever
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668).
*
* "Finished" is topics.ts's rule — a topic when every core problem is closed,
* a review when every one of its sub-issues is — the same one close-topics.ts
* closes the issue on, and the version is the instant the LAST required
* problem closed. Keying off the sub-issues rather than the topic/review issue
* lets this run as the tail of Close Solved: the push that closes the final
* required problem releases it in the same run, without waiting for
* close-topics to chain after it. The issue itself closes seconds later, on
* the same day.
*
* Each release is a changelog snapshot at that instant: the issue's problems
* that had closed by then (a topic's sub-issues plus those since re-parented
* into Review issues, or a review's sub-issues in solve order — see
* topics.ts), grouped as closed on the finish day (ET) and closed earlier,
* each with its set, difficulty, and the solution files as they stood in the
* tagged commit — the last main commit before the finish. Problems still open
* at the finish are left out: the release records what finished, and an open
* optional problem is tracked by its issue (or its Review day), not here.
* apps/docs/lib/releases.ts reads these `### Closed …` tables back for the
* docs `/changelog` timeline, so their row shape is its contract.
*
* 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), so the docs changelog dates each entry from
* this timever tag instead (apps/docs/lib/releases.ts)
* — the tag format is that contract.
*
* Reconciles instead of reacting: every finished topic or review 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 { movedFromTopics, readReviews, type Problem } from "../api/src/reviews.ts";
import { github } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
import { completion, coreSet, labelValue, readProblems } 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();
}
// ── finished topics and reviews ──────────────────────────────────
/** A topic or Review issue, as far as its release reads it. */
interface Issue {
number: number;
title: string;
milestone: string | null;
body: string;
}
/** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */
function readTopic(value: unknown): Issue | 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 {
kind: "topic" | "review";
issue: Issue;
/** Topic: every problem it owns, by number. Review: its sub-issues, in solve order. */
problems: Problem[];
/** The set whose last close finished it: a topic's core, a review's every problem. */
required: number[];
finishedAt: string;
}
// Closed and open issues alike: the rules read the sub-issues, so a topic or
// review finished moments ago (close-topics not yet run) releases now.
const finished: Finished[] = [];
const reviews = await readReviews(gh);
const moved = movedFromTopics(reviews);
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
const issue = readTopic(raw);
if (!issue) continue;
const problems = await readProblems(gh, issue.number, moved);
const core = coreSet(problems);
if (core.finishedAt) finished.push({ kind: "topic", issue, problems, required: core.all, finishedAt: core.finishedAt });
}
for (const review of reviews) {
const set = completion(review.problems);
if (!set.finishedAt) continue;
finished.push({
kind: "review",
// The review's phase is its body's `**Phase:**` line; its milestone is the
// shared "Review — Transfer & Mocks" and says nothing per issue.
issue: { number: review.number, title: review.title, milestone: null, body: review.body },
problems: review.problems,
required: set.all,
finishedAt: set.finishedAt,
});
}
// Oldest first, so a backfill creates releases in campaign order.
finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.issue.number - b.issue.number);
// ── existing releases ────────────────────────────────────────────
/** Topic or review issue -> its release. Name and body are kept in sync with the render on every run. */
const released = new Map<number, { id: number; tag: string; name: 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 name = "name" in raw && typeof raw.name === "string" ? raw.name : "";
const m = raw.body.match(/<!-- release-topics: #(\d+) -->/);
if (m) released.set(Number(m[1]), { id: raw.id, tag: raw.tag_name, name, body: raw.body });
}
// ── timever ──────────────────────────────────────────────────────
/**
* Timever of an instant. The API reports whole seconds, so SUBTIME is the
* tiebreak: two topics or reviews finishing in the same second (one push
* closing both last problems) get consecutive nanoseconds, in (instant,
* issue) order.
*/
const tags = new Map<number, string>(); // issue -> 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.issue.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 { issue, problems } = f;
const finishDay = etDate(new Date(f.finishedAt));
// Snapshot at the finish instant: a problem closed later was open then, and
// open problems are not part of the release.
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 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.required.length} ${f.kind === "topic" ? "core " : ""}problems closed, ` +
`${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`;
const fields: string[] = [];
const scheduled = bodyField(issue.body, "Scheduled");
if (scheduled) fields.push(`**Scheduled:** ${scheduled}`);
const phase = f.kind === "topic" ? issue.milestone : bodyField(issue.body, "Phase");
if (phase) fields.push(`**Phase:** ${phase}`);
fields.push(`**${f.kind === "topic" ? "Topic" : "Review"}:** #${issue.number}`);
const buildsOn = bodyField(issue.body, "Builds on");
if (buildsOn) fields.push(`**Builds on:** ${buildsOn}`);
const load = bodyField(issue.body, "Load");
if (load) fields.push(`**Load:** ${load}`);
// The marker is how a later run finds this release; see the file header.
// One marker for both kinds: issue numbers never collide.
const sections = [`<!-- release-topics: #${issue.number} -->\n${summary}`, fields.join(" · ")];
sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the ${f.kind} finished\n\n${table(onDay)}`);
if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`);
const notes = NOTES.flatMap((h) => {
const text = bodySection(issue.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 ` +
`${f.kind === "topic" ? "core set completed" : "last problem closed"}; ` +
`it tags ${sha.slice(0, 7)}, the last main commit before it.`,
);
return `${sections.join("\n\n")}\n`;
}
// ── reconcile ────────────────────────────────────────────────────
const latest = finished.at(-1)?.issue.number;
const report: string[][] = [];
const previews: string[] = [];
let created = 0;
let updated = 0;
for (const f of finished) {
const { issue } = f;
const finishedEt = etDate(new Date(f.finishedAt));
const existing = released.get(issue.number);
// An existing release keeps its tag and commit; only its name and body are
// reconciled. Its tag may postdate this clone (released by another run), so
// fetch it when missing — read-only, so a dry run does it too.
const tag = existing?.tag ?? tags.get(issue.number)!;
if (existing && Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT }).exitCode !== 0) {
git("fetch", "-q", "origin", `refs/tags/${tag}:refs/tags/${tag}`);
}
const sha = existing
? git("rev-parse", `${tag}^{commit}`)
: git("rev-list", "-1", "--first-parent", `--before=${f.finishedAt}`, "HEAD");
if (!sha) {
report.push([`#${issue.number}`, issue.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);
const name = issue.title;
if (existing) {
if (existing.body === body && existing.name === name) {
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released"]);
} else if (DRY) {
previews.push(`## ${name} (${tag})\n\n${body}`);
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "would update"]);
} else {
await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, {
method: "PATCH",
body: JSON.stringify({ name, body }),
});
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "updated"]);
updated++;
}
continue;
}
if (DRY) {
previews.push(`## ${name} (${tag})\n\n${body}`);
report.push([`#${issue.number}`, issue.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",
`${issue.title} (#${issue.number})\n\n` +
`${f.kind === "topic" ? "Core set complete" : "All problems closed"}: ${f.required.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,
body,
make_latest: issue.number === latest ? "true" : "false",
}),
});
report.push([`#${issue.number}`, issue.title, finishedEt, tag, "released now"]);
created++;
}
// ── report ───────────────────────────────────────────────────────
if (previews.length) console.log(`${previews.join("\n")}\n`);
const rows = [["issue", "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 and reviews · ` +
(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 and reviews, ${released.size} already released.\n\n` +
(report.length ? `${markdownTable(rows)}\n` : "Nothing finished.\n") +
(previews.length ? `\n${previews.join("\n")}` : ""),
);
+531 -67
View File
@@ -2,37 +2,78 @@
/**
* Sync work/1 leetcode solutions into apps/docs/content/ pages.
*
* - New problems get a full page (frontmatter, badge, warning, examples,
* - New problems get a full page (frontmatter, warning, examples,
* constraints, solution) in the gold-standard format.
* - Already-ported pages only get their script-owned parts regenerated — the
* `<Badge>` line and the `## Solution` section — so hand-curated prose is
* never touched.
* frontmatter `icon:` (`PAGE_ICON`) 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.
* - Every page carries LeetCode's own topic tags as frontmatter `topics:`
* (`{ name, slug }` per tag, LeetCode's order), fetched from the GraphQL
* `topicTags` once — when the page is created, or on any later run that
* finds the key missing — and never refreshed after, so a sync with nothing
* new makes no request. Delete the key to refetch it. The page itself
* renders no badges: the docs MDX `wrapper`
* (`apps/docs/components/ProblemBadges.astro`) turns `topics:` and the
* `video:` key `videos.ts` writes into the badge row under the description.
* - Every planned page (a problem the README plan lists) carries its GitHub
* problem issue as frontmatter `issue:` (the URL of the `problem`-labelled
* issue titled `LC <num> · …`), rendered by the same wrapper as a GitHub
* badge after the others; an unplanned warm-up has no catalog issue and no
* key. Like `topics:` it is filled once — on create, or on any run that
* finds it missing — and the issue list is fetched only on such a run
* (never on a sync with nothing new); offline, or with no `gh` auth, the
* page is written without it and the next sync retries. Delete the key to
* refetch it.
* - Every tag a page carries that no campaign topic teaches (not a key of
* `apps/docs/topic-landings.ts`) belongs in the Appendix glossary,
* `apps/docs/appendix.ts`, which is hand-written: sync only checks it,
* warning about a carried tag with no term (its badge falls back to
* LeetCode's list), a term whose tag a topic now teaches, and a term icon
* that is missing, a topic's (`TOPIC_ICONS`), or another term's — every
* badge linking to the term shows it.
*
* 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.
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`,
* ordered by topic number via a script-owned `meta.ts`). A problem outside the
* plan (warm-ups) has no topic, so it groups under the data-structure CATEGORY
* its file sits under in `work/1/<Difficulty>/<Category>/` instead.
*
* Topic folders are real route segments (`/tree-dfs/invert-binary-tree`), not
* `(group)` folders, so a hand-written `index.mdx` in one 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.
* Topics nest under their README PHASE: `(01-linear-structures)/tree-dfs/`.
* The phase folder is a Blume `(group)` — a sidebar section 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. The topic's problem pages sit one level down in a `(problems)` group,
* `tree-dfs/(problems)/226_invert-binary-tree.mdx`: no route segment either,
* and Blume strips the `<num>_` prefix from the route while sorting by it, so
* the URL is still `/tree-dfs/invert-binary-tree`. Sync never writes or moves
* an `index.mdx`, and never removes a folder that still holds one. A phase
* folder's `meta.ts` is written once, when missing, then hand-owned (its
* title is yours to rename); a topic's is script-owned. 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, readdir, rename, rmdir, unlink } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { APPENDIX } from "../docs/appendix.ts";
import { TOPIC_LANDINGS } from "../docs/topic-landings.ts";
import { github } from "./github.ts";
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";
@@ -63,11 +104,15 @@ interface Solution {
// ── work/ parsing ────────────────────────────────────────────────
/** Split a source file into (header comment text, code). */
function splitSource(src: string, lang: Lang): { header: string; code: string } {
function splitSource(
src: string,
lang: Lang,
): { header: string; code: string } {
let header: string, code: string;
if (lang === "js") {
const end = src.indexOf("*/");
if (!src.trimStart().startsWith("/*") || end === -1) throw new Error("missing /* header */");
if (!src.trimStart().startsWith("/*") || end === -1)
throw new Error("missing /* header */");
header = src
.slice(src.indexOf("/*") + 2, end)
.split("\n")
@@ -99,7 +144,11 @@ function paragraphs(lines: string[]): string[] {
flush();
continue;
}
if (/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(text)) {
if (
/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(
text,
)
) {
flush();
cur = text;
} else {
@@ -117,7 +166,10 @@ function parseHeader(header: string): Header {
const num = Number.parseInt(titleLine, 10);
const title = titleLine.replace(/^\d+\.\s*/, "");
const difficulty =
lines.find((l) => l.trim().startsWith("Difficulty:"))?.split(":")[1]?.trim() ?? "";
lines
.find((l) => l.trim().startsWith("Difficulty:"))
?.split(":")[1]
?.trim() ?? "";
// Body: everything after the ───── separator.
const sep = lines.findIndex((l) => /^[─]{3,}/.test(l.trim()));
@@ -171,24 +223,94 @@ 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",
};
/**
* 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, Topic>();
let phase: Phase | undefined;
let topic: Topic | null = null;
for (const line of md.split("\n")) {
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() };
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.has(Number(n[1]))) map.set(Number(n[1]), topic);
@@ -196,10 +318,192 @@ async function readmeTopics(): Promise<Map<number, Topic>> {
return map;
}
/** Blume folder meta: exact topic label (the folder slug loses `/` and case) and curriculum order. */
/**
* 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. Written only when the folder has none — the title is hand-owned after
* that. Nothing is lost by never rewriting it: the order and name it is built
* from are also the folder name (`phaseDir`), so a README change to either
* lands in a new folder that gets a fresh meta.
*/
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: "page"` so the
* topic is one row under its phase heading that drills into its own panel
* (the landing page, then its `(problems)` list). 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 order = topic.order === undefined ? "" : `, order: ${topic.order}`;
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: ${JSON.stringify(topic.name)}${order} });\n`;
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`;
}
// ── LeetCode topic tags ──────────────────────────────────────────
/** One of LeetCode's topic tags. `slug` is not derivable from `name` (`Graph Theory` → `graph`). */
interface TopicTag {
name: string;
slug: string;
}
/**
* The problem's topic tags, in LeetCode's order — or undefined, warned, when
* LeetCode cannot answer (offline commit, unknown slug): the page is written
* without `topics:` and the next sync fills it in.
*/
async function fetchTopicTags(sol: Solution): Promise<TopicTag[] | undefined> {
try {
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/${sol.slug}/`,
},
body: JSON.stringify({
query:
"query topicTags($titleSlug: String!) { question(titleSlug: $titleSlug) { topicTags { name slug } } }",
variables: { titleSlug: sol.slug },
}),
});
if (!res.ok)
throw new Error(`POST /graphql -> ${res.status} ${res.statusText}`);
const data = (await res.json()) as {
data?: { question?: { topicTags?: TopicTag[] } | null };
};
const tags = data.data?.question?.topicTags;
if (!Array.isArray(tags))
throw new Error("POST /graphql -> no such question");
return tags.map(({ name, slug }) => ({ name, slug }));
} catch (err) {
console.warn(
`no topics for ${sol.num}.${sol.slug}: ${err instanceof Error ? err.message : err}`,
);
return undefined;
}
}
/** The frontmatter `topics:` block, one flow mapping per tag (JSON strings are valid YAML). */
function topicsYaml(tags: TopicTag[]): string {
if (tags.length === 0) return "topics: []";
return [
"topics:",
...tags.map(
(t) =>
` - { name: ${JSON.stringify(t.name)}, slug: ${JSON.stringify(t.slug)} }`,
),
].join("\n");
}
/** The frontmatter's `topics:` as sync wrote them (or a human edited them); [] when absent. */
function frontmatterTopics(page: string): TopicTag[] {
const fm = page.match(/^---\n([\s\S]*?)\n---/)?.[1];
const data: unknown = fm ? Bun.YAML.parse(fm) : undefined;
if (
!data ||
typeof data !== "object" ||
!("topics" in data) ||
!Array.isArray(data.topics)
)
return [];
return data.topics.filter(
(t): t is TopicTag =>
typeof t?.name === "string" && typeof t?.slug === "string",
);
}
// ── GitHub problem issues ────────────────────────────────────────
let issueUrls: Promise<Map<number, string> | undefined> | undefined;
/**
* LC number → the URL of its `problem` issue (`LC <num> · <Name>`, open or
* closed), listed once per run and only when a page first needs it — or
* undefined, warned, when GitHub cannot answer (offline commit, no `gh`
* auth): pages go without `issue:` and the next sync fills it in.
*/
function problemIssues(): Promise<Map<number, string> | undefined> {
issueUrls ??= (async () => {
try {
const gh = await github();
const urls = new Map<number, string>();
for await (const raw of gh.list(
`/repos/${gh.repo}/issues?labels=problem&state=all`,
)) {
if (!raw || typeof raw !== "object" || "pull_request" in raw) continue;
if (!("title" in raw) || typeof raw.title !== "string") continue;
if (!("html_url" in raw) || typeof raw.html_url !== "string") continue;
const lc = raw.title.match(/^LC (\d+) · /)?.[1];
if (lc && !urls.has(Number(lc))) urls.set(Number(lc), raw.html_url);
}
return urls;
} catch (err) {
console.warn(
`no problem issues: ${err instanceof Error ? err.message : err}`,
);
return undefined;
}
})();
return issueUrls;
}
/**
* The problem's issue URL, or undefined (warned when GitHub answered but has
* no such issue). Only a problem the README plan lists has a catalog issue;
* an unplanned warm-up never will, so it is not looked up — otherwise its
* missing key would re-list the issues and re-warn on every sync.
*/
async function issueUrl(sol: Solution): Promise<string | undefined> {
if (!topics.has(sol.num)) return undefined;
const urls = await problemIssues();
const url = urls?.get(sol.num);
if (urls && !url)
console.warn(
`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`,
);
return url;
}
// ── inline-code formatting heuristics ────────────────────────────
@@ -208,13 +512,15 @@ function groupMeta(topic: Topic): string {
function identifiers(sol: Solution): Set<string> {
const ids = new Set<string>(["n", "m", "k"]);
for (const code of Object.values(sol.code)) {
for (const m of code.matchAll(/@param\s*\{[^}]*\}\s*(\w+)/g)) ids.add(m[1]!);
for (const m of code.matchAll(/@param\s*\{[^}]*\}\s*(\w+)/g))
ids.add(m[1]!);
for (const m of code.matchAll(/def \w+\(self,?\s*([^)]*)\)/g))
for (const arg of m[1]!.split(","))
if (arg.trim()) ids.add(arg.split(":")[0]!.trim());
for (const m of code.matchAll(/(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g))
for (const arg of m[2]!.split(","))
if (arg.trim()) ids.add(arg.trim());
for (const m of code.matchAll(
/(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g,
))
for (const arg of m[2]!.split(",")) if (arg.trim()) ids.add(arg.trim());
}
return ids;
}
@@ -271,7 +577,11 @@ function backtickify(text: string, ids: Set<string>): string {
while (j > i && /^[+\-*/=%]$/.test(toks[j]!.core)) j--;
const span = toks
.slice(i, j + 1)
.map((tok, idx, arr) => (idx === arr.length - 1 ? `${tok.pre}${tok.core}` : `${tok.pre}${tok.core}${tok.post}`))
.map((tok, idx, arr) =>
idx === arr.length - 1
? `${tok.pre}${tok.core}`
: `${tok.pre}${tok.core}${tok.post}`,
)
.join(" ");
out.push(`\`${span}\`${toks[j]!.post}`);
i = j + 1;
@@ -299,15 +609,22 @@ function warningText(h: Header): string {
return (
h.statement
.slice(1)
.find((p) => /^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(p)) ?? ""
.find((p) =>
/^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(
p,
),
) ?? ""
);
}
function badgeLine(sol: Solution): string {
return `<Badge variant="accent">${sol.category}</Badge>`;
}
/** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */
const PAGE_ICON = "dot";
function renderPage(sol: Solution): string {
function renderPage(
sol: Solution,
tags: TopicTag[] | undefined,
issue: string | undefined,
): string {
const h = sol.header;
const ids = identifiers(sol);
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
@@ -316,14 +633,17 @@ function renderPage(sol: Solution): string {
parts.push(
"---",
`title: '${h.num}. ${h.title.replaceAll("'", "''")}'`,
/[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`,
/[:#]/.test(description)
? `description: '${description.replaceAll("'", "''")}'`
: `description: ${description}`,
`icon: ${PAGE_ICON}`,
...(tags ? [topicsYaml(tags)] : []),
...(issue ? [`issue: ${JSON.stringify(issue)}`] : []),
"sidebar:",
` label: '${h.title.replaceAll("'", "''")}'`,
` badge: '${h.difficulty}'`,
"---",
"",
badgeLine(sol),
"",
);
const warning = warningText(h);
@@ -337,7 +657,8 @@ function renderPage(sol: Solution): string {
parts.push(`- Explanation: ${backtickify(ex.explanation[0]!, ids)}`);
else if (ex.explanation.length > 1) {
parts.push("- Explanation:");
for (const item of ex.explanation) parts.push(` - ${backtickify(item, ids)}`);
for (const item of ex.explanation)
parts.push(` - ${backtickify(item, ids)}`);
}
parts.push("");
});
@@ -362,7 +683,10 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
}
const num = Number(m[1]);
const slug = m[2]!;
const { header, code } = splitSource(await Bun.file(join(WORK, rel)).text(), lang);
const { header, code } = splitSource(
await Bun.file(join(WORK, rel)).text(),
lang,
);
const parsed = parseHeader(header);
const existing = solutions.get(num);
if (existing) {
@@ -380,6 +704,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 {
@@ -389,10 +718,45 @@ 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);
}
/** Insert `topics:` before `sidebar:` when the frontmatter lacks it; an existing list is kept as is. */
async function ensureTopics(page: string, sol: Solution): Promise<string> {
const fm = page.match(/^---\n[\s\S]*?\n---/);
if (!fm || /^topics:/m.test(fm[0])) return page;
const tags = await fetchTopicTags(sol);
if (!tags) return page;
return (
fm[0].replace(/^sidebar:$/m, `${topicsYaml(tags)}\nsidebar:`) +
page.slice(fm[0].length)
);
}
/** Insert `issue:` before `sidebar:` when the frontmatter lacks it; an existing URL is kept as is. */
async function ensureIssue(page: string, sol: Solution): Promise<string> {
const fm = page.match(/^---\n[\s\S]*?\n---/);
if (!fm || /^issue:/m.test(fm[0])) return page;
const url = await issueUrl(sol);
if (!url) return page;
return (
fm[0].replace(/^sidebar:$/m, `issue: ${JSON.stringify(url)}\nsidebar:`) +
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.
* appends after the solution survive regeneration.
*/
function solutionEnd(page: string, from: number): number {
const lines = page.slice(from).split("\n");
@@ -409,7 +773,9 @@ function solutionEnd(page: string, from: number): number {
// 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()) {
for (const rel of (
await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))
).sort()) {
const text = await Bun.file(join(DOCS, rel)).text();
const t = text.match(/^title: '(\d+)\./m);
if (t) pages.set(Number(t[1]), rel);
@@ -417,23 +783,28 @@ 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>(); // folder -> topic, for meta.ts
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
const tags = new Map<string, TopicTag>(); // every tag slug any problem page carries -> tag
for (const sol of ordered) {
const langs = Object.keys(sol.code).sort().join("+");
const workRef = `${sol.num}.${sol.slug} (${langs})`;
// <topic>/ folder (a real route segment, so its index.mdx is the topic landing
// page) + leetcode-number prefix: numeric sidebar order, prefix stripped from the URL.
// An unplanned problem's category may share a folder with a numbered topic
// (`Hash Table` warm-ups under topic 1); the numbered one owns the meta.
const topic = topics.get(sol.num) ?? { name: sol.category };
const group = topic.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
if (groups.get(group)?.order === undefined) groups.set(group, topic);
const name = `${group}/${sol.num}-${sol.slug}.mdx`;
// (0N-<phase>)/<topic>/(problems)/ folder (the topic is a real route segment,
// so its index.mdx is the landing page; `(problems)` adds none) +
// 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, dirname(name)), { recursive: true });
let written: string; // the page as it stands after this run, for its topics
if (existing) {
const page = await Bun.file(join(DOCS, existing)).text();
const at = page.indexOf("## Solution");
@@ -442,8 +813,12 @@ for (const sol of ordered) {
continue;
}
const tail = page.slice(solutionEnd(page, at));
const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
const next = ensureLabel(body, sol.header).replace(/^<Badge variant="accent">.*<\/Badge>$/m, badgeLine(sol));
const body =
page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
const next = await ensureIssue(
await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol),
sol,
);
const actions: string[] = [];
if (existing !== name) {
await rename(join(DOCS, existing), join(DOCS, name));
@@ -455,33 +830,122 @@ for (const sol of ordered) {
actions.push("updated");
}
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
written = next;
} else {
await Bun.write(join(DOCS, name), renderPage(sol));
const page = renderPage(
sol,
await fetchTopicTags(sol),
await issueUrl(sol),
);
await Bun.write(join(DOCS, name), page);
report.push([workRef, name, "created"]);
written = page;
}
for (const tag of frontmatterTopics(written)) tags.set(tag.slug, tag);
// 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 meta: one per live group, rewritten only when it differs.
for (const [group, topic] of groups) {
const path = join(DOCS, group, "meta.ts");
const meta = groupMeta(topic);
if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta) continue;
await Bun.write(path, meta);
report.push([`topic ${topic.order ?? "-"}`, `${group}/meta.ts`, "written"]);
// Every carried tag no campaign topic teaches is an Appendix term, filed by
// hand in apps/docs/appendix.ts: warn about the gaps and the dead weight. A
// term's icon is also the icon on every problem-page badge linking to its
// card, so it must not be a topic group's (`TOPIC_ICONS`), so a term badge
// never reads as a topic's, nor another term's.
for (const tag of [...tags.values()].sort((a, b) =>
a.slug.localeCompare(b.slug),
)) {
if (!TOPIC_LANDINGS[tag.slug] && !APPENDIX[tag.slug])
console.warn(
`tag ${tag.slug}: no Appendix term; add ${JSON.stringify(tag.name)} to apps/docs/appendix.ts`,
);
}
const topicIcons = new Set(Object.values(TOPIC_ICONS));
const termIcons = new Map<string, string>(); // icon -> the term that took it first
for (const [slug, term] of Object.entries(APPENDIX)) {
if (TOPIC_LANDINGS[slug])
console.warn(
`appendix ${slug}: a campaign topic teaches it now; delete its term`,
);
if (topicIcons.has(term.icon))
console.warn(
`appendix ${slug}: icon ${JSON.stringify(term.icon)} is a topic's (TOPIC_ICONS); pick another`,
);
else if (termIcons.has(term.icon))
console.warn(
`appendix ${slug}: icon ${JSON.stringify(term.icon)} is ${termIcons.get(term.icon)}'s too; pick another`,
);
else termIcons.set(term.icon, slug);
}
// A group every page left behind is dead: drop its meta and the folder itself.
for (const dir of vacated) {
if (groups.has(dir)) continue;
const left = await readdir(join(DOCS, dir));
if (left.some((f) => f !== "meta.ts")) continue;
if (left.length) await unlink(join(DOCS, dir, "meta.ts"));
await rmdir(join(DOCS, dir));
report.push(["", dir, "removed (empty)"]);
// Phase metas: created when missing (an empty file counts as missing), never
// rewritten, so a renamed phase title survives every sync.
for (const [dir, phase] of phases) {
const file = Bun.file(join(DOCS, dir, "meta.ts"));
if ((await file.exists()) && file.size > 0) continue;
await Bun.write(file, phaseMeta(phase));
report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]);
}
// Script-owned folder metas — one per live topic folder — rewritten only when
// they differ.
const metas: [label: string, dir: string, meta: string][] = [...groups].map(
([dir, topic]) => [`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);
const w1 = Math.max(...report.map((r) => r[1].length), 9);
console.log(`${"work file".padEnd(w0)} ${"docs page".padEnd(w1)} status`);
console.log(`${"─".repeat(w0)} ${"─".repeat(w1)} ${"─".repeat(20)}`);
for (const [a, b, c] of report) console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`);
for (const [a, b, c] of report)
console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`);
+89
View File
@@ -0,0 +1,89 @@
/**
* 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. Also the one reading of "is this
* Review issue finished", which close-topics.ts closes on.
*
* 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.
*
* Review issues (`Review <N> — <Phase>`, label `review`) are day blocks for
* the transfer phase: problems were re-parented into them from their topics
* (a GitHub issue has one parent). A topic still OWNS such a problem —
* readProblems() returns it alongside the topic's direct sub-issues, with the
* ownership recovered by apps/api/src/reviews.ts (shared with the Worker's
* catalog reconcile) — so the finished rule and the release snapshots do not
* change when a problem moves. A Review issue is finished when every one of
* its sub-issues is closed: it is a fixed assignment, so the set labels do
* not matter there.
*
* Library: side-effect-free on import; every call takes the caller's github().
*/
import type { Problem } from "../api/src/reviews.ts";
import type { GitHub } from "./github.ts";
/** Completion of a required set: a topic's core problems, or every problem of a review. */
export interface Core {
/** The required problems, closed and open alike, in the order given. */
all: number[];
/** The required problems still open — non-empty means the issue stays open. */
pending: number[];
/** When the last required problem closed; null until the set is finished (never, if empty). */
finishedAt: string | null;
}
/**
* Every problem a topic owns: its sub-issues plus the ones moved from it into
* a Review issue (`moved`, from movedFromTopics()). 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,
moved: Map<number, Problem[]>,
): Promise<Problem[]> {
const problems = [...(moved.get(topic) ?? [])];
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);
}
/** A topic's rule: its `set:core` problems are the required set. */
export function coreSet(problems: Problem[]): Core {
return completion(problems.filter((p) => labelValue(p, "set:") === "core"));
}
/** The shared rule: finished when non-empty and every required problem is closed. */
export function completion(required: Problem[]): Core {
const pending = required.filter((p) => !p.closedAt).map((p) => p.number);
// ISO instants from the API share one format, so string order is time order.
const finishedAt =
required.length && !pending.length
? required.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b))
: null;
return { all: required.map((p) => p.number), pending, finishedAt };
}
+22 -24
View File
@@ -2,24 +2,23 @@
/**
* 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.
* A finished solution page carries frontmatter `video: <YouTube id>` —
* NeetCode's walkthrough of the problem — which the docs MDX `wrapper`
* (`apps/docs/components/ProblemBadges.astro`) renders as the red
* "Explanation" badge beside the topic badges. `sync.ts` never writes that key
* (it owns `icon:`, `topics:` and `## Solution`), so pages accumulate without
* one. This script lists every numbered page lacking `video:` 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
* bun apps/cli/videos.ts --write # also add `video:` to the frontmatter
*
* --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.
* --write inserts the key before `sidebar:`. A page with no match is left
* untouched, so re-runs are no-ops once every page has its video.
*/
import { join } from "node:path";
@@ -126,23 +125,21 @@ interface Page {
text: string;
}
/** Numbered solution pages without a `<YouTube` embed, by LC number. */
/** The page's frontmatter block, `---` to `---`. */
const FRONTMATTER = /^---\n[\s\S]*?\n---/;
/** Numbered solution pages without a `video:` key, 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 });
const fm = text.match(FRONTMATTER)?.[0] ?? "";
if (t && !/^video:/m.test(fm)) 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();
@@ -162,7 +159,8 @@ for (const page of missing) {
found++;
let status = "";
if (WRITE) {
await Bun.write(join(DOCS, page.rel), withEmbed(page, video));
const next = page.text.replace(FRONTMATTER, (fm) => fm.replace(/^sidebar:$/m, `video: ${JSON.stringify(video.id)}\nsidebar:`));
await Bun.write(join(DOCS, page.rel), next);
status = "written";
}
rows.push([page.rel, `https://youtu.be/${video.id}`, `${video.title} (${video.channel})`, status]);
+232
View File
@@ -0,0 +1,232 @@
/**
* The Appendix glossary: one term per LeetCode topic tag that problem pages
* carry but no campaign topic teaches (not a key of `topic-landings.ts`).
* `pages/appendix.astro` renders each as a card — icon, category, definition,
* and the solved problems carrying the tag — anchored at `/appendix#<slug>`,
* which is where `tagTarget()` sends that tag's badge on a problem page.
*
* Hand-written: `apps/cli/sync.ts` only checks it, warning about a carried
* tag with no entry (its card renders bare), an entry whose tag a campaign
* topic now teaches (dead weight, delete it), and an icon that is a topic's
* (`TOPIC_ICONS`) or another term's — the icon is also the one on every
* badge linking here, so it must not read as anything else. Plain data, no
* Astro imports: sync imports this file under Bun.
*/
export type AppendixCategory = "Data Structures" | "Algorithms" | "Techniques";
export interface AppendixTerm {
/** LeetCode's tag name. */
name: string;
/** Lucide icon, unique across terms and topics. */
icon: string;
category: AppendixCategory;
/** One sentence: what the term means, the way a solution uses it. */
definition: string;
}
export const APPENDIX: Record<string, AppendixTerm> = {
// ── data structures ──────────────────────────────────────────────
array: {
name: "Array",
icon: "list-ordered",
category: "Data Structures",
definition: "A contiguous, index-addressed sequence: O(1) access by position, O(n) to insert or delete in the middle.",
},
"binary-tree": {
name: "Binary Tree",
icon: "git-branch",
category: "Data Structures",
definition: "A rooted tree in which every node has at most two children, left and right.",
},
"directed-acyclic-graph": {
name: "Directed Acyclic Graph",
icon: "route",
category: "Data Structures",
definition: "A directed graph with no cycles, which is exactly what lets its nodes be put in a topological order.",
},
"doubly-linked-list": {
name: "Doubly-Linked List",
icon: "link-2",
category: "Data Structures",
definition: "A linked list whose nodes point to both neighbours, so a node you already hold is unlinked in O(1).",
},
graph: {
name: "Graph Theory",
icon: "share-2",
category: "Data Structures",
definition: "Nodes joined by edges, directed or not, weighted or not, stored as an adjacency list or matrix.",
},
"k-d-tree": {
name: "K-D Tree",
icon: "axis-3d",
category: "Data Structures",
definition: "A binary tree that splits k-dimensional points on alternating axes, for nearest-neighbour and range queries.",
},
queue: {
name: "Queue",
icon: "list-end",
category: "Data Structures",
definition: "First in, first out: push at the back, pop from the front, both O(1).",
},
string: {
name: "String",
icon: "quote",
category: "Data Structures",
definition: "A sequence of characters; solutions lean on its small alphabet, its substrings, and its immutability.",
},
tree: {
name: "Tree",
icon: "tree-deciduous",
category: "Data Structures",
definition: "A connected acyclic graph, usually rooted, in which every node but the root has exactly one parent.",
},
// ── algorithms ───────────────────────────────────────────────────
"bidirectional-search": {
name: "Bidirectional Search",
icon: "move-horizontal",
category: "Algorithms",
definition: "Search from the start and the goal at once and stop where the frontiers meet, about 2·b^(d/2) states instead of b^d.",
},
"binary-lifting": {
name: "Binary Lifting",
icon: "chevrons-up",
category: "Algorithms",
definition: "Precompute every node's 2^k-th ancestor so any k-th ancestor, and so the LCA, takes O(log n) jumps.",
},
"bit-manipulation": {
name: "Bit Manipulation",
icon: "binary",
category: "Algorithms",
definition: "Treat integers as bit vectors: masks, shifts, and XOR in place of arithmetic or extra memory.",
},
"bracket-sequences": {
name: "Bracket Sequences",
icon: "parentheses",
category: "Algorithms",
definition: "Strings of opening and closing brackets; balanced means the running depth never dips below zero and ends at zero.",
},
"bucket-sort": {
name: "Bucket Sort",
icon: "container",
category: "Algorithms",
definition: "Scatter values into buckets by range or frequency, then read the buckets in order: linear when the key range is bounded.",
},
"complete-knapsack": {
name: "Complete Knapsack",
icon: "package-plus",
category: "Algorithms",
definition: "Knapsack in which every item may be taken any number of times, so the DP walks capacity upward and reuses the row.",
},
"divide-and-conquer": {
name: "Divide and Conquer",
icon: "split",
category: "Algorithms",
definition: "Split the input into independent parts, solve each recursively, and combine their answers.",
},
"floyds-cycle-finding-algorithm": {
name: "Floyd's Cycle Finding Algorithm",
icon: "repeat",
category: "Algorithms",
definition: "A slow and a fast pointer meet inside any cycle; restarting one from the head finds the cycle's entry, in O(1) space.",
},
"knapsack-problem": {
name: "Knapsack Problem",
icon: "backpack",
category: "Algorithms",
definition: "Pick items with weights and values to maximise value under a capacity; the 0/1 version is an O(n·W) DP.",
},
"longest-common-subsequence": {
name: "Longest Common Subsequence",
icon: "diff",
category: "Algorithms",
definition: "The longest sequence that appears in order, not necessarily contiguously, in two strings: the classic O(n·m) 2-D DP.",
},
"lowest-common-ancestor": {
name: "Lowest Common Ancestor",
icon: "git-merge",
category: "Algorithms",
definition: "The deepest node of a rooted tree that is an ancestor of both given nodes.",
},
memoization: {
name: "Memoization",
icon: "database",
category: "Algorithms",
definition: "Cache a pure function's result per argument so every subproblem is solved once: top-down dynamic programming.",
},
"merge-sort": {
name: "Merge Sort",
icon: "blend",
category: "Algorithms",
definition: "Sort each half recursively, then merge the two sorted halves: O(n log n), stable, O(n) extra space.",
},
quickselect: {
name: "Quickselect",
icon: "crosshair",
category: "Algorithms",
definition: "Partition around a pivot and recurse into one side only, finding the k-th smallest element in O(n) on average.",
},
quicksort: {
name: "Quicksort",
icon: "shuffle",
category: "Algorithms",
definition: "Partition around a pivot, then sort each side in place: O(n log n) on average, O(n²) at worst.",
},
recursion: {
name: "Recursion",
icon: "iteration-ccw",
category: "Algorithms",
definition: "Solve a problem by calling the same function on smaller inputs until a base case answers directly.",
},
simulation: {
name: "Simulation",
icon: "monitor-play",
category: "Algorithms",
definition: "Carry out the process the statement describes, step by step; the work is modelling the state faithfully.",
},
sorting: {
name: "Sorting",
icon: "arrow-down-wide-narrow",
category: "Algorithms",
definition: "Order the input first so a later step, such as two pointers, a greedy choice, or binary search, becomes possible.",
},
"tournament-sort": {
name: "Tournament Sort",
icon: "trophy",
category: "Algorithms",
definition: "A winner tree of pairwise comparisons: the root is the extreme element, and replaying one path yields the next in O(log n).",
},
// ── techniques ───────────────────────────────────────────────────
combinatorics: {
name: "Combinatorics",
icon: "dices",
category: "Techniques",
definition: "Count arrangements and selections (permutations, combinations, Catalan numbers), usually modulo a prime.",
},
counting: {
name: "Counting",
icon: "tally-5",
category: "Techniques",
definition: "Tally occurrences in a hash map or fixed array, then answer from the counts instead of the raw input.",
},
design: {
name: "Design",
icon: "pencil-ruler",
category: "Techniques",
definition: "Implement a class to an API spec, choosing the internals so every operation meets its time bound.",
},
geometry: {
name: "Geometry",
icon: "shapes",
category: "Techniques",
definition: "Points, lines, and areas on a plane or grid: distances, slopes, overlaps, and orientation by cross product.",
},
math: {
name: "Math",
icon: "calculator",
category: "Techniques",
definition: "An arithmetic or number-theory insight (modular arithmetic, GCD, digit manipulation, a closed form) in place of brute force.",
},
};
+150 -9
View File
@@ -1,13 +1,154 @@
import { defineConfig } from "blume";
import type { StandardSchema } from "blume/core/standard-schema.ts";
import { filesystem } from "blume/sources";
import { APPENDIX } from "./appendix";
import redirects from "./redirects";
export default defineConfig({
title: "Leetcode",
description: "Documentation for LeetCode solutions",
deployment: {
site: "https://prdlk.github.io",
base: "/leetcode",
},
content: {
root: "content",
// 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,
},
],
};
/**
* Custom frontmatter, rendered as the badge row by
* components/ProblemBadges.astro. Problem pages: `topics:` is LeetCode's topic
* tags and `issue:` the problem's GitHub issue URL (both written by
* apps/cli/sync.ts), `video:` the NeetCode walkthrough's
* YouTube id (apps/cli/videos.ts). Blume rejects undeclared keys and takes
* any Standard Schema; these are hand-rolled so the docs workspace needs no
* zod. All optional — every other page carries none.
*/
const optional = <T>(
message: string,
valid: (value: unknown) => value is T,
): StandardSchema<unknown, T | undefined> => ({
"~standard": {
version: 1,
vendor: "prdlk",
validate: (value) =>
value === undefined || valid(value)
? { value }
: { issues: [{ message }] },
},
});
const topicsSchema = optional(
"expected a list of { name, slug } LeetCode topic tags",
(value): value is { name: string; slug: string }[] =>
Array.isArray(value) &&
value.every(
(t) => typeof t?.name === "string" && typeof t?.slug === "string",
),
);
const videoSchema = optional(
"expected an 11-character YouTube video id",
(value): value is string =>
typeof value === "string" && /^[\w-]{11}$/.test(value),
);
const issueSchema = optional(
"expected a https://github.com/<owner>/<repo>/issues/<n> URL",
(value): value is string =>
typeof value === "string" &&
/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/issues\/\d+$/.test(value),
);
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",
// Sync's `/<num>` short links, then the Appendix's former per-term pages
// (`/appendix/<slug>`, published until the glossary became one page) onto
// their cards, so old links and search results still land.
redirects: [
...redirects,
...Object.keys(APPENDIX).map((slug) => ({
from: `/appendix/${slug}`,
to: `/appendix#${slug}`,
})),
],
frontmatter: {
extend: {
topics: topicsSchema,
video: videoSchema,
issue: issueSchema,
},
},
seo: {
og: { enabled: true },
// No `rss`: the changelog is one custom page (pages/changelog.astro), not
// `type: changelog` entries, so a changelog feed would have nothing in it.
sitemap: true,
robots: true,
structuredData: true,
x: { handle: "@prdlk" },
},
// Blume's own assistant route is server-rendered (it reads the provider key
// per request), which a static Pages build cannot ship. The SRS Worker
// answers instead (apps/api/src/ask.ts: OpenRouter, grounded on this site's
// llms-full.txt, which `agents.llmsTxt` publishes).
ai: {
assistant: {
enabled: true,
endpoint: "https://srs-api.prdlk.workers.dev/ask",
},
},
agents: {
llmsTxt: true,
mcp: { enabled: false },
},
// The only content source. `/changelog`, `/appendix` and `/progress` are
// custom pages (pages/*.astro): `/changelog` reads the GitHub releases
// itself — one timeline, no per-release pages — `/appendix` is one glossary
// page built from appendix.ts, and `/progress` is the live stats cards, so
// none enters the sidebar tree or prev/next.
content: {
sources: [filesystem({ root: "content" })],
},
theme: {
fonts: { display: inter, body: inter, mono: plexMono },
},
navigation: {
repo: "https://github.com/prdlk/leetcode",
sidebar: {
display: "flat", // "flat" | "group" | "page"
},
// The custom pages (pages/*.astro) as header tabs. None is a content
// folder, so the tabs scope nothing out of the sidebar tree.
tabs: [
{ label: "Changelog", path: "/changelog" },
{ label: "Progress", path: "/progress" },
{ label: "Appendix", path: "/appendix" },
],
},
});
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "theme.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}
+20
View File
@@ -0,0 +1,20 @@
/**
* Project MDX components, available on every page with no import, and layout
* slot overrides. 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",
// MDX's own content wrapper: problem pages' topic + explanation badges, from
// `topics:` / `video:` frontmatter, between the description and the body.
wrapper: "./components/ProblemBadges.astro",
},
layout: {
// Topic landing pages' "what links here": every problem pointing at the page.
PageFooter: "./components/BacklinksList.astro",
},
});
+95
View File
@@ -0,0 +1,95 @@
---
/**
* The `PageFooter` slot: an Obsidian-style backlinks list under a topic landing
* page — every problem page that points at it, as `<num> <name>` rows in
* LeetCode-number order. Pages nothing points at (problem pages, the
* overview) render nothing; a tag no topic teaches lists its problems on its
* Appendix card instead (`pages/appendix.astro`), from the same answer.
*
* A problem points at a page when either
* - one of its topic badges links there — the same `tagTarget()` answer
* `ProblemBadges.astro` renders, so this list and the badges cannot drift
* (a DFS problem is a backlink of the one DFS landing its badge picks); or
* - it lives under the page's route (`/hybrid-structures/lru-cache` under
* `/hybrid-structures`), so a topic no LeetCode tag names still lists its
* own problems.
*
* Problem pages are recognised by their `<num>. <name>` title, the shape
* `apps/cli/sync.ts` writes.
*
* The list is a native `<details>`, open on load: the chevron at the top
* right of the header row collapses it like an accordion, with no JS.
*/
import { getEntry } from "astro:content";
import data from "blume:data";
import { withBase } from "blume/components/islands/base-path.ts";
import Icon from "blume/components/Icon.astro";
import { tagTarget, type TopicTag } from "../topic-landings.ts";
interface Props {
route: string;
}
interface Route {
path: string;
title: string;
collection: string;
entryId: string;
}
const { route } = Astro.props;
const routes: Route[] = data.routes;
const served = new Set(routes.map((r) => r.path));
const problems = routes.flatMap((r) => {
const m = r.title.match(/^(\d+)\.\s+(.+)$/);
return m ? [{ route: r, num: Number(m[1]), name: m[2]! }] : [];
});
// A problem page is never a backlink target: skip the per-problem lookups there.
const backlinks = problems.some((p) => p.route.path === route)
? []
: (
await Promise.all(
problems.map(async (p) => {
if (p.route.path.startsWith(`${route}/`)) return p;
const entry = await getEntry(p.route.collection, p.route.entryId);
const tags: TopicTag[] = entry?.data.topics ?? [];
const page = { route: p.route.path, tags };
return tags.some((tag) => tagTarget(tag, page, served).href === route) ? p : undefined;
}),
)
)
.filter((p) => p !== undefined)
.sort((a, b) => a.num - b.num);
---
{
backlinks.length > 0 && (
<details
class="group not-prose mx-auto mt-12 max-w-content border-border border-t pt-6"
open
>
<summary class="mb-2 flex cursor-pointer list-none items-center justify-between gap-2 text-muted-foreground transition-colors hover:text-foreground [&::-webkit-details-marker]:hidden">
<h2 class="flex items-baseline gap-2 font-medium text-xs uppercase tracking-wide" id="backlinks">
Backlinks
<span class="tabular-nums">{backlinks.length}</span>
</h2>
<Icon class="shrink-0 transition-transform group-open:rotate-180" name="chevron-down" size={16} />
</summary>
<ul class="text-sm">
{backlinks.map((p) => (
<li>
<a
class="-mx-2 flex items-baseline gap-3 rounded-md px-2 py-1 text-foreground no-underline transition-colors hover:bg-muted"
href={withBase(p.route.path)}
>
<span class="w-10 shrink-0 text-right font-mono text-muted-foreground text-xs tabular-nums">{p.num}</span>
<span class="truncate">{p.name}</span>
</a>
</li>
))}
</ul>
</details>
)
}
+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>
+134
View File
@@ -0,0 +1,134 @@
---
/**
* The MDX `wrapper`: the badge row between a page's description and its body.
* Blume's page template renders the title and description itself and then
* the MDX content, with no slot in between, so wrapping the content is the
* only way to land right after the description. MDX hands a wrapper no
* route, so it is read off the URL, and the page's frontmatter is then
* looked up through the route manifest — the same `getEntry` call Blume's own
* page template makes. Pages with none of the keys below (landing pages, the
* changelog) render only their content.
*
* - `topics:` (problem pages, written by `apps/cli/sync.ts`) is LeetCode's own
* topic tags. A tag the campaign teaches (`topic-landings.ts`) links to that
* topic's landing page and is filled; any other tag links to its card on the
* Appendix glossary, `/appendix#<slug>` (`appendix.ts`), and is outlined.
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
* `PageFooter` backlinks list and the Appendix page read the same answer.
* Each tag badge is a pill carrying the icon its target shows (a topic's
* `meta.ts` icon in the sidebar, a term's `icon` on its card) and the tag's
* own colour (`lib/tag-colors.ts`), assigned over every tag the site has a
* page or card for — no two tags share one, and tags that share problem
* pages are spread furthest apart. A tag with neither (the LeetCode-list
* fallback) stays gray and iconless.
* - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode
* walkthrough's YouTube id, the red "Explanation" badge.
* - `issue:` (problem pages, written by sync) is the problem's GitHub issue
* URL, a neutral "Issue #N" badge with the GitHub mark, right after
* "Explanation" at the end of the row.
*/
import { getEntry } from "astro:content";
import data from "blume:data";
import Badge from "blume/components/content/Badge.astro";
import { stripBase, withBase } from "blume/components/islands/base-path.ts";
import { APPENDIX } from "../appendix.ts";
import { tagColors } from "../lib/tag-colors.ts";
import { TOPIC_LANDINGS, tagTarget, type TopicTag } from "../topic-landings.ts";
// `/sub/tree-dfs/invert-binary-tree/` (or `.../index.html` in a static build)
// -> `/tree-dfs/invert-binary-tree`, the base-less shape of `data.routes`.
const route =
stripBase(import.meta.env.BASE_URL ?? "/", Astro.url.pathname)
.replace(/(\/index)?\.html$/u, "")
.replace(/(.)\/+$/u, "$1");
const at = data.routes.find((r: { path: string }) => r.path === route);
const entry = at ? await getEntry(at.collection, at.entryId) : undefined;
const tags: TopicTag[] = entry?.data.topics ?? [];
const video: string | undefined = entry?.data.video;
const issue: string | undefined = entry?.data.issue;
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
// route → the icon its sidebar row shows: a page's own, or a topic group's
// (from its `meta.ts`) for the landing page the group row links to.
interface NavNode {
route?: string;
icon?: string;
children?: NavNode[];
}
const icons = new Map<string, string>();
const walk = (nodes: NavNode[]) => {
for (const node of nodes) {
if (node.route && node.icon && !icons.has(node.route)) icons.set(node.route, node.icon);
if (node.children) walk(node.children);
}
};
walk(data.navigation.sidebar);
// Every tag with a landing or an Appendix card, coloured against how problem
// pages (`<num>. <name>` titles, the shape sync writes) combine them. Only a
// page with tags needs the answer.
const colors = tags.length
? tagColors(
[...Object.keys(TOPIC_LANDINGS), ...Object.keys(APPENDIX)],
await Promise.all(
data.routes
.filter((r: { title: string }) => /^\d+\.\s/u.test(r.title))
.map(async (r: { collection: string; entryId: string }) => {
const page = await getEntry(r.collection, r.entryId);
return (page?.data.topics ?? []).map((t: TopicTag) => t.slug);
}),
),
)
: new Map<string, string>();
const badges = tags.map((tag) => {
const target = tagTarget(tag, { route, tags }, served);
const external = target.kind === "external";
return {
name: tag.name,
href: external ? target.href : withBase(target.href),
external,
icon: target.kind === "appendix" ? APPENDIX[tag.slug]?.icon : external ? undefined : icons.get(target.href),
color: external ? undefined : colors.get(tag.slug),
stroke: target.kind !== "landing",
};
});
const linkClass = "no-underline transition-opacity hover:opacity-80";
---
{
(badges.length > 0 || video || issue) && (
<div class="not-prose mb-6 flex flex-wrap gap-1.5">
{badges.map((b) => (
<a
href={b.href}
target={b.external ? "_blank" : undefined}
rel={b.external ? "noopener" : undefined}
class={linkClass}
>
<Badge color={b.color} icon={b.icon} shape="pill" stroke={b.stroke}>
{b.name}
</Badge>
</a>
))}
{video && (
<a href={`https://www.youtube.com/watch?v=${video}`} target="_blank" rel="noopener" class={linkClass}>
<Badge variant="danger" icon="youtube" shape="pill">
Explanation
</Badge>
</a>
)}
{issue && (
<a href={issue} target="_blank" rel="noopener" class={linkClass}>
<Badge icon="github" shape="pill">
Issue #{issue.slice(issue.lastIndexOf("/") + 1)}
</Badge>
</a>
)}
</div>
)
}
<slot />
@@ -0,0 +1,79 @@
/**
* The ten most recently solved problems — LeetCode's "Recent AC" list. Each
* row is dated by the problem's first pass (an ET calendar date: the Worker
* logs days, not times), read relative to today in ET with the full date on
* hover. A row links to the problem's docs page when one exists: the
* sync-generated short links (`redirects.ts`) are exactly the pages built.
*/
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { useStats, utcDate } from "../../lib/stats";
import redirects from "../../redirects";
import { StatsPending } from "./shared";
const PAGES = new Map(redirects.map((r) => [r.from, r.to]));
const ET_TODAY = new Intl.DateTimeFormat("en-CA", { timeZone: "America/New_York" });
const RELATIVE = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
const FULL = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" });
const DAY_MS = 86_400_000;
export default function RecentSolves() {
const load = useStats();
if (load.phase !== "ready") {
return (
<Card>
<CardHeader>
<CardTitle>Recently solved</CardTitle>
</CardHeader>
<CardContent>
<StatsPending load={load} className="h-80 w-full" />
</CardContent>
</Card>
);
}
const today = utcDate(ET_TODAY.format(new Date())).getTime();
return (
<Card>
<CardHeader>
<CardTitle>Recently solved</CardTitle>
</CardHeader>
<CardContent>
{load.stats.recent.length === 0 ? (
<p className="my-0! text-muted-foreground text-sm">Nothing solved yet.</p>
) : (
<ol className="my-0! flex flex-col">
{load.stats.recent.map((p) => {
const date = utcDate(p.date);
const page = PAGES.get(`/${p.lc}`);
const name = (
<>
<span className="text-muted-foreground tabular-nums">{p.lc}.</span> {p.title}
</>
);
return (
<li
key={p.lc}
className="flex items-center justify-between gap-4 rounded-lg px-4 py-3 text-sm odd:bg-muted"
>
{page ? (
<a href={page} className="truncate font-normal! text-foreground no-underline! hover:underline!">
{name}
</a>
) : (
<span className="truncate">{name}</span>
)}
<time dateTime={p.date} title={FULL.format(date)} className="shrink-0 text-muted-foreground">
{RELATIVE.format(Math.round((date.getTime() - today) / DAY_MS), "day")}
</time>
</li>
);
})}
</ol>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,143 @@
/**
* Solved-by-difficulty ring — LeetCode's profile gauge. A 270° arc split into
* one segment per difficulty, each sized by that difficulty's share of the
* catalog; the faint track is the whole set, the solid arc the solved part.
* The centre reads the total, or one difficulty while its segment (or its
* stat box) is hovered. "Attempting" is the open-miss count: problems whose
* latest attempt failed.
*/
import { useState } from "react";
import { Pie, PieChart } from "recharts";
import { Card, CardContent } from "../ui/card";
import { type ChartConfig, ChartContainer } from "../ui/chart";
import { DIFFICULTIES, type Difficulty, useStats } from "../../lib/stats";
import { cn } from "../../lib/utils";
import { StatsPending } from "./shared";
const chartConfig = {
easy: { label: "Easy", color: "var(--chart-1)" },
medium: { label: "Med.", color: "var(--chart-2)" },
hard: { label: "Hard", color: "var(--chart-3)" },
} satisfies ChartConfig;
// Static class names, so Tailwind's scanner sees every one (the chart's
// --color-* variables exist only inside ChartContainer).
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
easy: "text-chart-1",
medium: "text-chart-2",
hard: "text-chart-3",
};
// The gauge opens at the bottom: from 225° (lower left) clockwise to -45°
// (lower right), in Recharts' counter-clockwise-from-3-o'clock degrees.
const START = 225;
const SWEEP = 270;
const GAP = 8;
export default function SolvedRing() {
const load = useStats();
const [focus, setFocus] = useState<Difficulty | null>(null);
if (load.phase !== "ready") {
return (
<Card>
<CardContent>
<StatsPending load={load} className="h-52 w-full" />
</CardContent>
</Card>
);
}
const rows = DIFFICULTIES.map(
(difficulty) => load.stats.difficulty.find((d) => d.difficulty === difficulty) ?? { difficulty, total: 0, done: 0 },
);
const total = rows.reduce((n, r) => n + r.total, 0);
const done = rows.reduce((n, r) => n + r.done, 0);
const span = SWEEP - GAP * (rows.length - 1);
const segments: ((typeof rows)[number] & { from: number; to: number; solvedTo: number })[] = [];
let cursor = START;
for (const r of rows) {
const size = total > 0 ? (span * r.total) / total : 0;
const solved = r.total > 0 ? size * (r.done / r.total) : 0;
segments.push({ ...r, from: cursor, to: cursor - size, solvedTo: cursor - solved });
cursor -= size + GAP;
}
const focused = focus ? rows.find((r) => r.difficulty === focus) : undefined;
const arc = { innerRadius: "80%", outerRadius: "88%", cornerRadius: 99, stroke: "none", dataKey: "value" } as const;
return (
<Card>
<CardContent className="flex flex-col items-center gap-6 sm:flex-row">
<div className="relative w-full max-w-56 shrink-0">
<ChartContainer config={chartConfig} className="aspect-square w-full">
<PieChart>
{segments.map((s) => (
<Pie
key={`${s.difficulty}-track`}
{...arc}
data={[{ value: 1 }]}
startAngle={s.from}
endAngle={s.to}
fill={`var(--color-${s.difficulty})`}
fillOpacity={focus && focus !== s.difficulty ? 0.12 : 0.25}
isAnimationActive={false}
onMouseEnter={() => setFocus(s.difficulty)}
onMouseLeave={() => setFocus(null)}
/>
))}
{segments
.filter((s) => s.done > 0)
.map((s) => (
<Pie
key={`${s.difficulty}-solved`}
{...arc}
data={[{ value: 1 }]}
startAngle={s.from}
endAngle={s.solvedTo}
fill={`var(--color-${s.difficulty})`}
fillOpacity={focus && focus !== s.difficulty ? 0.35 : 1}
onMouseEnter={() => setFocus(s.difficulty)}
onMouseLeave={() => setFocus(null)}
/>
))}
</PieChart>
</ChartContainer>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-1">
<div className="flex items-baseline">
<span className="font-semibold text-4xl tabular-nums">{focused ? focused.done : done}</span>
<span className="text-muted-foreground tabular-nums">/{focused ? focused.total : total}</span>
</div>
<span className={cn("text-sm", focused ? DIFFICULTY_TEXT[focused.difficulty] : "text-muted-foreground")}>
{focused ? chartConfig[focused.difficulty].label : "Solved"}
</span>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-3 text-center text-muted-foreground text-xs">
<span className="font-semibold text-foreground tabular-nums">{load.stats.attempting}</span> Attempting
</div>
</div>
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-1">
{rows.map((r) => (
<div
key={r.difficulty}
className={cn(
"flex flex-col items-center gap-0.5 rounded-lg bg-muted px-4 py-2 transition-opacity",
focus && focus !== r.difficulty && "opacity-50",
)}
onPointerEnter={() => setFocus(r.difficulty)}
onPointerLeave={() => setFocus(null)}
>
<span className={cn("font-medium text-sm", DIFFICULTY_TEXT[r.difficulty])}>
{chartConfig[r.difficulty].label}
</span>
<span className="text-sm tabular-nums">
{r.done}/{r.total}
</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,220 @@
/**
* This calendar year by difficulty — LeetCode's solved/submissions bar chart,
* sharing its row with the heatmap. Stacked Easy / Med. / Hard bars per month
* or per week (Monday-start, like the heatmap rows); "Solved" counts each
* problem once, on its first-pass date (the date `recent` uses), while
* "Submissions" counts every logged attempt. The totals under the chart are
* the year's, in the selected mode. Buckets are cut from `year`, which always
* covers January 1 to today.
*/
import { useState } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "../ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "../ui/chart";
import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
import { DIFFICULTIES, type Day, type Difficulty, useStats, utcDate } from "../../lib/stats";
import { StatsPending } from "./shared";
const chartConfig = {
easy: { label: "Easy", color: "var(--chart-1)" },
medium: { label: "Med.", color: "var(--chart-2)" },
hard: { label: "Hard", color: "var(--chart-3)" },
} satisfies ChartConfig;
// Static class names, so Tailwind's scanner sees every one.
const DIFFICULTY_TEXT: Record<Difficulty, string> = {
easy: "text-chart-1",
medium: "text-chart-2",
hard: "text-chart-3",
};
type Mode = "solved" | "attempts";
type Grain = "month" | "week";
const DAY_MS = 86_400_000;
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
const MONTH_YEAR = new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
const SHORT = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
interface Bucket extends Record<Difficulty, number> {
index: number;
/** Axis tick: the month the bucket starts in. */
tick: string;
/** Tooltip heading. */
label: string;
}
/** The year's buckets, every one present (future ones empty), filled from `days`. */
function buckets(days: Day[], year: number, grain: Grain, mode: Mode): Bucket[] {
const jan1 = Date.UTC(year, 0, 1);
const lead = (new Date(jan1).getUTCDay() + 6) % 7; // Jan 1's Monday-based weekday
const length = Math.round((Date.UTC(year + 1, 0, 1) - jan1) / DAY_MS);
const out: Bucket[] = [];
if (grain === "month") {
for (let m = 0; m < 12; m++) {
const start = new Date(Date.UTC(year, m, 1));
out.push({ index: m, tick: MONTH.format(start), label: MONTH_YEAR.format(start), easy: 0, medium: 0, hard: 0 });
}
} else {
const weeks = Math.floor((length - 1 + lead) / 7) + 1;
for (let w = 0; w < weeks; w++) {
const start = new Date(Math.max(jan1, jan1 + (w * 7 - lead) * DAY_MS));
const end = new Date(Math.min(jan1 + (length - 1) * DAY_MS, jan1 + (w * 7 - lead + 6) * DAY_MS));
out.push({
index: w,
tick: MONTH.format(start),
label: `${SHORT.format(start)} – ${SHORT.format(end)}`,
easy: 0,
medium: 0,
hard: 0,
});
}
}
for (const day of days) {
const date = utcDate(day.date);
if (date.getUTCFullYear() !== year) continue;
const i = grain === "month" ? date.getUTCMonth() : Math.floor(((date.getTime() - jan1) / DAY_MS + lead) / 7);
const bucket = out[i];
if (!bucket) continue;
for (const d of DIFFICULTIES) bucket[d] += day[mode][d];
}
return out;
}
export default function SubmissionChart() {
const load = useStats();
const [mode, setMode] = useState<Mode>("solved");
const [grain, setGrain] = useState<Grain>("month");
if (load.phase !== "ready") {
return (
<Card>
<CardContent>
<StatsPending load={load} className="h-40 w-full" />
</CardContent>
</Card>
);
}
const year = utcDate(load.stats.year.at(-1)?.date ?? "1970-01-01").getUTCFullYear();
const data = buckets(load.stats.year, year, grain, mode);
const totals = Object.fromEntries(
DIFFICULTIES.map((d) => [d, data.reduce((n, b) => n + b[d], 0)]),
);
const peak = Math.max(...data.map((b) => b.easy + b.medium + b.hard));
// A round ceiling for the one labelled gridline, as LeetCode draws it.
const top = peak <= 5 ? 5 : Math.ceil(peak / 10) * 10;
return (
<Card>
<CardHeader className="flex flex-wrap items-center justify-between gap-2">
<ToggleGroup
type="single"
size="sm"
spacing={1}
value={mode}
onValueChange={(value: string) => value && setMode(value as Mode)}
className="rounded-full bg-muted p-0.5"
aria-label="Count"
>
<ToggleGroupItem value="solved" className="h-7 rounded-full px-2.5 text-xs">
Solved
</ToggleGroupItem>
<ToggleGroupItem value="attempts" className="h-7 rounded-full px-2.5 text-xs">
Submissions
</ToggleGroupItem>
</ToggleGroup>
<div className="flex items-center">
<ToggleGroup
type="single"
size="sm"
spacing={1}
value={grain}
onValueChange={(value: string) => value && setGrain(value as Grain)}
className="rounded-full bg-muted p-0.5"
aria-label="Bucket size"
>
<ToggleGroupItem value="month" className="h-7 rounded-full px-2.5 text-xs" aria-label="Per month">
M
</ToggleGroupItem>
<ToggleGroupItem value="week" className="h-7 rounded-full px-2.5 text-xs" aria-label="Per week">
W
</ToggleGroupItem>
</ToggleGroup>
</div>
</CardHeader>
<CardContent className="flex-1">
<ChartContainer config={chartConfig} className="aspect-auto h-40 w-full">
<BarChart accessibilityLayer data={data} margin={{ top: 8, right: 0, bottom: 0, left: 0 }}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="index"
type="category"
tickLine={false}
axisLine={false}
tickMargin={6}
ticks={[0, data.length - 1]}
interval={0}
// First label anchored at its start, last at its end, so neither
// is clipped when a week band is only a few pixels wide.
tick={({ x, y, payload }) => (
<text
x={x}
y={y}
dy={10}
textAnchor={payload.value === 0 ? "start" : "end"}
className="fill-muted-foreground"
>
{data[Number(payload.value)]?.tick}
</text>
)}
/>
<YAxis
orientation="right"
domain={[0, top]}
ticks={[0, top]}
tickLine={false}
axisLine={false}
width={28}
allowDecimals={false}
/>
<ChartTooltip
cursor={false}
// Recharts applies itemSorter only in its default tooltip; this
// content gets the stack's bottom-up order, so flip it to read
// top-down, Easy first, like the bars.
content={({ active, label, payload }) => (
<ChartTooltipContent
active={active}
label={label}
payload={[...(payload ?? [])].reverse()}
labelFormatter={(_, items) => items[0]?.payload?.label}
indicator="dot"
/>
)}
/>
{(["hard", "medium", "easy"] as const).map((d) => (
<Bar
key={d}
dataKey={d}
stackId="solved"
fill={`var(--color-${d})`}
radius={2}
maxBarSize={grain === "month" ? 10 : 5}
/>
))}
</BarChart>
</ChartContainer>
</CardContent>
<CardFooter className="gap-4 text-sm">
{DIFFICULTIES.map((d) => (
<span key={d} className="flex items-baseline gap-1">
<span className={DIFFICULTY_TEXT[d]}>{chartConfig[d].label}</span>
<span className="text-muted-foreground tabular-nums">{totals[d]}</span>
</span>
))}
<span className="ml-auto text-muted-foreground tabular-nums">{year}</span>
</CardFooter>
</Card>
);
}
@@ -0,0 +1,163 @@
/**
* The last six months of attempts — LeetCode's calendar heatmap, halved to
* share its row with the submissions chart: one cell per day, weeks as
* columns, each month its own block, and the window's attempt count, active
* days and longest streak above it. The figures are counted from exactly the
* cells drawn, so they cannot disagree with the grid. The colour ramp is the
* README/email heatmap's (theme.css `--heat-0..4`, level = attempts capped at
* 4) and rows start on Monday like it, so the three heatmaps read the same.
* Drawn as SVG in a viewBox, so it scales to its column instead of scrolling.
*/
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { type Day, useStats, utcDate } from "../../lib/stats";
import { StatsPending, TipRow, useHoverTip } from "./shared";
const MONTHS = 6;
const CELL = 10;
const STEP = 13; // cell + gap
const MONTH_GAP = 8;
const LABEL_H = 16;
const HEAT = ["var(--heat-0)", "var(--heat-1)", "var(--heat-2)", "var(--heat-3)", "var(--heat-4)"];
const MONTH = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" });
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
interface Cell {
x: number;
y: number;
date: Date;
attempts: number;
passes: number;
}
interface Block {
label: string;
x: number;
width: number;
cells: Cell[];
}
/**
* Month blocks laid left to right over the last MONTHS calendar months. Within
* a block a day sits at row = weekday (Mon = 0) and column = its week counted
* from the 1st, so a month starting on a Thursday opens with a short column.
*/
function layout(year: Day[]): { blocks: Block[]; width: number } {
const last = utcDate(year.at(-1)?.date ?? "1970-01-01");
const from = Date.UTC(last.getUTCFullYear(), last.getUTCMonth() - (MONTHS - 1), 1);
const blocks: Block[] = [];
let x = 0;
for (const day of year) {
const date = utcDate(day.date);
if (date.getTime() < from) continue;
let block = blocks.at(-1);
if (date.getUTCDate() === 1 || !block) {
if (block) x += block.width + MONTH_GAP;
block = { label: MONTH.format(date), x, width: 0, cells: [] };
blocks.push(block);
}
const first = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1));
const index = (first.getUTCDay() + 6) % 7 + date.getUTCDate() - 1;
const col = Math.floor(index / 7);
block.cells.push({
x: x + col * STEP,
y: (index % 7) * STEP,
date,
attempts: day.attempts.easy + day.attempts.medium + day.attempts.hard,
passes: day.passes,
});
block.width = col * STEP + CELL;
}
const end = blocks.at(-1);
return { blocks, width: end ? end.x + end.width : 0 };
}
export default function SubmissionHeatmap() {
const load = useStats();
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
if (load.phase !== "ready") {
return (
<Card>
<CardContent>
<StatsPending load={load} className="h-40 w-full" />
</CardContent>
</Card>
);
}
const { blocks, width } = layout(load.stats.year);
const cells = blocks.flatMap((b) => b.cells);
let run = 0;
let streak = 0;
for (const c of cells) {
run = c.attempts > 0 ? run + 1 : 0;
streak = Math.max(streak, run);
}
const height = 7 * STEP - (STEP - CELL) + LABEL_H;
return (
<Card>
<CardHeader>
<CardTitle className="flex items-baseline gap-2">
<span className="font-semibold text-2xl tabular-nums">{cells.reduce((n, c) => n + c.attempts, 0)}</span>
<span className="font-normal text-muted-foreground text-sm">attempts in the past {MONTHS} months</span>
</CardTitle>
<CardDescription className="flex gap-4">
<span>
Active days:{" "}
<span className="text-foreground tabular-nums">{cells.filter((c) => c.attempts > 0).length}</span>
</span>
<span>
Max streak: <span className="text-foreground tabular-nums">{streak}</span>
</span>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-1 items-center">
<div ref={ref} className="relative w-full" onPointerLeave={hide}>
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-auto w-full"
role="img"
aria-label={`Attempts per day over the past ${MONTHS} months`}
>
{blocks.map((block) => (
<g key={`${block.label}-${block.x}`}>
{block.cells.map((cell) => (
<rect
key={cell.x * 1000 + cell.y}
x={cell.x}
y={cell.y}
width={CELL}
height={CELL}
rx={2}
fill={HEAT[Math.min(cell.attempts, 4)]}
onPointerMove={(event) =>
show(
event,
<>
<div className="font-medium">{DAY.format(cell.date)}</div>
<TipRow color={HEAT[Math.min(cell.attempts, 4)]!} label="Attempts" value={cell.attempts} />
<TipRow color="var(--chart-4)" label="Passed" value={cell.passes} />
</>,
)
}
/>
))}
<text
x={block.x + block.width / 2}
y={height - 3}
textAnchor="middle"
className="fill-muted-foreground text-[9px]"
>
{block.label}
</text>
</g>
))}
</svg>
{node}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,137 @@
/**
* The campaign's topics as packed bubbles — LeetCode's "All Topics" view,
* over our curriculum instead of LeetCode's tags. A bubble's area is the
* topic's problem count; it fills from the bottom like a glass as problems
* are solved, and turns solid once every one is. A topic the schedule has not
* reached yet is dimmed. Packing is d3-hierarchy's front-chain (packSiblings,
* largest first, so the layout is deterministic), drawn as SVG in a viewBox:
* Recharts has no packed layout.
*/
import { packEnclose, packSiblings } from "d3-hierarchy";
import { useId } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { type Stats, useStats } from "../../lib/stats";
import { cn } from "../../lib/utils";
import { StatsPending, TipRow, useHoverTip } from "./shared";
const PAD = 2; // gap between bubbles, in layout units
const UNIT = 12; // radius of a one-problem bubble
type Topic = Stats["topics"][number];
type Bubble = Topic & { x: number; y: number; r: number };
/** Split a name across lines of at most ~10 characters, never breaking a word. */
function lines(name: string): string[] {
const out: string[] = [];
for (const word of name.split(" ")) {
const last = out.at(-1);
if (last !== undefined && `${last} ${word}`.length <= 10) out[out.length - 1] = `${last} ${word}`;
else out.push(word);
}
return out;
}
export default function TopicBubbles() {
const load = useStats();
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
const clip = useId().replace(/:/g, "");
if (load.phase !== "ready") {
return (
<Card>
<CardHeader>
<CardTitle>Topics</CardTitle>
</CardHeader>
<CardContent>
<StatsPending load={load} className="aspect-square w-full" />
</CardContent>
</Card>
);
}
const topics = load.stats.topics.filter((t) => t.total > 0);
const circles: Bubble[] = topics
.slice()
.sort((a, b) => b.total - a.total || a.issue - b.issue)
.map((t) => ({ ...t, x: 0, y: 0, r: UNIT * Math.sqrt(t.total) + PAD }));
packSiblings(circles);
const bound = packEnclose(circles) ?? { x: 0, y: 0, r: 1 };
const solved = topics.reduce((n, t) => n + t.done, 0);
const total = topics.reduce((n, t) => n + t.total, 0);
return (
<Card>
<CardHeader>
<CardTitle>Topics</CardTitle>
<CardDescription>
{solved} of {total} problems across {topics.length} topics · size is problem count, fill is solved
</CardDescription>
</CardHeader>
<CardContent>
<div ref={ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hide}>
<svg
viewBox={`${bound.x - bound.r} ${bound.y - bound.r} ${bound.r * 2} ${bound.r * 2}`}
className="h-auto w-full"
role="img"
aria-label="Campaign topics sized by problem count and filled by problems solved"
>
{circles.map((c) => {
const r = c.r - PAD;
const ratio = c.total > 0 ? c.done / c.total : 0;
const complete = c.done >= c.total;
const words = lines(c.name);
const size = Math.min(r / 3.2, (1.7 * r) / Math.max(...words.map((w) => w.length)));
const id = `${clip}-${c.issue}`;
return (
<g
key={c.issue}
className={cn("transition-opacity hover:opacity-90", !c.taught && "opacity-50")}
onPointerMove={(event) =>
show(
event,
<>
<div className="font-medium">{c.name}</div>
<TipRow color="var(--chart-4)" label="Solved" value={`${c.done}/${c.total}`} />
{!c.taught && <div className="text-muted-foreground">Not reached yet</div>}
</>,
)
}
>
<clipPath id={id}>
<circle cx={c.x} cy={c.y} r={r} />
</clipPath>
<circle cx={c.x} cy={c.y} r={r} className="fill-muted stroke-border" strokeWidth={0.5} />
<rect
x={c.x - r}
y={c.y + r - 2 * r * ratio}
width={2 * r}
height={2 * r * ratio}
clipPath={`url(#${id})`}
fill="var(--chart-4)"
fillOpacity={complete ? 1 : 0.35}
/>
<text
x={c.x}
y={c.y - ((words.length - 1) * size * 1.15) / 2}
textAnchor="middle"
dominantBaseline="central"
clipPath={`url(#${id})`}
className={cn("pointer-events-none select-none", complete ? "fill-background" : "fill-foreground")}
style={{ fontSize: size }}
>
{words.map((w, i) => (
<tspan key={`${i}-${w}`} x={c.x} dy={i === 0 ? 0 : size * 1.15}>
{w}
</tspan>
))}
</text>
</g>
);
})}
</svg>
{node}
</div>
</CardContent>
</Card>
);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* What the progress cards share besides the data: the not-ready body
* (a skeleton while /api/stats loads, one muted line if it fails) and the
* hover tooltip for the two charts drawn as plain SVG (the heatmap calendar
* and the topic bubbles — Recharts has neither layout). The tooltip wears the
* exact classes of shadcn's ChartTooltipContent, so all four cards' tooltips
* look like one component.
*/
import { type PointerEvent, type ReactNode, useRef, useState } from "react";
import { cn } from "../../lib/utils";
import type { Load } from "../../lib/stats";
import { Skeleton } from "../ui/skeleton";
export function StatsPending({ load, className }: { load: Exclude<Load, { phase: "ready" }>; className: string }) {
if (load.phase === "loading") return <Skeleton className={className} />;
return (
<p className="my-0! text-muted-foreground text-sm">
Stats unavailable — they are served live from the SRS Worker, and it did not respond.
</p>
);
}
interface Tip {
x: number;
y: number;
content: ReactNode;
}
/**
* A pointer-following tooltip for SVG marks. Put `ref` on a `relative`
* wrapper, call `show` from a mark's pointer events, render `tip` inside the
* wrapper. Positions are relative to the wrapper, so page scroll never skews it.
*/
export function useHoverTip<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [tip, setTip] = useState<Tip | null>(null);
const show = (event: PointerEvent, content: ReactNode) => {
const box = ref.current?.getBoundingClientRect();
if (box) setTip({ x: event.clientX - box.left, y: event.clientY - box.top, content });
};
const hide = () => setTip(null);
const node = tip && (
<div
className={cn(
"pointer-events-none absolute z-10 grid min-w-[8rem] -translate-x-1/2 -translate-y-[calc(100%+0.75rem)] items-start gap-1.5",
"rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
)}
style={{ left: tip.x, top: tip.y }}
>
{tip.content}
</div>
);
return { ref, show, hide, node };
}
/** One tooltip row, the shape ChartTooltipContent draws: colour dot, label, mono value. */
export function TipRow({ color, label, value }: { color: string; label: ReactNode; value: ReactNode }) {
return (
<div className="flex w-full items-center gap-2">
<div className="size-2.5 shrink-0 rounded-[2px]" style={{ background: color }} />
<div className="flex flex-1 items-center justify-between gap-4 leading-none">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium font-mono text-foreground tabular-nums">{value}</span>
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import * as React from "react"
import { cn } from "../../lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+372
View File
@@ -0,0 +1,372 @@
import * as React from "react"
import { cn } from "../../lib/utils"
import * as RechartsPrimitive from "recharts"
import type { TooltipValueType } from "recharts"
// Format: { THEME_NAME: CSS_SELECTOR }. Blume switches dark mode with
// [data-theme="dark"] on <html>, not shadcn's .dark class.
const THEMES = { light: "", dark: ':root[data-theme="dark"]' } as const
const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string
export type ChartConfig = Record<
string,
{
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
>
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
initialDimension = INITIAL_DIMENSION,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
initialDimension?: {
width: number
height: number
}
}) {
const uniqueId = React.useId()
const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer
initialDimension={initialDimension}
>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme ?? config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ??
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
} & Omit<
RechartsPrimitive.DefaultTooltipContentProps<
TooltipValueType,
TooltipNameType
>,
"accessibilityLayer"
>) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? (config[label]?.label ?? label)
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color ?? item.payload?.fill ?? item.color
return (
<div
key={index}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label ?? item.name}
</span>
</div>
{item.value != null && (
<span className="font-mono font-medium text-foreground tabular-nums">
{typeof item.value === "number"
? item.value.toLocaleString()
: String(item.value)}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> & {
hideIcon?: boolean
nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey ?? item.dataKey ?? "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={index}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config ? config[configLabelKey] : config[key]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "../../lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
+83
View File
@@ -0,0 +1,83 @@
"use client"
import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"
import { toggleVariants } from "./toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils"
import { Toggle as TogglePrimitive } from "radix-ui"
// Blume's `accent` is its brand colour, not shadcn's subtle surface: the on
// and hover states use background/muted instead (see theme.css).
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-muted hover:text-foreground",
},
size: {
default: "h-9 min-w-9 px-2",
sm: "h-8 min-w-8 px-1.5",
lg: "h-10 min-w-10 px-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
@@ -1,13 +1,17 @@
---
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
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
video: "nIVW4P8b1VA"
issue: "https://github.com/prdlk/leetcode/issues/62"
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]].
::::
@@ -68,7 +72,3 @@ class Solution:
return nums[lowest_index]
```
## Explanation
<YouTube id="nIVW4P8b1VA" title="Find Minimum in Rotated Sorted Array - Binary Search - Leetcode 153 - Python" />
@@ -0,0 +1,48 @@
---
title: '162. Find Peak Element'
description: A peak element is an element that is strictly greater than its neighbors
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
issue: "https://github.com/prdlk/leetcode/issues/64"
sidebar:
label: 'Find Peak Element'
badge: 'Medium'
---
::::warning
You must write an algorithm that runs in O(log n) time.
::::
### Example 1:
- Input: `nums = [1,2,3,1]`
- Output: `2`
- Explanation: `3` is a peak element and your function should return the index number `2`.
### Example 2:
- Input: `nums = [1,2,1,3,5,6,4]`
- Output: `5`
- Explanation: Your function can return either index number `1` where the peak element is `2`, or index number `5` where the peak element is `6`.
### Constraints:
- `1 <= nums.length <= 1000`
- `-2^31 <= nums[i] <= 2^31 - 1`
- `nums[i] != nums[i + 1] for all valid i.`
## Solution
```py
class Solution:
def findPeakElement(self, nums: list[int]) -> int:
left, right = 0, len(nums) - 1
while left < right:
mid = (left + right) // 2
if nums[mid] < nums[mid + 1]:
left = mid + 1
else:
right = mid
return left
```
@@ -1,13 +1,17 @@
---
title: '33. Search in Rotated Sorted Array'
description: There is an integer array nums sorted in ascending order (with distinct values)
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
video: "U8XENwh8Oy8"
issue: "https://github.com/prdlk/leetcode/issues/60"
sidebar:
label: 'Search in Rotated Sorted Array'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
::::warning
You must write an algorithm with O(log n) runtime complexity.
::::
@@ -92,7 +96,3 @@ var search = function(nums, target) {
return -1;
};
```
## Explanation
<YouTube id="U8XENwh8Oy8" title="Search in rotated sorted array - Leetcode 33 - Python" />
@@ -0,0 +1,60 @@
---
title: '34. Find First and Last Position of Element in Sorted Array'
description: Given an array of integers nums sorted in non-decreasing order, find the starting and ending position of a given target value
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
issue: "https://github.com/prdlk/leetcode/issues/65"
sidebar:
label: 'Find First and Last Position of Element in Sorted Array'
badge: 'Medium'
---
::::warning
You must write an algorithm with O(log n) runtime complexity.
::::
### Example 1:
- Input: `nums = [5,7,7,8,8,10], target = 8`
- Output: `[3,4]`
### Example 2:
- Input: `nums = [5,7,7,8,8,10], target = 6`
- Output: `[-1,-1]`
### Example 3:
- Input: `nums = [], target = 0`
- Output: `[-1,-1]`
### Constraints:
- `0 <= nums.length <= 10^5`
- `-10^9 <= nums[i] <= 10^9`
- `nums` is a non-decreasing array.
- `-10^9 <= target <= 10^9`
## Solution
```py
class Solution:
def searchRange(self, nums: list[int], target: int) -> list[int]:
def find(first):
result = -1
left, right = 0, len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
result = mid
if first:
right = mid - 1
else:
left = mid + 1
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return result
return [find(True), find(False)]
```
@@ -1,13 +1,17 @@
---
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
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
video: "K-RYzDZkzCI"
issue: "https://github.com/prdlk/leetcode/issues/63"
sidebar:
label: 'Search Insert Position'
badge: 'Easy'
---
<Badge variant="accent">Array</Badge>
::::warning
You must write an algorithm with O(log n) runtime complexity.
::::
@@ -65,7 +69,3 @@ class Solution:
return (l + r) // 2 + 1
```
## Explanation
<YouTube id="K-RYzDZkzCI" title="Search Insert Position - Binary Search - Leetcode 35 - Python" />
@@ -0,0 +1,63 @@
---
title: '4. Median of Two Sorted Arrays'
description: Given two sorted arrays nums1 and nums2 of size m and n respectively, return the median of the two sorted arrays
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
- { name: "Divide and Conquer", slug: "divide-and-conquer" }
issue: "https://github.com/prdlk/leetcode/issues/66"
sidebar:
label: 'Median of Two Sorted Arrays'
badge: 'Hard'
---
### Example 1:
- Input: `nums1 = [1,3], nums2 = [2]`
- Output: `2.00000`
- Explanation: merged array = `[1,2,3]` and median is `2`.
### Example 2:
- Input: `nums1 = [1,2], nums2 = [3,4]`
- Output: `2.50000`
- Explanation: merged array = `[1,2,3,4]` and median is `(2 + 3`) / `2 = 2.5`.
### Constraints:
- `nums1.length == m`
- `nums2.length == n`
- `0 <= m <= 1000`
- `0 <= n <= 1000`
- `1 <= m + n <= 2000`
- `-10^6 <= nums1[i], nums2[i] <= 10^6`
## Solution
```py
class Solution:
def findMedianSortedArrays(self, nums1: list[int], nums2: list[int]) -> float:
if len(nums1) > len(nums2):
return self.findMedianSortedArrays(nums2, nums1)
m, n = len(nums1), len(nums2)
left, right = 0, m
while left <= right:
partA = (left + right) // 2
partB = (m + n + 1) // 2 - partA
maxLeftA = float("-inf") if partA == 0 else nums1[partA - 1]
minRightA = float("inf") if partA == m else nums1[partA]
maxLeftB = float("-inf") if partB == 0 else nums2[partB - 1]
minRightB = float("inf") if partB == n else nums2[partB]
if maxLeftA <= minRightB and maxLeftB <= minRightA:
if (m + n) % 2 == 0:
return (max(maxLeftA, maxLeftB) + min(minRightA, minRightB)) / 2
else:
return max(maxLeftA, maxLeftB)
elif maxLeftA > minRightB:
right = partA - 1
else:
left = partA + 1
```
@@ -1,13 +1,17 @@
---
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
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
video: "s4DPM8ct1pI"
issue: "https://github.com/prdlk/leetcode/issues/58"
sidebar:
label: 'Binary Search'
badge: 'Easy'
---
<Badge variant="accent">Array</Badge>
::::warning
You must write an algorithm with O(log n) runtime complexity.
::::
@@ -70,7 +74,3 @@ var search = function(nums, target) {
return -1;
};
```
## Explanation
<YouTube id="s4DPM8ct1pI" title="Binary Search - Leetcode 704 - Python" />
@@ -1,13 +1,18 @@
---
title: '74. Search a 2D Matrix'
description: 'You are given an m x n integer matrix matrix with the following two properties:'
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
- { name: "Matrix", slug: "matrix" }
video: "Ber2pi2C0j0"
issue: "https://github.com/prdlk/leetcode/issues/61"
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.
::::
@@ -85,7 +90,3 @@ class Solution:
return True
return False
```
## Explanation
<YouTube id="Ber2pi2C0j0" title="Search a 2D Matrix - Leetcode 74 - Python" />
@@ -1,13 +1,17 @@
---
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
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
video: "U2SozAs9RzA"
issue: "https://github.com/prdlk/leetcode/issues/59"
sidebar:
label: 'Koko Eating Bananas'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `piles = [3,6,7,11], h = 8`
- Output: `4`
@@ -76,7 +80,3 @@ var minEatingSpeed = function(piles, h) {
return l;
};
```
## Explanation
<YouTube id="U2SozAs9RzA" title="Koko Eating Bananas - Binary Search - Leetcode 875 - Python" />
@@ -0,0 +1,86 @@
---
title: Binary Search
description: 'Half of a sorted range is removed at each step until the target is found.'
hidden: true
---
Binary search is an efficient array search algorithm. It works by narrowing down the search range by half each time. If you have looked up a word in a physical dictionary, you've already used binary search in real life. Let's look at a simple example:
*Given a sorted array of integers and an integer called target, find the element that equals the target and return its index. If the element is not found, return -1.*
The key observation here is that the array is sorted. We pick a random element in the array and compare it to the target.
- If we happen to pick the element that equals the target (how lucky!), then bingo. We don't need to do any more work; we return its index.
- If the element is smaller than the target, then we know the target cannot be found in the section to the left of the current element, since everything to the left is even smaller. So, we discard the current element and everything on the left from the search range.
- If the element is larger than the target, then we know the target cannot be found in the section to the right of the current element, since everything to the right is even larger. So, we discard the current element and everything on the right from the search range.
We repeat this process until we find the target. Instead of picking a random element, we always pick the **middle** element in the current search range. This way, we can discard half of the options and shrink the search range by half each time. This gives us `O(log(N))` runtime.
This idea can be implemented both iteratively and recursively. However, the major difference is that the iterative version of binary search uses `O(1)` memory while the recursive version uses `O(log(N))` memory.
## Implementation
The search range is represented by the `left` and `right` indices that start from both ends of the array and move towards each other as we search. When moving the index, we discard elements and shrink the search range.
Time Complexity: `O(log(n))`
<Carousel>
<CarouselImage src='../../../public/binary_search.001.png' />
<CarouselImage src='../../../public/binary_search.002.png' />
<CarouselImage src='../../../public/binary_search.003.png' />
<CarouselImage src='../../../public/binary_search.004.png' />
<CarouselImage src='../../../public/binary_search.005.png' />
<CarouselImage src='../../../public/binary_search.006.png' />
<CarouselImage src='../../../public/binary_search.007.png' />
<CarouselImage src='../../../public/binary_search.008.png' />
</Carousel>
```python
def binary_search(arr: list[int], target: int) -> int:
left, right = 0, len(arr) - 1
# <= because left and right could point to the same element, < would miss it
while left <= right:
# double slash for integer division in python 3,
# we don't have to worry about integer `left + right` overflow
# since python integers can be arbitrarily large
mid = (left + right) // 2
# found target, return its index
if arr[mid] == target:
return mid
# middle less than target, discard left half by making left search boundary `mid + 1`
if arr[mid] < target:
left = mid + 1
# middle greater than target, discard right half by making right search boundary `mid - 1`
else:
right = mid - 1
return -1 # if we get here we didn't hit above return so we didn't find target
```
### Calculating `mid`
Note that when calculating `mid`, if the number of elements is even, there are two elements in the middle. We usually follow the convention of picking the first one, equivalent to doing integer division `(left + right) / 2`.
In most programming languages, we calculate `mid` with `left + floor((right-left) / 2)` to avoid potential integer overflow. However, in Python, we do not need to worry about integer overflow with `left + right` because Python3 integers can be arbitrarily large.
## Deduction
It's essential to understand and deduce the algorithm yourself instead of memorizing it. In an actual interview, interviewers may ask you additional questions to test your understanding, so simply memorizing the algorithm may not be enough to convince the interviewer.
Key elements in writing a correct binary search:
### 1. When to terminate the loop
Make sure the `while` loop includes an equality comparison; otherwise, we might skip the loop and miss the potential match for the edge case of a one-element array.
### 2. Whether/how to update `left` and `right` boundary in the `if` conditions
Consider which side to discard. If `arr[mid]` is smaller than the `target`, we should discard everything on the left by making `left = mid + 1`.
### 3. Should I discard the current element?
For vanilla binary search, we can discard it since it cannot be the final answer if it is not equal to the target. There might be situations where you would want to think twice before discarding the current element. We'll discuss this in the next module.
## When to use binary search
Interestingly, binary search works beyond sorted arrays. **You can use binary search whenever you make a binary decision to shrink the search range.**
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Binary Search", order: 5, icon: "search", display: "group" });
@@ -0,0 +1,56 @@
---
title: '128. Longest Consecutive Sequence'
description: Given an unsorted array of integers nums, return the length of the longest consecutive elements sequence
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Hash Table", slug: "hash-table" }
- { name: "Union-Find", slug: "union-find" }
issue: "https://github.com/prdlk/leetcode/issues/34"
sidebar:
label: 'Longest Consecutive Sequence'
badge: 'Medium'
---
::::warning
You must write an algorithm that runs in O(n) time.
::::
### Example 1:
- Input: `nums = [100,4,200,1,3,2]`
- Output: `4`
- Explanation: The longest consecutive elements sequence is [1, `2`, `3`, 4]. Therefore its length is `4`.
### Example 2:
- Input: `nums = [0,3,7,2,5,8,4,6,0,1]`
- Output: `9`
### Example 3:
- Input: `nums = [1,0,1,2]`
- Output: `3`
### Constraints:
- `0 <= nums.length <= 10^5`
- `-10^9 <= nums[i] <= 10^9`
## Solution
```py
class Solution:
def longestConsecutive(self, nums: list[int]) -> int:
seen = set(nums)
best = 0
for n in seen:
if n - 1 in seen:
continue
length = 1
while length + n in seen:
length += 1
best = max(length, best)
return best
```
@@ -1,13 +1,15 @@
---
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
topics:
- { name: "Hash Table", slug: "hash-table" }
- { name: "String", slug: "string" }
sidebar:
label: 'Check if the Sentence Is Pangram'
badge: 'Easy'
---
<Badge variant="accent">Hash Table</Badge>
### Example 1:
- Input: `sentence = "thequickbrownfoxjumpsoverthelazydog"`
- Output: `true`
@@ -1,13 +1,17 @@
---
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
topics:
- { name: "Array", slug: "array" }
- { name: "Hash Table", slug: "hash-table" }
video: "KLlXCFG5TnA"
issue: "https://github.com/prdlk/leetcode/issues/29"
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?
::::
@@ -59,7 +63,3 @@ class Solution:
seen[num] = i
return []
```
## Explanation
<YouTube id="KLlXCFG5TnA" title="Two Sum - Leetcode 1 - HashMap - Python" />
@@ -0,0 +1,52 @@
---
title: '205. Isomorphic Strings'
description: Given two strings s and t, determine if they are isomorphic
icon: dot
topics:
- { name: "Hash Table", slug: "hash-table" }
- { name: "String", slug: "string" }
issue: "https://github.com/prdlk/leetcode/issues/33"
sidebar:
label: 'Isomorphic Strings'
badge: 'Easy'
---
### Example 1:
- Input: `s = "egg", t = "add"`
- Output: `true`
- Explanation: The strings `s` and `t` can be made identical by: • Mapping 'e' to 'a'. • Mapping 'g' to 'd'.
### Example 2:
- Input: `s = "f11", t = "b23"`
- Output: `false`
- Explanation: The strings `s` and `t` can not be made identical as `'1`' needs to be mapped to both `'2`' and `'3`'.
### Example 3:
- Input: `s = "paper", t = "title"`
- Output: `true`
### Constraints:
- `1 <= s.length <= 5 * 10^4`
- `t.length == s.length`
- `s` and `t` consist of any valid ascii character.
## Solution
```py
class Solution:
def isIsomorphic(self, s: str, t: str) -> bool:
N = len(t)
seen = {}
for i in range(N):
c1, c2 = s[i], t[i]
if c1 in seen and seen[c1] != c2:
return False
elif c1 in seen and seen[c1] == c2:
continue
else:
seen[c1] = c2
return len(set(s)) == len(set(t))
```
@@ -1,13 +1,18 @@
---
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
topics:
- { name: "Array", slug: "array" }
- { name: "Hash Table", slug: "hash-table" }
- { name: "Sorting", slug: "sorting" }
video: "3OamzN90kPg"
issue: "https://github.com/prdlk/leetcode/issues/27"
sidebar:
label: 'Contains Duplicate'
badge: 'Easy'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `nums = [1,2,3,1]`
- Output: `true`
@@ -42,32 +47,8 @@ flowchart TD
## Solution
<CodeGroup>
```py Python
```py
class Solution:
def containsDuplicate(self, nums: List[int]) -> bool:
return len(set(nums)) != len(nums)
```
```js JavaScript
/**
* @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;
};
```
</CodeGroup>
## Explanation
<YouTube id="3OamzN90kPg" title="Contains Duplicate - Leetcode 217 - Python" />
@@ -1,13 +1,18 @@
---
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
topics:
- { name: "Hash Table", slug: "hash-table" }
- { name: "String", slug: "string" }
- { name: "Sorting", slug: "sorting" }
video: "9UtInBqnCgA"
issue: "https://github.com/prdlk/leetcode/issues/28"
sidebar:
label: 'Valid Anagram'
badge: 'Easy'
---
<Badge variant="accent">Hash Table</Badge>
::::warning
What if the inputs contain Unicode characters? How would you adapt your solution to such a case?
::::
@@ -59,7 +64,3 @@ var isAnagram = function(s, t) {
return true;
};
```
## Explanation
<YouTube id="9UtInBqnCgA" title="Valid Anagram - Leetcode 242 - Python" />
@@ -0,0 +1,57 @@
---
title: '290. Word Pattern'
description: Given a pattern and a string s, find if s follows the same pattern
icon: dot
topics:
- { name: "Hash Table", slug: "hash-table" }
- { name: "String", slug: "string" }
issue: "https://github.com/prdlk/leetcode/issues/35"
sidebar:
label: 'Word Pattern'
badge: 'Easy'
---
### Example 1:
- Input: `pattern = "abba", s = "dog cat cat dog"`
- Output: `true`
- Explanation: The bijection can be established as: • 'a' maps to "dog". • 'b' maps to "cat".
### Example 2:
- Input: `pattern = "abba", s = "dog cat cat fish"`
- Output: `false`
### Example 3:
- Input: `pattern = "aaaa", s = "dog cat cat dog"`
- Output: `false`
### Constraints:
- `1 <= pattern.length <= 300`
- `pattern` contains only lower-case English letters.
- `1 <= s.length <= 3000`
- `s` contains only lowercase English letters and spaces ' '.
- `s` does not contain any leading or trailing spaces.
- All the words in `s` are separated by a single space.
## Solution
```py
class Solution:
def wordPattern(self, pattern: str, s: str) -> bool:
if len(pattern) != len(s.split()):
return False
words = s.split()
map = {}
for i in range(len(pattern)):
c = pattern[i]
w = words[i]
if c in map and map[c] != w:
return False
else:
map[c] = w
return len(set(pattern)) == len(set(words))
```
@@ -1,13 +1,23 @@
---
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
topics:
- { name: "Array", slug: "array" }
- { name: "Hash Table", slug: "hash-table" }
- { name: "Divide and Conquer", slug: "divide-and-conquer" }
- { name: "Sorting", slug: "sorting" }
- { name: "Heap (Priority Queue)", slug: "heap-priority-queue" }
- { name: "Bucket Sort", slug: "bucket-sort" }
- { name: "Counting", slug: "counting" }
- { name: "Quickselect", slug: "quickselect" }
video: "YPTqKIgVk-k"
issue: "https://github.com/prdlk/leetcode/issues/31"
sidebar:
label: 'Top K Frequent Elements'
badge: 'Medium'
---
<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.
::::
@@ -59,7 +69,3 @@ var topKFrequent = function (nums, k) {
.slice(0, k);
};
```
## Explanation
<YouTube id="YPTqKIgVk-k" title="Top K Frequent Elements - Bucket Sort - Leetcode 347 - Python" />
@@ -1,13 +1,18 @@
---
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
topics:
- { name: "Hash Table", slug: "hash-table" }
- { name: "String", slug: "string" }
- { name: "Queue", slug: "queue" }
- { name: "Counting", slug: "counting" }
video: "rBENYgWy3xU"
sidebar:
label: 'First Unique Character in a String'
badge: 'Easy'
---
<Badge variant="accent">Hash Table</Badge>
### Example 1:
- Input: `s = "leetcode"`
- Output: `0`
@@ -57,7 +62,3 @@ var firstUniqChar = function (s) {
return -1;
};
```
## Explanation
<YouTube id="rBENYgWy3xU" title="First Unique Character in a String - Leetcode 387 - Python" />
@@ -1,13 +1,20 @@
---
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
topics:
- { name: "Hash Table", slug: "hash-table" }
- { name: "String", slug: "string" }
- { name: "Sorting", slug: "sorting" }
- { name: "Heap (Priority Queue)", slug: "heap-priority-queue" }
- { name: "Bucket Sort", slug: "bucket-sort" }
- { name: "Counting", slug: "counting" }
video: "OXdXc9HTrIg"
sidebar:
label: 'Sort Characters By Frequency'
badge: 'Medium'
---
<Badge variant="accent">Hash Table</Badge>
### Example 1:
- Input: `s = "tree"`
- Output: `"eert"`
@@ -59,7 +66,3 @@ var frequencySort = function (s) {
.join("");
};
```
## Explanation
<YouTube id="OXdXc9HTrIg" title="Sort Characters By Frequency - Leetcode 451 - Python" />
@@ -1,13 +1,19 @@
---
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
topics:
- { name: "Array", slug: "array" }
- { name: "Hash Table", slug: "hash-table" }
- { name: "String", slug: "string" }
- { name: "Sorting", slug: "sorting" }
video: "vzdNOK2oB2E"
issue: "https://github.com/prdlk/leetcode/issues/30"
sidebar:
label: 'Group Anagrams'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `strs = ["eat","tea","tan","ate","nat","bat"]`
- Output: `[["bat"],["nat","tan"],["ate","eat","tea"]]`
@@ -64,7 +70,3 @@ var groupAnagrams = function(strs) {
return Object.values(anagrams);
};
```
## Explanation
<YouTube id="vzdNOK2oB2E" title="Group Anagrams - Categorize Strings by Count - Leetcode 49" />
@@ -0,0 +1,50 @@
---
title: Hash Table
description: 'A key maps to a value in constant time, which removes the need for a full scan.'
hidden: true
---
Sometimes when programming, we need a data structure that maps an arbitrary data type to another arbitrary data type, like an array with non-integer indices. For example, a phone book that allows lookup by a person's name requires such data type.
A **hash table** (or a hash map) is one way of representing this data structure. The idea of hash table is this:
Data is stored in a fix-sized array, where each data value has an unique index. When we add a new `(key, value)` pair to the array, we use a hash function to generate a hash value within the range of the array with the `key`. We then store the data `value` at the index indicated by the generated hash value. Later, when we retrieve the data with the `key` from the hash table, we hash the `key` again and look up the data in the array with the corresponding index. Since the hash function always returns the same hash values with the same input, we will always find the correct item in that index.
<Frame caption="Hash Table Operations">
![](../../../public/hash-table-operations.svg)
</Frame>
## Efficiency
Assuming the hash function uses constant time, the average time complexity is `O(n / k)` for each insertion/access operation on the table, where `n` is the number of entries in the hash table and `k` is the array size of the table. This assumes the hash function distributes keys uniformly across the `k` buckets, so each bucket holds about `n / k` entries on average.
### Frequency Counting
```python
def get_counter(arr: list[int]) -> dict[int, int]:
# initialize a hash map to store each number and its count
counter: dict[int, int] = {}
for num in arr:
# check if num is a key in the hash map
if num in counter:
# update the count of num to increase by 1
counter[num] += 1
else:
# set the count of num to 1
counter[num] = 1
return counter
```
:::tip
Python provides a built-in [`collections.Counter`](https://docs.python.org/3/library/collections.html#collections.Counter) class that does this for us.
:::
```python
from collections import Counter
fq = Counter(arr)
```
@@ -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: "1. Linear Structures", order: 1 });
@@ -0,0 +1,59 @@
---
title: '496. Next Greater Element I'
description: The next greater element of some element x in an array is the first greater element that is to the right of x in the same array
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Hash Table", slug: "hash-table" }
- { name: "Stack", slug: "stack" }
- { name: "Monotonic Stack", slug: "monotonic-stack" }
issue: "https://github.com/prdlk/leetcode/issues/74"
sidebar:
label: 'Next Greater Element I'
badge: 'Easy'
---
::::warning
Could you find an O(nums1.length + nums2.length) solution?
::::
### Example 1:
- Input: `nums1 = [4,1,2], nums2 = [1,3,4,2]`
- Output: `[-1,3,-1]`
- Explanation:
- The next greater element for each value of `nums1` is as follows:
- `4` is underlined in `nums2 = [1,3,4,2]`. There is no next greater element, so the answer is `-1`.
- `1` is underlined in `nums2 = [1,3,4,2]`. The next greater element is `3`.
- `2` is underlined in `nums2 = [1,3,4,2]`. There is no next greater element, so the answer is `-1`.
### Example 2:
- Input: `nums1 = [2,4], nums2 = [1,2,3,4]`
- Output: `[3,-1]`
- Explanation:
- The next greater element for each value of `nums1` is as follows:
- `2` is underlined in `nums2 = [1,2,3,4]`. The next greater element is `3`.
- `4` is underlined in `nums2 = [1,2,3,4]`. There is no next greater element, so the answer is `-1`.
### Constraints:
- `1 <= nums1.length <= nums2.length <= 1000`
- `0 <= nums1[i], nums2[i] <= 10^4`
- All integers in `nums1` and `nums2` are unique.
- All the integers of `nums1` also appear in nums2.
## Solution
```py
class Solution:
def nextGreaterElement(self, nums1: list[int], nums2: list[int]) -> list[int]:
mono_stack = []
next_greater = {}
for cur in nums2:
while mono_stack and cur > mono_stack[-1]:
val = mono_stack.pop()
next_greater[val] = cur
mono_stack.append(cur)
return [next_greater.get(x, -1) for x in nums1]
```
@@ -1,13 +1,18 @@
---
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
topics:
- { name: "Array", slug: "array" }
- { name: "Stack", slug: "stack" }
- { name: "Monotonic Stack", slug: "monotonic-stack" }
video: "cTBiBSnjO3c"
issue: "https://github.com/prdlk/leetcode/issues/72"
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]`
@@ -53,7 +58,3 @@ class Solution:
stack.append(i)
return ans
```
## Explanation
<YouTube id="cTBiBSnjO3c" title="Daily Temperatures - Monotonic Stack - Leetcode 739 - Python" />
@@ -0,0 +1,51 @@
---
title: '84. Largest Rectangle in Histogram'
description: Given an array of integers heights representing the histogram's bar height where the width of each bar is 1, return the area of the largest rectangle in the histogram
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Stack", slug: "stack" }
- { name: "Monotonic Stack", slug: "monotonic-stack" }
- { name: "Range Minimum/Maximum Query", slug: "range-minimum-maximum-query" }
issue: "https://github.com/prdlk/leetcode/issues/76"
sidebar:
label: 'Largest Rectangle in Histogram'
badge: 'Hard'
---
### Example 1:
- Input: `heights = [2,1,5,6,2,3]`
- Output: `10`
- Explanation: The above is a histogram where width of each bar is `1`. The largest rectangle is shown in the red area, which has an area = `10` units.
### Example 2:
- Input: `heights = [2,4]`
- Output: `4`
### Constraints:
- `1 <= heights.length <= 10^5`
- `0 <= heights[i] <= 10^4`
## Solution
```py
class Solution:
def largestRectangleArea(self, heights: list[int]) -> int:
maxArea = 0
stack = []
for i, h in enumerate(heights):
start = i
while stack and stack[-1][1] > h:
index, height = stack.pop()
width = i - index
maxArea = max(maxArea, height * width)
start = index
stack.append((start, h))
for i, h in stack:
maxArea = max(maxArea, h * (len(heights) - i))
return maxArea
```
@@ -1,13 +1,19 @@
---
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
topics:
- { name: "Array", slug: "array" }
- { name: "Stack", slug: "stack" }
- { name: "Sorting", slug: "sorting" }
- { name: "Monotonic Stack", slug: "monotonic-stack" }
video: "Pr6T-3yB9RM"
issue: "https://github.com/prdlk/leetcode/issues/73"
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`
@@ -61,7 +67,3 @@ class Solution:
return len(stack)
```
## Explanation
<YouTube id="Pr6T-3yB9RM" title="Car Fleet - Leetcode 853 - Python" />
@@ -0,0 +1,45 @@
---
title: '901. Online Stock Span'
description: Design an algorithm that collects daily price quotes for some stock and returns the span of that stock's price for the current day
icon: dot
topics:
- { name: "Stack", slug: "stack" }
- { name: "Design", slug: "design" }
- { name: "Monotonic Stack", slug: "monotonic-stack" }
- { name: "Data Stream", slug: "data-stream" }
issue: "https://github.com/prdlk/leetcode/issues/75"
sidebar:
label: 'Online Stock Span'
badge: 'Medium'
---
### Example 1:
- Input: ``
- Output: ``
- Explanation: Input ["StockSpanner", "next", "next", "next", "next", "next", "next", "next"] `[[]`, `[100]`, `[80]`, `[60]`, `[70]`, `[60]`, `[75]`, [85]] Output [null, `1`, `1`, `1`, `2`, `1`, `4`, 6] Explanation StockSpanner stockSpanner = new StockSpanner(); stockSpanner.next(100); // return `1` stockSpanner.next(80); // return `1` stockSpanner.next(60); // return `1` stockSpanner.next(70); // return `2` stockSpanner.next(60); // return `1` stockSpanner.next(75); // return `4`, because the last `4` prices (including today's `price` of `75`) were less than or equal to today's `price`. stockSpanner.next(85); // return `6`
### Constraints:
- `1 <= price <= 10^5`
- At most 10^4 calls will be made to next.
## Solution
```py
class StockSpanner:
def __init__(self):
self.stack = []
def next(self, price: int) -> int:
ans = 1
while self.stack and self.stack[-1][0] <= price:
ans += self.stack.pop()[1]
self.stack.append([price, ans])
return ans
# Your StockSpanner object will be instantiated and called as such:
# obj = StockSpanner()
# param_1 = obj.next(price)
```
@@ -0,0 +1,36 @@
---
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
---
<YouTube id="Dq_ObZwTY_Q" title="Monotonic Stack" />
A monotonic stack keeps its values in sorted order as you push elements. The order can be increasing or decreasing, depending on the problem.
A decreasing monotonic stack is the most common version. Before pushing a new value `x`, we pop from the top while the top is smaller than or equal to `x`. After those pops, pushing `x` keeps the stack decreasing from bottom to top.
This push rule is the whole idea. A monotonic deque uses the same rule, but a deque also lets you remove old elements from the front, which is useful for sliding window problems.
## Example Implementation
This implementation maintains a decreasing monotonic stack.
```python
def mono_stack(insert_entries):
stack = []
for entry in insert_entries:
# Pop smaller (or equal) values so stack stays decreasing.
while stack and stack[-1] <= entry:
stack.pop()
# Do something with the popped item here
stack.append(entry)
```
:::tip
In interview problems, the stack usually stores indices instead of values. You can read the value from the original array when needed, and the index gives you distance information such as **"how many steps away is the next greater element."**
:::
For an array `nums`, if index `j` pops when you process index `i`, then `i` is the first index to the right that satisfies the stack condition for `j`. In a next-greater-element problem, that means `nums[i]` is the next greater value for `nums[j]`.
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Monotonic Stack", order: 7, icon: "trending-up", display: "group" });
@@ -1,13 +1,17 @@
---
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
topics:
- { name: "Array", slug: "array" }
- { name: "Prefix Sum", slug: "prefix-sum" }
video: "bNvIQI2wAjk"
issue: "https://github.com/prdlk/leetcode/issues/37"
sidebar:
label: 'Product of Array Except Self'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
::::warning
You must write an algorithm that runs in O(n) time and without using the division operation.
Can you solve the problem in O(1) extra space complexity? (The output array does not count as extra space for space complexity analysis.)
@@ -62,7 +66,3 @@ var productExceptSelf = function(nums) {
return answer;
};
```
## Explanation
<YouTube id="bNvIQI2wAjk" title="Product of Array Except Self - Leetcode 238 - Python" />
@@ -1,13 +1,18 @@
---
title: '303. Range Sum Query - Immutable'
description: Given an integer array nums, handle multiple queries of the following type
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Design", slug: "design" }
- { name: "Prefix Sum", slug: "prefix-sum" }
video: "2pndAmo_sMA"
issue: "https://github.com/prdlk/leetcode/issues/36"
sidebar:
label: 'Range Sum Query - Immutable'
badge: 'Easy'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `["NumArray", "sumRange", "sumRange", "sumRange"]` `[[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]]`
- Output: `[null, 1, -1, -3]`
@@ -65,7 +70,3 @@ class NumArray {
* var param_1 = obj.sumRange(left,right)
*/
```
## Explanation
<YouTube id="2pndAmo_sMA" title="Range Sum Query Immutable - Leetcode 303 - Python" />
@@ -0,0 +1,53 @@
---
title: '525. Contiguous Array'
description: Given a binary array nums, return the maximum length of a contiguous subarray with an equal number of 0 and 1
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Hash Table", slug: "hash-table" }
- { name: "Prefix Sum", slug: "prefix-sum" }
issue: "https://github.com/prdlk/leetcode/issues/39"
sidebar:
label: 'Contiguous Array'
badge: 'Medium'
---
### Example 1:
- Input: `nums = [0,1]`
- Output: `2`
- Explanation: [0, 1] is the longest contiguous subarray with an equal number of `0` and `1`.
### Example 2:
- Input: `nums = [0,1,0]`
- Output: `2`
- Explanation: [0, 1] (or [1, 0]) is a longest contiguous subarray with equal number of `0` and `1`.
### Example 3:
- Input: `nums = [0,1,1,1,1,1,0,0,0]`
- Output: `6`
- Explanation: `[1,1,1,0,0,0]` is the longest contiguous subarray with equal number of `0` and `1`.
### Constraints:
- `1 <= nums.length <= 10^5`
- `nums[i]` is either 0 or 1.
## Solution
```py
class Solution:
def findMaxLength(self, nums: List[int]) -> int:
map = {0: -1}
count = 0
longest = 0
for i, num in enumerate(nums):
count += 1 if num == 1 else -1
if count in map:
longest = max(longest, i - map[count])
else:
map[count] = i
return longest
```
@@ -1,13 +1,18 @@
---
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
topics:
- { name: "Array", slug: "array" }
- { name: "Hash Table", slug: "hash-table" }
- { name: "Prefix Sum", slug: "prefix-sum" }
video: "fFVZt-6sgyo"
issue: "https://github.com/prdlk/leetcode/issues/38"
sidebar:
label: 'Subarray Sum Equals K'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `nums = [1,1,1], k = 2`
- Output: `2`
@@ -64,7 +69,3 @@ 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,49 @@
---
title: '974. Subarray Sums Divisible by K'
description: Given an integer array nums and an integer k, return the number of non-empty subarrays that have a sum divisible by k
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Hash Table", slug: "hash-table" }
- { name: "Prefix Sum", slug: "prefix-sum" }
issue: "https://github.com/prdlk/leetcode/issues/40"
sidebar:
label: 'Subarray Sums Divisible by K'
badge: 'Medium'
---
### Example 1:
- Input: `nums = [4,5,0,-2,-3,1], k = 5`
- Output: `7`
- Explanation: There are `7` subarrays with a sum divisible by `k = 5`: [4, `5`, `0`, `-2`, `-3`, 1], `[5]`, [5, 0], [5, `0`, `-2`, -3], `[0]`, [0, `-2`, -3], [-2, -3]
### Example 2:
- Input: `nums = [5], k = 9`
- Output: `0`
### Constraints:
- `1 <= nums.length <= 3 * 10^4`
- `-10^4 <= nums[i] <= 10^4`
- `2 <= k <= 10^4`
## Solution
```py
class Solution:
def subarraysDivByK(self, nums: list[int], k: int) -> int:
remainder_counts = {0: 1}
cur_sum = 0
count = 0
for n in nums:
cur_sum += n
remainder = cur_sum % k
if remainder in remainder_counts:
count += remainder_counts[remainder]
remainder_counts[remainder] = remainder_counts.get(remainder, 0) + 1
return count
```
@@ -0,0 +1,45 @@
---
title: Prefix Sum
description: 'A running total is stored at each index, so the sum of any range is one subtraction.'
hidden: true
---
Suppose you are handed an array and asked for the sum of the elements between index `i` and index `j`. One query is easy: loop from `i` to `j` and add. But now imagine thousands of such queries over the same array, or a search that inspects the sum of every possible subarray. Recomputing each range from scratch costs `O(n)` per query, and the work piles up fast.
Prefix sum removes that repetition. You spend `O(n)` once to precompute running totals, and afterward every range sum collapses into a single subtraction that runs in `O(1)`, no matter how wide the range.
<YouTube id="CRVxUgmIO8M" title="Prefix Sum Explained" />
## Transformation
A prefix sum turns an array into a new array of running totals. For an input array `arr` of length `n`, build a `prefix` array of length `n + 1`:
`prefix[0] = 0`
`prefix[k] = arr[0] + arr[1] + ... + arr[k-1]`
Each entry is the previous total plus one more element, so a single pass builds the whole thing:
`prefix[k] = prefix[k-1] + arr[k-1]`
The leading `prefix[0] = 0` is the sum of zero elements. That extra slot is what makes the range math below land exactly on the boundaries, which is why `prefix` has one more cell than `arr`.
<Frame caption="Each bar is the running total up to that index. Every step keeps the previous total (blue) and adds one new element (amber)" hint="">
![](../../../public/prefix-sum_1.png)
</Frame>
## Any range sum is one subtraction
The sum of the subarray from index `i` up to (but not including) `j` is `prefix[j] - prefix[i]`. `prefix[j]` is the total of everything before `j`, and `prefix[i]` is the total of everything before `i`, so subtracting throws away the front you did not ask for and leaves the middle.
## Watch the boundaries
The one place prefix sums trip people up is the off-by-one at the edges. Keep three facts straight: `prefix` has length `n + 1`, `prefix[0]` is `0`, and `sum(arr[i..j-1]) = prefix[j] - prefix[i]` uses an exclusive right index `j`. If a problem hands you an inclusive right index `r`, translate it to `prefix[r + 1] - prefix[i]`.
## When to reach for it
Prefix sums fit whenever you need repeated range sums over a fixed array, or when an algorithm searches across subarray boundaries. For a single one-off sum, a plain loop is simpler; the prefix array earns its `O(n)` setup only once the same array is queried many times.
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Prefix Sum", order: 2, icon: "sigma", display: "group" });
@@ -0,0 +1,52 @@
---
title: '1004. Max Consecutive Ones III'
description: Given a binary array nums and an integer k, return the maximum number of consecutive 1's in the array if you can flip at most k 0's
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
- { name: "Sliding Window", slug: "sliding-window" }
- { name: "Prefix Sum", slug: "prefix-sum" }
issue: "https://github.com/prdlk/leetcode/issues/56"
sidebar:
label: 'Max Consecutive Ones III'
badge: 'Medium'
---
### Example 1:
- Input: `nums = [1,1,1,0,0,0,1,1,1,1,0], k = 2`
- Output: `6`
- Explanation: `[1,1,1,0,0,1,1,1,1,1,1]` Bolded numbers were flipped from `0` to `1`. The longest subarray is underlined.
### Example 2:
- Input: `nums = [0,0,1,1,0,0,1,1,1,0,1,1,0,0,0,1,1,1,1], k = 3`
- Output: `10`
- Explanation: `[0,0,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1]` Bolded numbers were flipped from `0` to `1`. The longest subarray is underlined.
### Constraints:
- `1 <= nums.length <= 10^5`
- `nums[i]` is either 0 or 1.
- `0 <= k <= nums.length`
## Solution
```py
class Solution:
def longestOnes(self, nums: list[int], k: int) -> int:
left = 0
cur = 0
ans = 0
for right in range(len(nums)):
if nums[right] == 0:
cur += 1
while cur > k:
if nums[left] == 0:
cur -= 1
left += 1
ans = max(ans, right - left + 1)
return ans
```
@@ -1,13 +1,17 @@
---
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
topics:
- { name: "Array", slug: "array" }
- { name: "Dynamic Programming", slug: "dynamic-programming" }
video: "1pkOgXD63yU"
issue: "https://github.com/prdlk/leetcode/issues/50"
sidebar:
label: 'Best Time to Buy and Sell Stock'
badge: 'Easy'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `prices = [7,1,5,3,6,4]`
- Output: `5`
@@ -54,7 +58,3 @@ 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,57 @@
---
title: '209. Minimum Size Subarray Sum'
description: Given an array of positive integers nums and a positive integer target, return the minimal length of a subarray whose sum is greater than or equal to target. If there is no such subarray, return 0 instead
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
- { name: "Sliding Window", slug: "sliding-window" }
- { name: "Prefix Sum", slug: "prefix-sum" }
issue: "https://github.com/prdlk/leetcode/issues/54"
sidebar:
label: 'Minimum Size Subarray Sum'
badge: 'Medium'
---
::::warning
If you have figured out the O(n) solution, try coding another solution of which the time complexity is O(n log(n)).
::::
### Example 1:
- Input: `target = 7, nums = [2,3,1,2,4,3]`
- Output: `2`
- Explanation: The subarray `[4,3]` has the minimal length under the problem constraint.
### Example 2:
- Input: `target = 4, nums = [1,4,4]`
- Output: `1`
### Example 3:
- Input: `target = 11, nums = [1,1,1,1,1,1,1,1]`
- Output: `0`
### Constraints:
- `1 <= target <= 10^9`
- `1 <= nums.length <= 10^5`
- `1 <= nums[i] <= 10^4`
## Solution
```py
class Solution:
def minSubArrayLen(self, target: int, nums: list[int]) -> int:
window_sum = 0
length = len(nums) + 1
left = 0
for right in range(len(nums)):
window_sum += nums[right]
while window_sum >= target:
length = min(length, right - left + 1)
window_sum -= nums[left]
left += 1
if length > len(nums):
return 0
return length
```
@@ -1,13 +1,18 @@
---
title: '3. Longest Substring Without Repeating Characters'
description: Given a string s, find the length of the longest substring without duplicate characters
icon: dot
topics:
- { name: "Hash Table", slug: "hash-table" }
- { name: "String", slug: "string" }
- { name: "Sliding Window", slug: "sliding-window" }
video: "wiGpQwVHdE0"
issue: "https://github.com/prdlk/leetcode/issues/51"
sidebar:
label: 'Longest Substring Without Repeating Characters'
badge: 'Medium'
---
<Badge variant="accent">Hash Table</Badge>
### Example 1:
- Input: `s = "abcabcbb"`
- Output: `3`
@@ -60,7 +65,3 @@ class Solution:
return ans
```
## Explanation
<YouTube id="wiGpQwVHdE0" title="Longest Substring Without Repeating Characters - Leetcode 3 - Python" />
@@ -1,13 +1,18 @@
---
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
topics:
- { name: "Hash Table", slug: "hash-table" }
- { name: "String", slug: "string" }
- { name: "Sliding Window", slug: "sliding-window" }
video: "gqXU1UyA8pk"
issue: "https://github.com/prdlk/leetcode/issues/52"
sidebar:
label: 'Longest Repeating Character Replacement'
badge: 'Medium'
---
<Badge variant="accent">Hash Table</Badge>
### Example 1:
- Input: `s = "ABAB", k = 2`
- Output: `4`
@@ -60,7 +65,3 @@ class Solution:
res = max(res, r - l + 1)
return res
```
## Explanation
<YouTube id="gqXU1UyA8pk" title="Longest Repeating Character Replacement - Leetcode 424 - Python" />
@@ -1,13 +1,19 @@
---
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
topics:
- { name: "Hash Table", slug: "hash-table" }
- { name: "Two Pointers", slug: "two-pointers" }
- { name: "String", slug: "string" }
- { name: "Sliding Window", slug: "sliding-window" }
video: "UbyhOgBN834"
issue: "https://github.com/prdlk/leetcode/issues/53"
sidebar:
label: 'Permutation in String'
badge: 'Medium'
---
<Badge variant="accent">Hash Table</Badge>
### Example 1:
- Input: `s1 = "ab", s2 = "eidbaooo"`
- Output: `true`
@@ -67,7 +73,3 @@ class Solution:
return False
```
## Explanation
<YouTube id="UbyhOgBN834" title="Permutation in String - Leetcode 567 - Python" />
@@ -0,0 +1,46 @@
---
title: '643. Maximum Average Subarray I'
description: You are given an integer array nums consisting of n elements, and an integer k
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Sliding Window", slug: "sliding-window" }
issue: "https://github.com/prdlk/leetcode/issues/55"
sidebar:
label: 'Maximum Average Subarray I'
badge: 'Easy'
---
### Example 1:
- Input: `nums = [1,12,-5,-6,50,3], k = 4`
- Output: `12.75000`
- Explanation: Maximum average is `(12 - 5 - 6 + 50`) / `4 = 51 / 4 = 12.75`
### Example 2:
- Input: `nums = [5], k = 1`
- Output: `5.00000`
### Constraints:
- `n == nums.length`
- `1 <= k <= n <= 10^5`
- `-10^4 <= nums[i] <= 10^4`
## Solution
```py
class Solution:
def findMaxAverage(self, nums: list[int], k: int) -> float:
window_sum = 0
for i in range(k):
window_sum += nums[i]
largest = window_sum
for right in range(k, len(nums)):
left = right - k
window_sum -= nums[left]
window_sum += nums[right]
largest = max(largest, window_sum)
return largest / k
```
@@ -0,0 +1,46 @@
---
title: '76. Minimum Window Substring'
description: Given two strings s and t of lengths m and n respectively, return the minimum window substring of s such that every character in t (including duplicates) is included in the window. If there is no such substring, return the empty string ""
icon: dot
topics:
- { name: "Hash Table", slug: "hash-table" }
- { name: "String", slug: "string" }
- { name: "Sliding Window", slug: "sliding-window" }
issue: "https://github.com/prdlk/leetcode/issues/57"
sidebar:
label: 'Minimum Window Substring'
badge: 'Hard'
---
::::warning
Could you find an algorithm that runs in O(m + n) time?
::::
### Example 1:
- Input: `s = "ADOBECODEBANC", t = "ABC"`
- Output: `"BANC"`
- Explanation: The minimum window substring "BANC" includes 'A', 'B', and 'C' from string `t`.
### Example 2:
- Input: `s = "a", t = "a"`
- Output: `"a"`
- Explanation: The entire string `s` is the minimum window.
### Example 3:
- Input: `s = "a", t = "aa"`
- Output: `""`
- Explanation: Both 'a's from `t` must be included in the window. Since the largest window of `s` only has one 'a', return empty string.
### Constraints:
- `m == s.length`
- `n == t.length`
- `1 <= m, n <= 10^5`
- `s` and `t` consist of uppercase and lowercase English letters.
## Solution
```py
class Solution:
def minWindow(self, s: str, t: str) -> str:
```
@@ -0,0 +1,214 @@
---
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
---
<YouTube id="tSTO9w3piAo" title="Sliding Window" />
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
:::tip
The rule for all three: every element enters once and leaves once, so the whole thing runs in O(n).
:::
<Accordion>
<AccordionItem title="What is an invariant?">
An invariant is a statement that stays true before and after every iteration. In a sliding window, that statement usually describes the window itself, such as “the window is valid,” “it has no duplicates,” or “its sum is at most the target.” It can also describe a helper structure, such as “the frequency map matches the characters currently inside the window.”
</AccordionItem>
<AccordionItem title="Why invariants matter?">
Without an invariant, shrinking the window is just a guess. You no longer know when the window is valid, when to update the answer, or why the left pointer should stop. The invariant is what makes the algorithm correct.
</AccordionItem>
<AccordionItem title="How sliding window differs from other two-pointer families?">
Sliding window is not the same as every other two-pointer pattern. In Container With Most Water, the interval is only the remaining search space. In sliding window, the interval itself is the thing you care about. In Remove Duplicates, the left side is already the finished answer prefix. In sliding window, both pointers describe a live window that is still being checked.
</AccordionItem>
</Accordion>
## 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.
:::
<Frame caption="Sliding Window Fixed" hint="">
![](../../../public/sliding-window-fixed-size.svg)
</Frame>
```python
def sliding_window_fixed(input, window_size):
ans = window = input[0:window_size]
for right in range(window_size, len(input)):
left = right - window_size
remove input[left] from window
append input[right] to window
ans = optimal(ans, window)
return ans
```
<Steps>
<Step title="">
Add `nums[right]` to the window.
</Step>
<Step title="">
If the window has `k` elements, record the answer.
</Step>
<Step title="">
Remove the leftmost element so the window stays at size `k`.
</Step>
</Steps>
## 2. Flexible-size windows with a validity rule
:::tip
Here the window expands until invalid, then shrinks until valid again.
:::
<Frame caption="Sliding Window Flexible" hint="">
![](../../../public/sliding-window-flexible.svg)
</Frame>
<Tabs inline>
<Tab title="Longest Window">
```python
def sliding_window_flexible_longest(input):
initialize window, ans
left = 0
for right in range(len(input)):
append input[right] to window
while invalid(window): # update left until window is valid again
remove input[left] from window
left += 1
ans = max(ans, window) # window is guaranteed to be valid here
return ans
```
</Tab>
<Tab title="Shortest Window">
```python
def sliding_window_flexible_shortest(input):
initialize window, ans
left = 0
for right in range(len(input)):
append input[right] to window
while valid(window):
ans = min(ans, window) # window is guaranteed to be valid here
remove input[left] from window
left += 1
return ans
```
</Tab>
</Tabs>
<Steps>
<Step title="">
Add `nums[right]` to the window.
</Step>
<Step title="">
While the window is invalid, remove `nums[left]` and move `left` up.
</Step>
<Step title="">
The window is now valid. Record its size.
</Step>
</Steps>
---
## 3. Frequency-tracking windows
:::tip
The difficulty comes from keeping the bookkeeping correct as characters enter and leave.
:::
<Frame caption="Frequency-tracking windows" hint="">
![](../../../public/sliding-window-frequency.svg)
</Frame>
```python
from collections import defaultdict
def frequency_window(s):
left = 0
counts = defaultdict(int)
best = 0
for right in range(len(s)):
counts[s[right]] += 1 # character enters
while counts[s[right]] > 1: # rule: no repeats
counts[s[left]] -= 1 # character leaves
left += 1
best = max(best, right - left + 1)
return best
```
<Steps>
<Step title="">
Add `s[right]` to the window.
</Step>
<Step title="">
While the window has more than one character, remove `s[left]` and move `left` up.
</Step>
<Step title="">
The window is now valid. Record its size.
</Step>
</Steps>
---
For anagram or "minimum window substring" problems, you also keep a target count. Track how many characters currently match the target with one integer (`matched`). Update it only when a count crosses the target value. This keeps each step `O(1)`.
```python
def anagram_window(s, p):
need = defaultdict(int)
for c in p:
need[c] += 1
counts = defaultdict(int)
matched = 0 # how many chars have counts[c] == need[c]
result = []
k = len(p)
for right in range(len(s)):
c = s[right]
counts[c] += 1
if counts[c] == need[c]:
matched += 1
if right >= k: # window too big, remove left
d = s[right - k]
if counts[d] == need[d]:
matched -= 1
counts[d] -= 1
if matched == len(need):
result.append(right - k + 1)
return result
```
@@ -0,0 +1,3 @@
import { defineMeta } from "blume";
export default defineMeta({ title: "Sliding Window", order: 4, icon: "app-window", display: "group" });
@@ -1,13 +1,18 @@
---
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
topics:
- { name: "Array", slug: "array" }
- { name: "Math", slug: "math" }
- { name: "Stack", slug: "stack" }
video: "iu0082c4HDE"
issue: "https://github.com/prdlk/leetcode/issues/69"
sidebar:
label: 'Evaluate Reverse Polish Notation'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `tokens = ["2","1","+","3","*"]`
- Output: `9`
@@ -68,7 +73,3 @@ class Solution:
return stack[0]
```
## Explanation
<YouTube id="iu0082c4HDE" title="Evaluate Reverse Polish Notation - Leetcode 150 - Python" />
@@ -1,13 +1,17 @@
---
title: '155. Min Stack'
description: Design a stack that supports push, pop, top, and retrieving the minimum element in constant time
icon: dot
topics:
- { name: "Stack", slug: "stack" }
- { name: "Design", slug: "design" }
video: "qkLl7nAwDPo"
issue: "https://github.com/prdlk/leetcode/issues/68"
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.
::::
@@ -76,7 +80,3 @@ class MinStack:
# param_3 = obj.top()
# param_4 = obj.getMin()
```
## Explanation
<YouTube id="qkLl7nAwDPo" title="Design Min Stack - Amazon Interview Question - Leetcode 155 - Python" />
@@ -1,13 +1,18 @@
---
title: '20. Valid Parentheses'
description: Given a string s containing just the characters '(', ')', '{', '}', '[' and ']', determine if the input string is valid
icon: dot
topics:
- { name: "String", slug: "string" }
- { name: "Stack", slug: "stack" }
- { name: "Bracket Sequences", slug: "bracket-sequences" }
video: "WTzjTskDFMg"
issue: "https://github.com/prdlk/leetcode/issues/67"
sidebar:
label: 'Valid Parentheses'
badge: 'Easy'
---
<Badge variant="accent">String</Badge>
### Example 1:
- Input: `s = "()"`
- Output: `true`
@@ -70,7 +75,3 @@ class Solution:
return True if not stack else False
```
## Explanation
<YouTube id="WTzjTskDFMg" title="Valid Parentheses - Stack - Leetcode 20 - Python" />
@@ -0,0 +1,43 @@
---
title: '224. Basic Calculator'
description: Given a string s representing a valid expression, implement a basic calculator to evaluate it, and return the result of the evaluation
icon: dot
topics:
- { name: "Math", slug: "math" }
- { name: "String", slug: "string" }
- { name: "Stack", slug: "stack" }
- { name: "Recursion", slug: "recursion" }
issue: "https://github.com/prdlk/leetcode/issues/71"
sidebar:
label: 'Basic Calculator'
badge: 'Hard'
---
### Example 1:
- Input: `s = "1 + 1"`
- Output: `2`
### Example 2:
- Input: `s = " 2-1 + 2 "`
- Output: `3`
### Example 3:
- Input: `s = "(1+(4+5+2)-3)+(6+8)"`
- Output: `23`
### Constraints:
- `1 <= s.length <= 3 * 10^5`
- `s` consists of digits, '+', '-', '(', ')', and ' '.
- `s` represents a valid expression.
- '+' is not used as a unary operation (i.e., "+1" and "+(2 + 3)" is invalid).
- '-' could be used as a unary operation (i.e., "-1" and "-(2 + 3)" is valid).
- There will be no two consecutive operators in the input.
- Every number and running calculation will fit in a signed 32-bit integer.
## Solution
```py
class Solution:
def calculate(self, s: str) -> int:
```
@@ -0,0 +1,62 @@
---
title: '71. Simplify Path'
description: You are given an absolute path for a Unix-style file system, which always begins with a slash '/'. Your task is to transform this absolute path into its simplified canonical path
icon: dot
topics:
- { name: "String", slug: "string" }
- { name: "Stack", slug: "stack" }
issue: "https://github.com/prdlk/leetcode/issues/70"
sidebar:
label: 'Simplify Path'
badge: 'Medium'
---
### Example 1:
- Input: `path = "/home/"`
- Output: `"/home"`
- Explanation: The trailing slash should be removed.
### Example 2:
- Input: `path = "/home//foo/"`
- Output: `"/home/foo"`
- Explanation: Multiple consecutive slashes are replaced by a single one.
### Example 3:
- Input: `path = "/home/user/Documents/../Pictures"`
- Output: `"/home/user/Pictures"`
- Explanation: A double period ".." refers to the directory up a level (the parent directory).
### Example 4:
- Input: `path = "/../"`
- Output: `"/"`
- Explanation: Going one level up from the root directory is not possible.
### Example 5:
- Input: `path = "/.../a/../b/c/../d/./"`
- Output: `"/.../b/d"`
- Explanation: "..." is a valid name for a directory in this problem.
### Constraints:
- `1 <= path.length <= 3000`
- `path` consists of English letters, digits, period '.', slash '/' or '_'.
- `path` is a valid absolute Unix path.
## Solution
```py
class Solution:
def simplifyPath(self, path: str) -> str:
stack = []
segments = [item for item in path.split("/") if item]
for p in segments:
if stack and p == "..":
stack.pop()
elif not stack and p == ".." or p == ".":
continue
else:
stack.append(p)
return "/" + "/".join(stack)
```
Loaded 100 of 338 files, more files were not shown because too many files have changed in this diff. Show more