mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
mise ~/.config/mise/config.toml tools: crush@0.91.0
feat(api): add React Email rendering and PNG heatmap endpoint
This commit is contained in:
@@ -4,7 +4,14 @@
|
|||||||
"workspaces": {
|
"workspaces": {
|
||||||
"": {
|
"": {
|
||||||
"name": "srs-api",
|
"name": "srs-api",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-email/components": "^1.0.12",
|
||||||
|
"@react-email/render": "^2.1.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
"wrangler": "^4.125.0",
|
"wrangler": "^4.125.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -146,36 +153,124 @@
|
|||||||
|
|
||||||
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
||||||
|
|
||||||
|
"@react-email/body": ["@react-email/body@0.3.0", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-uGo0BOOzjbMUo3lu+BIDWayvn5o6Xyfmnlla5VGf05n8gHMvO1ll7U4FtzWe3hxMLwt53pmc4iE0M+B5slG+Ug=="],
|
||||||
|
|
||||||
|
"@react-email/button": ["@react-email/button@0.2.1", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A=="],
|
||||||
|
|
||||||
|
"@react-email/code-block": ["@react-email/code-block@0.2.1", "", { "dependencies": { "prismjs": "^1.30.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw=="],
|
||||||
|
|
||||||
|
"@react-email/code-inline": ["@react-email/code-inline@0.0.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA=="],
|
||||||
|
|
||||||
|
"@react-email/column": ["@react-email/column@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg=="],
|
||||||
|
|
||||||
|
"@react-email/components": ["@react-email/components@1.0.12", "", { "dependencies": { "@react-email/body": "0.3.0", "@react-email/button": "0.2.1", "@react-email/code-block": "0.2.1", "@react-email/code-inline": "0.0.6", "@react-email/column": "0.0.14", "@react-email/container": "0.0.16", "@react-email/font": "0.0.10", "@react-email/head": "0.0.13", "@react-email/heading": "0.0.16", "@react-email/hr": "0.0.12", "@react-email/html": "0.0.12", "@react-email/img": "0.0.12", "@react-email/link": "0.0.13", "@react-email/markdown": "0.0.18", "@react-email/preview": "0.0.14", "@react-email/render": "2.0.6", "@react-email/row": "0.0.13", "@react-email/section": "0.0.17", "@react-email/tailwind": "2.0.7", "@react-email/text": "0.1.6" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-tH18JhPDWgE+3jnYkzyB6ZrZdfNnEsFe4PwmuXmlOw4NGIysP8wPY5aXZg++pTG9qUabXg1nzX/FGHGkObH8xQ=="],
|
||||||
|
|
||||||
|
"@react-email/container": ["@react-email/container@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ=="],
|
||||||
|
|
||||||
|
"@react-email/font": ["@react-email/font@0.0.10", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA=="],
|
||||||
|
|
||||||
|
"@react-email/head": ["@react-email/head@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog=="],
|
||||||
|
|
||||||
|
"@react-email/heading": ["@react-email/heading@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw=="],
|
||||||
|
|
||||||
|
"@react-email/hr": ["@react-email/hr@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA=="],
|
||||||
|
|
||||||
|
"@react-email/html": ["@react-email/html@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw=="],
|
||||||
|
|
||||||
|
"@react-email/img": ["@react-email/img@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ=="],
|
||||||
|
|
||||||
|
"@react-email/link": ["@react-email/link@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw=="],
|
||||||
|
|
||||||
|
"@react-email/markdown": ["@react-email/markdown@0.0.18", "", { "dependencies": { "marked": "^15.0.12" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg=="],
|
||||||
|
|
||||||
|
"@react-email/preview": ["@react-email/preview@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw=="],
|
||||||
|
|
||||||
|
"@react-email/render": ["@react-email/render@2.1.0", "", { "dependencies": { "entities": "^4.5.0", "html-to-text": "^9.0.5", "html5parser": "^3.0.0", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-F+zE3O6d6sW6Aj2UjvZAA17R7tJKM7kcq2mgV6k4HCT8jeLLFaVP2txMtH1lgqYFRMZ0Gxsd37q2PRyiXLXXxA=="],
|
||||||
|
|
||||||
|
"@react-email/row": ["@react-email/row@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw=="],
|
||||||
|
|
||||||
|
"@react-email/section": ["@react-email/section@0.0.17", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w=="],
|
||||||
|
|
||||||
|
"@react-email/tailwind": ["@react-email/tailwind@2.0.7", "", { "dependencies": { "tailwindcss": "^4.1.18" }, "peerDependencies": { "@react-email/body": ">=0", "@react-email/button": ">=0", "@react-email/code-block": ">=0", "@react-email/code-inline": ">=0", "@react-email/container": ">=0", "@react-email/heading": ">=0", "@react-email/hr": ">=0", "@react-email/img": ">=0", "@react-email/link": ">=0", "@react-email/preview": ">=0", "@react-email/text": ">=0", "react": "^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@react-email/body", "@react-email/button", "@react-email/code-block", "@react-email/code-inline", "@react-email/container", "@react-email/heading", "@react-email/hr", "@react-email/img", "@react-email/link", "@react-email/preview"] }, "sha512-kGw80weVFXikcnCXbigTGXGWQ0MRCSYNCudcdkWxebkWYd0FG6/NPoN3V1p/u68/4+NxZwYPVi2fhnp0x23HdA=="],
|
||||||
|
|
||||||
|
"@react-email/text": ["@react-email/text@0.1.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw=="],
|
||||||
|
|
||||||
|
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
|
||||||
|
|
||||||
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||||
|
|
||||||
"@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="],
|
"@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="],
|
||||||
|
|
||||||
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||||
|
|
||||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||||
|
|
||||||
|
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
|
|
||||||
|
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||||
|
|
||||||
|
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||||
|
|
||||||
|
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||||
|
|
||||||
|
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||||
|
|
||||||
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||||
|
|
||||||
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
|
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
|
||||||
|
|
||||||
|
"html5parser": ["html5parser@3.0.0", "", {}, "sha512-iNpSopa+4YHX50UOk825tBy7MghmXHo/ZpLskBYN0kAr1xhH8GlIMk5bLRXcZlfP3AnLUcSuFMu8C4MdOUxA8A=="],
|
||||||
|
|
||||||
|
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
|
||||||
|
|
||||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||||
|
|
||||||
|
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
|
||||||
|
|
||||||
|
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
|
||||||
|
|
||||||
"miniflare": ["miniflare@5.20260820.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260820.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-Bv1j2kcKKNwXLWuCx+j0xGt7z318mqQkJmEN6ellM9sCESbPBDTM9ofZMbKqx47jSnoGA3CiaUkAmzGVXUa/wQ=="],
|
"miniflare": ["miniflare@5.20260820.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260820.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-Bv1j2kcKKNwXLWuCx+j0xGt7z318mqQkJmEN6ellM9sCESbPBDTM9ofZMbKqx47jSnoGA3CiaUkAmzGVXUa/wQ=="],
|
||||||
|
|
||||||
|
"parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
|
||||||
|
|
||||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||||
|
|
||||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||||
|
|
||||||
|
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
|
||||||
|
|
||||||
|
"prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
|
||||||
|
|
||||||
|
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
||||||
|
|
||||||
|
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
|
||||||
|
|
||||||
|
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
|
||||||
|
|
||||||
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
|
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
|
||||||
|
|
||||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||||
|
|
||||||
"sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="],
|
"sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="],
|
||||||
|
|
||||||
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||||
|
|
||||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||||
@@ -191,5 +286,7 @@
|
|||||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||||
|
|
||||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||||
|
|
||||||
|
"@react-email/components/@react-email/render": ["@react-email/render@2.0.6", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-xOzaYkH3jLZKqN5MqrTXYnmqBYUnZSVbkxdb5PGGmDcK6sKDVMliaDiSwfXajRC9JtSHTcGc2tmGLHWuCgVpog=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
"test": "bun test src"
|
"test": "bun test src"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
"wrangler": "^4.125.0"
|
"wrangler": "^4.125.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-email/components": "^1.0.12",
|
||||||
|
"@react-email/render": "^2.1.0",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+164
-47
@@ -1,17 +1,21 @@
|
|||||||
/**
|
/**
|
||||||
* Hand-rolled SVG chart generation for the SRS Worker.
|
* Hand-rolled SVG chart generation for the SRS Worker.
|
||||||
*
|
*
|
||||||
* Feeds four endpoints — /chart/progress.svg, /chart/ladder.svg,
|
* Feeds five endpoints — /chart/progress.svg, /chart/ladder.svg,
|
||||||
* /chart/heatmap.svg, /badge/gate.svg — each served with
|
* /chart/heatmap.svg, /chart/heatmap.png, /badge/gate.svg — each served with
|
||||||
* `Cache-Control: public, max-age=300`.
|
* `Cache-Control: public, max-age=300`. The heatmap has a raster twin because
|
||||||
|
* every major email client blocks SVG, so the digest cannot embed the vector.
|
||||||
*
|
*
|
||||||
* Everything is string-built: no chart library, no runtime imports. The only
|
* The SVG is string-built: no chart library, no dependencies, and the one
|
||||||
* dynamic values entering the markup are numbers, percentages, and dates the
|
* import is the hand-rolled PNG encoder next door. The only dynamic values
|
||||||
* Worker itself computes, plus the fixed phase/stage labels — so nothing here
|
* entering the markup are numbers, percentages, and dates the Worker itself
|
||||||
* needs XML escaping. Styling is shadcn dark zinc to match the README's
|
* computes, plus the fixed phase/stage labels — so nothing here needs XML
|
||||||
* shieldcn badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub
|
* escaping. Styling is shadcn dark zinc to match the README's shieldcn
|
||||||
* dark-mode green ramp. Every function renders valid SVG on a zero-row DB.
|
* badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode
|
||||||
|
* green ramp. Every function renders on a zero-row DB.
|
||||||
*/
|
*/
|
||||||
|
import { Canvas, textWidth } from "./png.ts";
|
||||||
|
|
||||||
// D1Database comes from the generated worker-configuration.d.ts runtime types.
|
// D1Database comes from the generated worker-configuration.d.ts runtime types.
|
||||||
|
|
||||||
const FONT = "Verdana,DejaVu Sans,sans-serif";
|
const FONT = "Verdana,DejaVu Sans,sans-serif";
|
||||||
@@ -195,9 +199,43 @@ export async function ladderChart(db: D1Database): Promise<string> {
|
|||||||
|
|
||||||
const DAY_MS = 86_400_000;
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
export async function heatmapChart(db: D1Database, start: string, days: number): Promise<string> {
|
// Layout in CSS pixels. heatmapPng multiplies every one of these by its device
|
||||||
// YYYY-MM-DD → UTC midnight; all per-day dates derive from this timestamp,
|
// scale, so the vector and raster pictures cannot drift apart.
|
||||||
// so local-timezone drift never shifts a cell.
|
const HEAT_W = 560;
|
||||||
|
const HEAT_H = 160;
|
||||||
|
const HEAT_GRID_X = 34;
|
||||||
|
const HEAT_GRID_Y = 24;
|
||||||
|
const HEAT_CELL = 16;
|
||||||
|
const HEAT_STEP = 19; // cell + 3px gap
|
||||||
|
|
||||||
|
// Grid row → left-hand label; both renderers print only these three.
|
||||||
|
const WEEKDAYS: [number, string][] = [
|
||||||
|
[0, "Mon"],
|
||||||
|
[2, "Wed"],
|
||||||
|
[4, "Fri"],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** One grid square: column, Mon-based row, and index into GREENS. */
|
||||||
|
interface HeatmapCell {
|
||||||
|
col: number;
|
||||||
|
row: number;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The heatmap's whole data model — one cell per campaign day, plus the week
|
||||||
|
* count that positions the legend — shared by both renderers.
|
||||||
|
*
|
||||||
|
* Every per-day date derives from one UTC-midnight timestamp, so local-timezone
|
||||||
|
* drift never shifts a cell. The campaign starts on a Monday, so day i sits at
|
||||||
|
* column i/7; the row comes from the real weekday, so an off-Monday start still
|
||||||
|
* lands correctly.
|
||||||
|
*/
|
||||||
|
async function heatmapCells(
|
||||||
|
db: D1Database,
|
||||||
|
start: string,
|
||||||
|
days: number,
|
||||||
|
): Promise<{ cells: HeatmapCell[]; weeks: number }> {
|
||||||
const [sy, sm, sd] = start.split("-").map(Number);
|
const [sy, sm, sd] = start.split("-").map(Number);
|
||||||
const base = Date.UTC(sy!, sm! - 1, sd!);
|
const base = Date.UTC(sy!, sm! - 1, sd!);
|
||||||
const end = new Date(base + (days - 1) * DAY_MS).toISOString().slice(0, 10);
|
const end = new Date(base + (days - 1) * DAY_MS).toISOString().slice(0, 10);
|
||||||
@@ -209,56 +247,135 @@ export async function heatmapChart(db: D1Database, start: string, days: number):
|
|||||||
|
|
||||||
const byDate = new Map(results.map((r) => [r.date, r.attempts]));
|
const byDate = new Map(results.map((r) => [r.date, r.attempts]));
|
||||||
|
|
||||||
const width = 560;
|
const cells: HeatmapCell[] = [];
|
||||||
const height = 160;
|
|
||||||
const gridX = 34;
|
|
||||||
const gridY = 24;
|
|
||||||
const cell = 16;
|
|
||||||
const step = 19; // cell + 3px gap
|
|
||||||
const weeks = Math.ceil(days / 7);
|
|
||||||
|
|
||||||
const parts = [svgOpen(width, height)];
|
|
||||||
|
|
||||||
// Week numbers across the top, Mon/Wed/Fri down the left.
|
|
||||||
for (let w = 0; w < weeks; w++) {
|
|
||||||
parts.push(text(gridX + w * step + cell / 2, gridY - 7, `W${w + 1}`, { size: 10, fill: MUTED, anchor: "middle" }));
|
|
||||||
}
|
|
||||||
const weekdays: [number, string][] = [
|
|
||||||
[0, "Mon"],
|
|
||||||
[2, "Wed"],
|
|
||||||
[4, "Fri"],
|
|
||||||
];
|
|
||||||
for (const [row, label] of weekdays) {
|
|
||||||
parts.push(text(gridX - 6, gridY + row * step + cell - 4, label, { size: 10, fill: MUTED, anchor: "end" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// One cell per campaign day; the start date is a Monday, so day i sits at
|
|
||||||
// column i/7, row i%7 — but the row is derived from the real weekday so an
|
|
||||||
// off-Monday start still lands correctly.
|
|
||||||
for (let i = 0; i < days; i++) {
|
for (let i = 0; i < days; i++) {
|
||||||
const day = new Date(base + i * DAY_MS);
|
const day = new Date(base + i * DAY_MS);
|
||||||
const attempts = byDate.get(day.toISOString().slice(0, 10)) ?? 0;
|
const attempts = byDate.get(day.toISOString().slice(0, 10)) ?? 0;
|
||||||
const col = Math.floor(i / 7);
|
cells.push({
|
||||||
const row = (day.getUTCDay() + 6) % 7; // Mon = 0
|
col: Math.floor(i / 7),
|
||||||
const color = GREENS[Math.min(attempts, 4)];
|
row: (day.getUTCDay() + 6) % 7, // Mon = 0
|
||||||
|
level: Math.min(attempts, 4),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cells, weeks: Math.ceil(days / 7) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function heatmapChart(db: D1Database, start: string, days: number): Promise<string> {
|
||||||
|
const { cells, weeks } = await heatmapCells(db, start, days);
|
||||||
|
|
||||||
|
const parts = [svgOpen(HEAT_W, HEAT_H)];
|
||||||
|
|
||||||
|
// Week numbers across the top, Mon/Wed/Fri down the left.
|
||||||
|
for (let w = 0; w < weeks; w++) {
|
||||||
parts.push(
|
parts.push(
|
||||||
`<rect x="${gridX + col * step}" y="${gridY + row * step}" width="${cell}" height="${cell}" rx="2" fill="${color}"/>`,
|
text(HEAT_GRID_X + w * HEAT_STEP + HEAT_CELL / 2, HEAT_GRID_Y - 7, `W${w + 1}`, {
|
||||||
|
size: 10,
|
||||||
|
fill: MUTED,
|
||||||
|
anchor: "middle",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const [row, label] of WEEKDAYS) {
|
||||||
|
parts.push(
|
||||||
|
text(HEAT_GRID_X - 6, HEAT_GRID_Y + row * HEAT_STEP + HEAT_CELL - 4, label, {
|
||||||
|
size: 10,
|
||||||
|
fill: MUTED,
|
||||||
|
anchor: "end",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { col, row, level } of cells) {
|
||||||
|
parts.push(
|
||||||
|
`<rect x="${HEAT_GRID_X + col * HEAT_STEP}" y="${HEAT_GRID_Y + row * HEAT_STEP}" width="${HEAT_CELL}" height="${HEAT_CELL}" rx="2" fill="${GREENS[level]}"/>`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Less → More ramp fills the space right of the grid.
|
// Less → More ramp fills the space right of the grid.
|
||||||
const legendX = gridX + weeks * step + 40;
|
const legendX = HEAT_GRID_X + weeks * HEAT_STEP + 40;
|
||||||
const legendY = gridY + 3 * step;
|
const legendY = HEAT_GRID_Y + 3 * HEAT_STEP;
|
||||||
parts.push(text(legendX - 6, legendY + cell - 4, "Less", { size: 10, fill: MUTED, anchor: "end" }));
|
parts.push(text(legendX - 6, legendY + HEAT_CELL - 4, "Less", { size: 10, fill: MUTED, anchor: "end" }));
|
||||||
GREENS.forEach((color, i) => {
|
GREENS.forEach((color, i) => {
|
||||||
parts.push(`<rect x="${legendX + i * step}" y="${legendY}" width="${cell}" height="${cell}" rx="2" fill="${color}"/>`);
|
parts.push(
|
||||||
|
`<rect x="${legendX + i * HEAT_STEP}" y="${legendY}" width="${HEAT_CELL}" height="${HEAT_CELL}" rx="2" fill="${color}"/>`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
parts.push(text(legendX + GREENS.length * step + 3, legendY + cell - 4, "More", { size: 10, fill: MUTED }));
|
parts.push(text(legendX + GREENS.length * HEAT_STEP + 3, legendY + HEAT_CELL - 4, "More", { size: 10, fill: MUTED }));
|
||||||
|
|
||||||
parts.push("</svg>");
|
parts.push("</svg>");
|
||||||
return parts.join("");
|
return parts.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The PNG carries no alpha channel, so whatever the rounded card does not cover
|
||||||
|
// has to be painted with the colour sitting behind the image: the digest
|
||||||
|
// email's GitHub-dark card.
|
||||||
|
const EMAIL_CARD = "#0d1117";
|
||||||
|
|
||||||
|
// Device-pixel multiplier. The email hands the image the full 552px card
|
||||||
|
// width, so 3x lands a little over 2x density on a retina screen; the labels
|
||||||
|
// stay 10 CSS px, the size of the SVG's. Flat colour compresses to a few KB
|
||||||
|
// either way, so the extra resolution is close to free.
|
||||||
|
const HEAT_SCALE = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same picture as heatmapChart, rastered for email.
|
||||||
|
*
|
||||||
|
* Two deliberate departures from the SVG. The canvas is trimmed to the width
|
||||||
|
* the content actually occupies instead of HEAT_W: the README's SVG sits in a
|
||||||
|
* narrow table cell and scales up, whereas the email gives the image the full
|
||||||
|
* 552px card, and the SVG's slack right margin would otherwise shrink the
|
||||||
|
* grid to nothing. And the bitmap font hangs off a top-left origin rather than
|
||||||
|
* a baseline, so each SVG baseline becomes "baseline minus one cap height".
|
||||||
|
*/
|
||||||
|
export async function heatmapPng(db: D1Database, start: string, days: number): Promise<Uint8Array> {
|
||||||
|
const { cells, weeks } = await heatmapCells(db, start, days);
|
||||||
|
|
||||||
|
const s = HEAT_SCALE;
|
||||||
|
const gridX = HEAT_GRID_X * s;
|
||||||
|
const gridY = HEAT_GRID_Y * s;
|
||||||
|
const cell = HEAT_CELL * s;
|
||||||
|
const step = HEAT_STEP * s;
|
||||||
|
const capH = 7 * s;
|
||||||
|
|
||||||
|
const legendX = gridX + weeks * step + 40 * s;
|
||||||
|
const legendY = gridY + 3 * step;
|
||||||
|
const moreX = legendX + GREENS.length * step + 3 * s;
|
||||||
|
const width = moreX + textWidth("More", s) + gridX;
|
||||||
|
const height = HEAT_H * s;
|
||||||
|
|
||||||
|
const canvas = new Canvas(width, height, EMAIL_CARD);
|
||||||
|
canvas.rect(0, 0, width, height, BG, 6 * s);
|
||||||
|
|
||||||
|
for (let w = 0; w < weeks; w++) {
|
||||||
|
const label = `W${w + 1}`;
|
||||||
|
const middle = gridX + w * step + cell / 2;
|
||||||
|
canvas.text(middle - textWidth(label, s) / 2, gridY - 7 * s - capH, label, MUTED, s);
|
||||||
|
}
|
||||||
|
for (const [row, label] of WEEKDAYS) {
|
||||||
|
canvas.text(
|
||||||
|
gridX - 6 * s - textWidth(label, s),
|
||||||
|
gridY + row * step + cell - 4 * s - capH,
|
||||||
|
label,
|
||||||
|
MUTED,
|
||||||
|
s,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { col, row, level } of cells) {
|
||||||
|
canvas.rect(gridX + col * step, gridY + row * step, cell, cell, GREENS[level]!, 2 * s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legendBase = legendY + cell - 4 * s - capH;
|
||||||
|
canvas.text(legendX - 6 * s - textWidth("Less", s), legendBase, "Less", MUTED, s);
|
||||||
|
GREENS.forEach((color, i) => {
|
||||||
|
canvas.rect(legendX + i * step, legendY, cell, cell, color, 2 * s);
|
||||||
|
});
|
||||||
|
canvas.text(moreX, legendBase, "More", MUTED, s);
|
||||||
|
|
||||||
|
return await canvas.encode();
|
||||||
|
}
|
||||||
|
|
||||||
// ── gate badge: shieldcn-style dark pill ─────────────────────────
|
// ── gate badge: shieldcn-style dark pill ─────────────────────────
|
||||||
|
|
||||||
export async function gateBadge(db: D1Database): Promise<string> {
|
export async function gateBadge(db: D1Database): Promise<string> {
|
||||||
|
|||||||
+74
-120
@@ -1,10 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* The daily digest email — built from D1 + the bundled schedule, sent at
|
* The daily digest email — built from D1 + the bundled schedule, sent at
|
||||||
* 8 AM ET via the send_email binding. Plain inline-styled HTML that renders
|
* 8 AM ET via the send_email binding. This module owns data only: it turns
|
||||||
* clean in Gmail mobile; always sent with a text alternative.
|
* D1 rows into a `DigestData` and hands it to email.tsx, which renders both
|
||||||
|
* the HTML and the plain-text alternative from that single tree (React Email
|
||||||
|
* `render`), so the two can never drift.
|
||||||
*
|
*
|
||||||
* Retrieval rules: review and drill lines carry number + difficulty only —
|
* Retrieval rules: review and drill rows carry number + difficulty only —
|
||||||
* never the topic, never a solution link. The learning day's core list is
|
* never the topic, never a solution link. `DigestRow` has no field for
|
||||||
|
* either, so the rule holds by construction. The learning day's core list is
|
||||||
* the only labeled section. Reviews + drills ≤ 6, reviews first, overflow
|
* the only labeled section. Reviews + drills ≤ 6, reviews first, overflow
|
||||||
* simply stays due (oldest tomorrow). Sunday is a two-line rest note.
|
* simply stays due (oldest tomorrow). Sunday is a two-line rest note.
|
||||||
*
|
*
|
||||||
@@ -26,62 +29,58 @@ import {
|
|||||||
streak,
|
streak,
|
||||||
weekdayOf,
|
weekdayOf,
|
||||||
} from "./srs.ts";
|
} from "./srs.ts";
|
||||||
|
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
|
||||||
import { signLink } from "./links.ts";
|
import { signLink } from "./links.ts";
|
||||||
|
|
||||||
const DAILY_CAP = 6;
|
const DAILY_CAP = 6;
|
||||||
|
|
||||||
function cap(difficulty: string): string {
|
|
||||||
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Digest {
|
export interface Digest {
|
||||||
subject: string;
|
subject: string;
|
||||||
html: string;
|
html: string;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One-tap pass/fail anchor pair for a problem line. */
|
/** A problem row plus its signed one-tap pass/fail URLs. */
|
||||||
async function tapLinks(env: Env, lc: number, date: string): Promise<string> {
|
async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): Promise<DigestRow> {
|
||||||
const pass = await signLink(env.LINK_KEY, lc, "pass", date);
|
const [pass, fail] = await Promise.all([
|
||||||
const fail = await signLink(env.LINK_KEY, lc, "fail", date);
|
signLink(env.LINK_KEY, p.lc_number, "pass", date),
|
||||||
const url = (r: string, sig: string) =>
|
signLink(env.LINK_KEY, p.lc_number, "fail", date),
|
||||||
`${env.PUBLIC_URL}/log?p=${lc}&r=${r}&d=${date}&sig=${sig}`;
|
]);
|
||||||
return (
|
const base = `${env.PUBLIC_URL}/log?p=${p.lc_number}&d=${date}&r=`;
|
||||||
`<a href="${url("pass", pass)}" style="color:#1a7f37;font-weight:bold">pass</a>` +
|
return {
|
||||||
` · <a href="${url("fail", fail)}" style="color:#cf222e;font-weight:bold">fail</a>`
|
lc: p.lc_number,
|
||||||
);
|
difficulty: p.difficulty,
|
||||||
|
...(staged ? { stage: p.stage } : {}),
|
||||||
|
passUrl: `${base}pass&sig=${pass}`,
|
||||||
|
failUrl: `${base}fail&sig=${fail}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildDigest(env: Env, date: string): Promise<Digest> {
|
/** Everything the email needs, read straight out of D1 and the schedule. */
|
||||||
|
export async function collectDigest(env: Env, date: string): Promise<DigestData> {
|
||||||
const db = env.DB;
|
const db = env.DB;
|
||||||
const day = campaignDay(date);
|
const rest = weekdayOf(date) === 0;
|
||||||
const week = campaignWeek(date);
|
|
||||||
const header = `${prettyDate(date)} — Day ${day}/${CAMPAIGN_DAYS} · Week ${week}`;
|
|
||||||
|
|
||||||
if (weekdayOf(date) === 0) {
|
// Reviews take the cap first; drills fill whatever is left.
|
||||||
return {
|
const due = rest ? [] : await dueReviews(db, date);
|
||||||
subject: `SRS — ${prettyDate(date)} — rest day`,
|
const capped = due.slice(0, DAILY_CAP);
|
||||||
html: `<p><strong>${header}</strong></p><p>Rest day. Nothing is due. Overdue reviews moved to Monday.</p>`,
|
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - capped.length);
|
||||||
text: `${header}\nRest day. Nothing is due. Overdue reviews moved to Monday.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const due = await dueReviews(db, date);
|
const data: DigestData = {
|
||||||
const reviews = due.slice(0, DAILY_CAP);
|
day: prettyDate(date),
|
||||||
const carried = due.length - reviews.length;
|
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
|
||||||
const drills = await pickDrills(db, date, DAILY_CAP - reviews.length);
|
streak: await streak(db, date),
|
||||||
const currentStreak = await streak(db, date);
|
rest,
|
||||||
|
reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))),
|
||||||
|
carried: due.length - capped.length,
|
||||||
|
drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))),
|
||||||
|
yesterday: { total: 0, failed: [] },
|
||||||
|
heatmapUrl: `${env.PUBLIC_URL}/chart/heatmap.png`,
|
||||||
|
progressUrl: `${env.DOCS_URL}/progress`,
|
||||||
|
};
|
||||||
|
|
||||||
const htmlParts: string[] = [];
|
// New topic — the only section allowed to name problems and link them.
|
||||||
const textParts: string[] = [];
|
const topicIssue = rest ? undefined : SCHEDULE[date];
|
||||||
htmlParts.push(
|
|
||||||
`<h2 style="margin:0 0 4px">${header}</h2>`,
|
|
||||||
`<p style="margin:0 0 16px;color:#57606a">🔥 streak: ${currentStreak} day${currentStreak === 1 ? "" : "s"}</p>`,
|
|
||||||
);
|
|
||||||
textParts.push(header, `streak: ${currentStreak}`);
|
|
||||||
|
|
||||||
// New topic — the only labeled section.
|
|
||||||
const topicIssue = SCHEDULE[date];
|
|
||||||
if (topicIssue !== undefined) {
|
if (topicIssue !== undefined) {
|
||||||
const topic = await db
|
const topic = await db
|
||||||
.prepare("SELECT name FROM topics WHERE issue = ?")
|
.prepare("SELECT name FROM topics WHERE issue = ?")
|
||||||
@@ -93,57 +92,14 @@ export async function buildDigest(env: Env, date: string): Promise<Digest> {
|
|||||||
)
|
)
|
||||||
.bind(topicIssue)
|
.bind(topicIssue)
|
||||||
.all<ProblemRow>();
|
.all<ProblemRow>();
|
||||||
const links = core
|
data.topic = {
|
||||||
.map(
|
name: topic?.name ?? `#${topicIssue}`,
|
||||||
(p) =>
|
core: core.map((p) => ({
|
||||||
`<a href="https://github.com/${env.REPO}/issues/${p.issue}">LC ${p.lc_number}</a>${p.stage === "new" ? "" : " ✓"}`,
|
lc: p.lc_number,
|
||||||
)
|
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
||||||
.join(" · ");
|
solved: p.stage !== "new",
|
||||||
htmlParts.push(
|
})),
|
||||||
`<h3 style="margin:16px 0 4px">New topic: ${topic?.name ?? `#${topicIssue}`}</h3>`,
|
};
|
||||||
`<p style="margin:0">Core: ${links}</p>`,
|
|
||||||
);
|
|
||||||
textParts.push(`New topic: ${topic?.name ?? topicIssue} — core: ${core.map((p) => `LC ${p.lc_number}`).join(", ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reviews due — unlabeled lines, one-tap links.
|
|
||||||
htmlParts.push(`<h3 style="margin:16px 0 4px">Reviews due (${reviews.length})</h3>`);
|
|
||||||
textParts.push(`Reviews due (${reviews.length})`);
|
|
||||||
if (reviews.length) {
|
|
||||||
htmlParts.push(
|
|
||||||
`<p style="margin:0 0 4px;color:#57606a">Solve each from scratch. Do not open your old solution first.</p>`,
|
|
||||||
);
|
|
||||||
for (const p of reviews) {
|
|
||||||
htmlParts.push(
|
|
||||||
`<p style="margin:2px 0">LC ${p.lc_number} — ${cap(p.difficulty)} — stage ${p.stage} — ${await tapLinks(env, p.lc_number, date)}</p>`,
|
|
||||||
);
|
|
||||||
textParts.push(`- LC ${p.lc_number} — ${cap(p.difficulty)} — stage ${p.stage}`);
|
|
||||||
}
|
|
||||||
if (carried > 0) {
|
|
||||||
htmlParts.push(`<p style="margin:4px 0;color:#57606a">${carried} more carried to tomorrow (cap ${DAILY_CAP}).</p>`);
|
|
||||||
textParts.push(`${carried} more carried to tomorrow.`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
htmlParts.push(`<p style="margin:0">None.</p>`);
|
|
||||||
textParts.push("none");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Blind drills — unlabeled.
|
|
||||||
htmlParts.push(`<h3 style="margin:16px 0 4px">Blind drills (${drills.length})</h3>`);
|
|
||||||
textParts.push(`Blind drills (${drills.length})`);
|
|
||||||
if (drills.length) {
|
|
||||||
htmlParts.push(
|
|
||||||
`<p style="margin:0 0 4px;color:#57606a">No topic given. Name the pattern out loud before you code.</p>`,
|
|
||||||
);
|
|
||||||
for (const p of drills) {
|
|
||||||
htmlParts.push(
|
|
||||||
`<p style="margin:2px 0">LC ${p.lc_number} — ${cap(p.difficulty)} — ${await tapLinks(env, p.lc_number, date)}</p>`,
|
|
||||||
);
|
|
||||||
textParts.push(`- LC ${p.lc_number} — ${cap(p.difficulty)}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
htmlParts.push(`<p style="margin:0">None today.</p>`);
|
|
||||||
textParts.push("none");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saturday: the review issue already exists (created at midnight ET).
|
// Saturday: the review issue already exists (created at midnight ET).
|
||||||
@@ -153,40 +109,38 @@ export async function buildDigest(env: Env, date: string): Promise<Digest> {
|
|||||||
.bind(isoWeek(date))
|
.bind(isoWeek(date))
|
||||||
.first<{ issue: number }>();
|
.first<{ issue: number }>();
|
||||||
if (gate) {
|
if (gate) {
|
||||||
htmlParts.push(
|
data.gate = {
|
||||||
`<h3 style="margin:16px 0 4px">Saturday gate</h3>`,
|
week: campaignWeek(date),
|
||||||
`<p style="margin:0"><a href="https://github.com/${env.REPO}/issues/${gate.issue}">Review — Week ${campaignWeek(date)} (#${gate.issue})</a> — timed, blind, then close the issue.</p>`,
|
url: `https://github.com/${env.REPO}/issues/${gate.issue}`,
|
||||||
);
|
};
|
||||||
textParts.push(`Saturday gate: https://github.com/${env.REPO}/issues/${gate.issue}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer: yesterday's log, gate rate to date, progress page.
|
// Footer: yesterday's log summarised (failures named — they are the
|
||||||
const yesterday = addDays(date, -1);
|
// actionable part) and the gate rate to date.
|
||||||
const { results: logged } = await db
|
const { results: logged } = await db
|
||||||
.prepare("SELECT lc_number, kind, result FROM attempts WHERE date = ? ORDER BY id")
|
.prepare("SELECT lc_number, result FROM attempts WHERE date = ? ORDER BY id")
|
||||||
.bind(yesterday)
|
.bind(addDays(date, -1))
|
||||||
.all<{ lc_number: number; kind: string; result: string }>();
|
.all<{ lc_number: number; result: string }>();
|
||||||
|
data.yesterday = {
|
||||||
|
total: logged.length,
|
||||||
|
failed: logged.filter((a) => a.result !== "pass").map((a) => a.lc_number),
|
||||||
|
};
|
||||||
const lastGate = await db
|
const lastGate = await db
|
||||||
.prepare("SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1")
|
.prepare("SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1")
|
||||||
.first<{ pass_rate: number }>();
|
.first<{ pass_rate: number }>();
|
||||||
const footerBits = [
|
if (lastGate) data.gateRate = lastGate.pass_rate;
|
||||||
logged.length
|
|
||||||
? `Yesterday: ${logged.map((a) => `LC ${a.lc_number} ${a.result === "pass" ? "✅" : "❌"}`).join(" · ")}`
|
|
||||||
: "Yesterday: no attempts logged.",
|
|
||||||
lastGate ? `Gate pass rate: ${Math.round(lastGate.pass_rate * 100)}%` : "No gates scored yet.",
|
|
||||||
];
|
|
||||||
htmlParts.push(
|
|
||||||
`<hr style="border:none;border-top:1px solid #d0d7de;margin:16px 0">`,
|
|
||||||
`<p style="margin:0;color:#57606a;font-size:13px">${footerBits.join(" · ")} · <a href="${env.DOCS_URL}/progress">progress page</a></p>`,
|
|
||||||
);
|
|
||||||
textParts.push(...footerBits, `progress: ${env.DOCS_URL}/progress`);
|
|
||||||
|
|
||||||
const load = reviews.length + drills.length;
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildDigest(env: Env, date: string): Promise<Digest> {
|
||||||
|
const data = await collectDigest(env, date);
|
||||||
|
const { html, text } = await renderDigest(data);
|
||||||
return {
|
return {
|
||||||
subject: `SRS — ${prettyDate(date)} — ${load} on deck`,
|
subject: `(Day ${campaignDay(date)}/${CAMPAIGN_DAYS}) LeetCode Daily Digest`,
|
||||||
html: `<div style="font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;max-width:600px;margin:0 auto;padding:12px">${htmlParts.join("\n")}</div>`,
|
html,
|
||||||
text: textParts.join("\n"),
|
text,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,451 @@
|
|||||||
|
/**
|
||||||
|
* Presentation layer for the daily digest — React Email components rendered
|
||||||
|
* to HTML (and to the plain-text alternative) inside the Worker.
|
||||||
|
*
|
||||||
|
* This file owns *only* layout and wording. Every number, URL and signature
|
||||||
|
* is computed in digest.ts and handed over as `DigestData`, so the retrieval
|
||||||
|
* rules (review/drill rows carry number + difficulty only — never the topic,
|
||||||
|
* never a solution link) are enforced by what the data model can express:
|
||||||
|
* `DigestRow` has no title and no issue field.
|
||||||
|
*
|
||||||
|
* Dark by design. The theme is hard-coded rather than left to the client's
|
||||||
|
* dark-mode heuristics: `color-scheme: dark` tells Apple Mail and Outlook not
|
||||||
|
* to re-invert it, and the canvas colour is painted by a full-width <Section>
|
||||||
|
* table because Gmail drops styles on <body>.
|
||||||
|
*
|
||||||
|
* Other email constraints that shape the markup: inline styles only (clients
|
||||||
|
* strip <style>), real <table> layout for the problem lists so Outlook and
|
||||||
|
* Gmail agree on column alignment, React Email's <Button> for the one-tap
|
||||||
|
* links (it emits the MSO padding conditionals a bare <a> lacks), and the
|
||||||
|
* heatmap arrives as PNG — every major client refuses remote SVG.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Head,
|
||||||
|
Heading,
|
||||||
|
Hr,
|
||||||
|
Html,
|
||||||
|
Img,
|
||||||
|
Link,
|
||||||
|
Preview,
|
||||||
|
Section,
|
||||||
|
Text,
|
||||||
|
} from "@react-email/components";
|
||||||
|
import { render } from "@react-email/render";
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
// ── palette (GitHub dark) ────────────────────────────────────────
|
||||||
|
const CANVAS = "#010409";
|
||||||
|
const CARD = "#0d1117";
|
||||||
|
const BORDER = "#30363d";
|
||||||
|
const RULE = "#21262d";
|
||||||
|
const INK = "#e6edf3";
|
||||||
|
const MUTED = "#8b949e";
|
||||||
|
const LINK = "#58a6ff";
|
||||||
|
const GREEN = "#3fb950";
|
||||||
|
const AMBER = "#d29922";
|
||||||
|
const RED = "#f85149";
|
||||||
|
const PASS_BG = "#238636";
|
||||||
|
const FAIL_BG = "#da3633";
|
||||||
|
const GATE_BG = "#1f6feb";
|
||||||
|
|
||||||
|
const FONT = '-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif';
|
||||||
|
|
||||||
|
// Outlook's Word engine is unreliable with text-transform, so display labels
|
||||||
|
// are table lookups rather than a CSS trick.
|
||||||
|
const DIFFICULTY: Record<string, { label: string; color: string }> = {
|
||||||
|
easy: { label: "Easy", color: GREEN },
|
||||||
|
medium: { label: "Medium", color: AMBER },
|
||||||
|
hard: { label: "Hard", color: RED },
|
||||||
|
};
|
||||||
|
|
||||||
|
// The SRS ladder in plain English — "+5" means the last look was 5 days back.
|
||||||
|
const LAST_SEEN: Record<string, string> = {
|
||||||
|
new: "first look",
|
||||||
|
"+2": "2 days ago",
|
||||||
|
"+5": "5 days ago",
|
||||||
|
"+10": "10 days ago",
|
||||||
|
retired: "retired",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── data model ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A review or drill line: number + difficulty + one-tap links, nothing else. */
|
||||||
|
export interface DigestRow {
|
||||||
|
lc: number;
|
||||||
|
difficulty: string;
|
||||||
|
/** Reviews only — drills are unstaged by design. */
|
||||||
|
stage?: string;
|
||||||
|
passUrl: string;
|
||||||
|
failUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigestData {
|
||||||
|
/** "Tuesday, August 25" */
|
||||||
|
day: string;
|
||||||
|
/** "Day 9 of 56 · Week 2" */
|
||||||
|
progress: string;
|
||||||
|
streak: number;
|
||||||
|
rest: boolean;
|
||||||
|
topic?: { name: string; core: { lc: number; url: string; solved: boolean }[] };
|
||||||
|
reviews: DigestRow[];
|
||||||
|
/** Reviews past the daily cap; they simply stay due. */
|
||||||
|
carried: number;
|
||||||
|
drills: DigestRow[];
|
||||||
|
gate?: { week: number; url: string };
|
||||||
|
yesterday: { total: number; failed: number[] };
|
||||||
|
gateRate?: number;
|
||||||
|
/** PNG, not SVG — email clients refuse the latter. */
|
||||||
|
heatmapUrl: string;
|
||||||
|
progressUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── styles ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const page: CSSProperties = { backgroundColor: CANVAS, padding: "28px 0" };
|
||||||
|
const card: CSSProperties = {
|
||||||
|
backgroundColor: CARD,
|
||||||
|
border: `1px solid ${BORDER}`,
|
||||||
|
borderRadius: "10px",
|
||||||
|
margin: "0 auto",
|
||||||
|
maxWidth: "600px",
|
||||||
|
padding: "28px 24px",
|
||||||
|
};
|
||||||
|
const h1: CSSProperties = { color: INK, fontSize: "20px", fontWeight: 600, lineHeight: "26px", margin: 0 };
|
||||||
|
const label: CSSProperties = {
|
||||||
|
color: MUTED,
|
||||||
|
fontSize: "11px",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.8px",
|
||||||
|
margin: "0 0 10px",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
};
|
||||||
|
const hint: CSSProperties = { color: MUTED, fontSize: "13px", lineHeight: "19px", margin: "0 0 12px" };
|
||||||
|
const th: CSSProperties = {
|
||||||
|
borderBottom: `1px solid ${BORDER}`,
|
||||||
|
color: MUTED,
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 400,
|
||||||
|
padding: "0 0 8px",
|
||||||
|
textAlign: "left",
|
||||||
|
};
|
||||||
|
const td: CSSProperties = {
|
||||||
|
borderBottom: `1px solid ${RULE}`,
|
||||||
|
color: INK,
|
||||||
|
fontSize: "14px",
|
||||||
|
padding: "12px 0",
|
||||||
|
textAlign: "left",
|
||||||
|
};
|
||||||
|
const tap: CSSProperties = {
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#ffffff",
|
||||||
|
display: "inline-block",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: "12px",
|
||||||
|
padding: "9px 15px",
|
||||||
|
textDecoration: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A problem list as a real table. The "last seen" column appears only for
|
||||||
|
* reviews, which keeps drills at three columns on a phone.
|
||||||
|
*/
|
||||||
|
function ProblemTable({ rows, staged }: { rows: DigestRow[]; staged: boolean }) {
|
||||||
|
return (
|
||||||
|
<table
|
||||||
|
width="100%"
|
||||||
|
border={0}
|
||||||
|
cellPadding={0}
|
||||||
|
cellSpacing={0}
|
||||||
|
style={{ borderCollapse: "collapse", width: "100%" }}
|
||||||
|
>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={th}>Problem</th>
|
||||||
|
<th style={th}>Level</th>
|
||||||
|
{staged ? <th style={th}>Last seen</th> : null}
|
||||||
|
<th style={{ ...th, textAlign: "right" }}>How did it go?</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => {
|
||||||
|
const difficulty = DIFFICULTY[row.difficulty];
|
||||||
|
return (
|
||||||
|
<tr key={row.lc}>
|
||||||
|
<td style={{ ...td, fontWeight: 600 }}>LC {row.lc}</td>
|
||||||
|
<td style={{ ...td, color: difficulty?.color ?? INK }}>{difficulty?.label ?? row.difficulty}</td>
|
||||||
|
{staged ? (
|
||||||
|
<td style={{ ...td, color: MUTED }}>{(row.stage && LAST_SEEN[row.stage]) ?? row.stage}</td>
|
||||||
|
) : null}
|
||||||
|
<td style={{ ...td, textAlign: "right", whiteSpace: "nowrap" }}>
|
||||||
|
<Button href={row.passUrl} style={{ ...tap, backgroundColor: PASS_BG }}>
|
||||||
|
got it
|
||||||
|
</Button>
|
||||||
|
<Button href={row.failUrl} style={{ ...tap, backgroundColor: FAIL_BG, marginLeft: "6px" }}>
|
||||||
|
missed it
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Block({
|
||||||
|
title,
|
||||||
|
note,
|
||||||
|
rows,
|
||||||
|
staged,
|
||||||
|
empty,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
note: string;
|
||||||
|
rows: DigestRow[];
|
||||||
|
staged: boolean;
|
||||||
|
empty: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Section style={{ marginTop: "28px" }}>
|
||||||
|
<Text style={label}>{title}</Text>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<Text style={{ ...hint, margin: 0 }}>{empty}</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text style={hint}>{note}</Text>
|
||||||
|
<ProblemTable rows={rows} staged={staged} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── email ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function DigestEmail({ data }: { data: DigestData }) {
|
||||||
|
const load = data.reviews.length + data.drills.length;
|
||||||
|
const preview = data.rest
|
||||||
|
? "Rest day — nothing to do but rest."
|
||||||
|
: `${load} to work through today${data.topic ? `, starting with ${data.topic.name}` : ""}.`;
|
||||||
|
|
||||||
|
const passed = data.yesterday.total - data.yesterday.failed.length;
|
||||||
|
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html lang="en" dir="ltr">
|
||||||
|
<Head>
|
||||||
|
<meta name="color-scheme" content="dark" />
|
||||||
|
<meta name="supported-color-schemes" content="dark" />
|
||||||
|
</Head>
|
||||||
|
<Preview>{preview}</Preview>
|
||||||
|
<Body style={{ backgroundColor: CANVAS, fontFamily: FONT, margin: 0, padding: 0 }}>
|
||||||
|
<Section style={page}>
|
||||||
|
<Container style={card}>
|
||||||
|
<Heading as="h1" style={h1}>
|
||||||
|
{data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`}
|
||||||
|
</Heading>
|
||||||
|
<Text style={{ color: MUTED, fontSize: "13px", margin: "8px 0 0" }}>
|
||||||
|
{data.progress}
|
||||||
|
{data.rest
|
||||||
|
? " · nothing is due today, and anything overdue waits for Monday."
|
||||||
|
: data.streak === 0
|
||||||
|
? " · no streak going yet — today is a good day to start one."
|
||||||
|
: ` · 🔥 ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{data.rest ? null : (
|
||||||
|
<>
|
||||||
|
{data.topic ? (
|
||||||
|
<Section style={{ marginTop: "28px" }}>
|
||||||
|
<Text style={label}>Something new today</Text>
|
||||||
|
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||||
|
{data.topic.name}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ ...hint, margin: 0 }}>
|
||||||
|
Work through these — ticked ones you have already solved:{" "}
|
||||||
|
{data.topic.core.map((p, i) => (
|
||||||
|
<span key={p.lc}>
|
||||||
|
{i > 0 ? " · " : ""}
|
||||||
|
<Link href={p.url} style={{ color: p.solved ? MUTED : LINK, textDecoration: "none" }}>
|
||||||
|
LC {p.lc}
|
||||||
|
</Link>
|
||||||
|
{p.solved ? <span style={{ color: GREEN }}> ✓</span> : null}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Block
|
||||||
|
title="Time to see these again"
|
||||||
|
note="Solve each one from scratch, and resist opening your old answer first."
|
||||||
|
rows={data.reviews}
|
||||||
|
staged
|
||||||
|
empty="Nothing is due for review today."
|
||||||
|
/>
|
||||||
|
{data.carried > 0 ? (
|
||||||
|
<Text style={{ ...hint, margin: "12px 0 0" }}>
|
||||||
|
{data.carried} more {data.carried === 1 ? "is" : "are"} waiting — they come back
|
||||||
|
tomorrow, oldest first.
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Block
|
||||||
|
title="Cold start, no hints"
|
||||||
|
note="You are not told the topic. Say the pattern out loud before you write a line."
|
||||||
|
rows={data.drills}
|
||||||
|
staged={false}
|
||||||
|
empty="No cold starts today."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{data.gate ? (
|
||||||
|
<Section style={{ marginTop: "28px" }}>
|
||||||
|
<Text style={label}>Weekly checkpoint</Text>
|
||||||
|
<Text style={hint}>Timed and blind. Close the issue when you are done.</Text>
|
||||||
|
<Button
|
||||||
|
href={data.gate.url}
|
||||||
|
style={{ ...tap, backgroundColor: GATE_BG, fontSize: "13px", padding: "11px 18px" }}
|
||||||
|
>
|
||||||
|
Open week {data.gate.week} review
|
||||||
|
</Button>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Eight weeks at a glance — the one thing here that is pure
|
||||||
|
encouragement rather than instruction. */}
|
||||||
|
<Section style={{ marginTop: "28px" }}>
|
||||||
|
<Text style={label}>Every day you showed up</Text>
|
||||||
|
<Link href={data.progressUrl}>
|
||||||
|
<Img
|
||||||
|
src={data.heatmapUrl}
|
||||||
|
alt="Attempt heatmap across the eight-week campaign"
|
||||||
|
width="552"
|
||||||
|
style={{ border: `1px solid ${BORDER}`, borderRadius: "8px", display: "block", width: "100%" }}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Hr style={{ borderColor: BORDER, margin: "28px 0 18px" }} />
|
||||||
|
<Text style={{ color: MUTED, fontSize: "12px", lineHeight: "19px", margin: 0 }}>
|
||||||
|
{data.yesterday.total === 0
|
||||||
|
? "You did not log anything yesterday."
|
||||||
|
: `Yesterday you logged ${data.yesterday.total} and got ${passed} of them` +
|
||||||
|
(missed ? `; ${missed} got away.` : ".")}{" "}
|
||||||
|
{data.gateRate === undefined
|
||||||
|
? "No checkpoints scored yet."
|
||||||
|
: `Checkpoints are running at ${Math.round(data.gateRate * 100)}%.`}{" "}
|
||||||
|
<Link href={data.progressUrl} style={{ color: LINK, textDecoration: "none" }}>
|
||||||
|
See the full picture →
|
||||||
|
</Link>
|
||||||
|
</Text>
|
||||||
|
</Container>
|
||||||
|
</Section>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plain-text alternative. React Email's `plainText` mode flattens the
|
||||||
|
* problem tables into one unreadable run, so text gets its own writer — fed
|
||||||
|
* by the same `DigestData`, so the numbers and links can never disagree with
|
||||||
|
* the HTML even though the wording lives in two places.
|
||||||
|
*/
|
||||||
|
function plainDigest(data: DigestData): string {
|
||||||
|
const out: string[] = [
|
||||||
|
data.rest ? `Take the day off — ${data.day}` : `Good morning. It's ${data.day}.`,
|
||||||
|
data.progress +
|
||||||
|
(data.rest
|
||||||
|
? " · nothing is due today, and anything overdue waits for Monday."
|
||||||
|
: data.streak === 0
|
||||||
|
? " · no streak going yet — today is a good day to start one."
|
||||||
|
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!data.rest) {
|
||||||
|
if (data.topic) {
|
||||||
|
out.push(
|
||||||
|
"",
|
||||||
|
`SOMETHING NEW TODAY — ${data.topic.name}`,
|
||||||
|
"Work through these; the ones marked done you have already solved.",
|
||||||
|
...data.topic.core.map((p) => ` LC ${p.lc}${p.solved ? " (done)" : ""} — ${p.url}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lists: [string, string, DigestRow[], string][] = [
|
||||||
|
[
|
||||||
|
"TIME TO SEE THESE AGAIN",
|
||||||
|
"Solve each one from scratch, and resist opening your old answer first.",
|
||||||
|
data.reviews,
|
||||||
|
"Nothing is due for review today.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"COLD START, NO HINTS",
|
||||||
|
"You are not told the topic. Say the pattern out loud before you write a line.",
|
||||||
|
data.drills,
|
||||||
|
"No cold starts today.",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const [title, note, rows, empty] of lists) {
|
||||||
|
out.push("", title);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
out.push(empty);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(note);
|
||||||
|
for (const row of rows) {
|
||||||
|
const seen = row.stage ? `, last seen ${LAST_SEEN[row.stage] ?? row.stage}` : "";
|
||||||
|
out.push(
|
||||||
|
` LC ${row.lc} — ${DIFFICULTY[row.difficulty]?.label ?? row.difficulty}${seen}`,
|
||||||
|
` got it: ${row.passUrl}`,
|
||||||
|
` missed it: ${row.failUrl}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.carried > 0) {
|
||||||
|
out.push(
|
||||||
|
"",
|
||||||
|
`${data.carried} more ${data.carried === 1 ? "is" : "are"} waiting — they come back tomorrow, oldest first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.gate) {
|
||||||
|
out.push(
|
||||||
|
"",
|
||||||
|
"WEEKLY CHECKPOINT",
|
||||||
|
"Timed and blind. Close the issue when you are done.",
|
||||||
|
` Week ${data.gate.week} review — ${data.gate.url}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passed = data.yesterday.total - data.yesterday.failed.length;
|
||||||
|
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
|
||||||
|
out.push(
|
||||||
|
"",
|
||||||
|
"─".repeat(48),
|
||||||
|
data.yesterday.total === 0
|
||||||
|
? "You did not log anything yesterday."
|
||||||
|
: `Yesterday you logged ${data.yesterday.total} and got ${passed} of them` +
|
||||||
|
(missed ? `; ${missed} got away.` : "."),
|
||||||
|
data.gateRate === undefined
|
||||||
|
? "No checkpoints scored yet."
|
||||||
|
: `Checkpoints are running at ${Math.round(data.gateRate * 100)}%.`,
|
||||||
|
`Every day you showed up: ${data.heatmapUrl}`,
|
||||||
|
`See the full picture: ${data.progressUrl}`,
|
||||||
|
);
|
||||||
|
return out.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTML body plus its plain-text alternative — always sent as a pair so a
|
||||||
|
* client that refuses HTML still gets the same problems and the same links.
|
||||||
|
*/
|
||||||
|
export async function renderDigest(data: DigestData): Promise<{ html: string; text: string }> {
|
||||||
|
return { html: await render(<DigestEmail data={data} />), text: plainDigest(data) };
|
||||||
|
}
|
||||||
+10
-1
@@ -10,7 +10,7 @@
|
|||||||
* admin routes); everything else is read-only public.
|
* admin routes); everything else is read-only public.
|
||||||
*/
|
*/
|
||||||
import { reconcileCatalog } from "./catalog.ts";
|
import { reconcileCatalog } from "./catalog.ts";
|
||||||
import { gateBadge, heatmapChart, ladderChart, progressChart } from "./charts.ts";
|
import { gateBadge, heatmapChart, heatmapPng, ladderChart, progressChart } from "./charts.ts";
|
||||||
import { sendDigest } from "./digest.ts";
|
import { sendDigest } from "./digest.ts";
|
||||||
import { createReviewIssue, scoreReview } from "./gate.ts";
|
import { createReviewIssue, scoreReview } from "./gate.ts";
|
||||||
import { type GitHub, github } from "./github.ts";
|
import { type GitHub, github } from "./github.ts";
|
||||||
@@ -233,6 +233,12 @@ const SVG_HEADERS = {
|
|||||||
"cache-control": "public, max-age=300",
|
"cache-control": "public, max-age=300",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The digest embeds the raster heatmap; email clients refuse SVG entirely.
|
||||||
|
const PNG_HEADERS = {
|
||||||
|
"content-type": "image/png",
|
||||||
|
"cache-control": "public, max-age=300",
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
@@ -256,6 +262,9 @@ export default {
|
|||||||
if (path === "/chart/heatmap.svg") {
|
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, 56), { headers: SVG_HEADERS });
|
||||||
}
|
}
|
||||||
|
if (path === "/chart/heatmap.png") {
|
||||||
|
return new Response(await heatmapPng(env.DB, CAMPAIGN_START, 56), { headers: PNG_HEADERS });
|
||||||
|
}
|
||||||
if (path === "/badge/gate.svg") {
|
if (path === "/badge/gate.svg") {
|
||||||
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
||||||
}
|
}
|
||||||
|
|||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* Hand-rolled PNG encoder for the SRS Worker.
|
||||||
|
*
|
||||||
|
* Every major email client — Gmail, Outlook, Apple Mail — refuses to render
|
||||||
|
* SVG, inline or remote, so the daily digest cannot reuse /chart/heatmap.svg;
|
||||||
|
* it needs raster bytes. The Worker ships with no runtime dependencies by
|
||||||
|
* design (SVG, HMAC and GraphQL are all hand-rolled here), so this file
|
||||||
|
* encodes PNG itself instead of pulling in a codec.
|
||||||
|
*
|
||||||
|
* The format reduces to four things the runtime already provides:
|
||||||
|
* - an 8-byte signature plus chunks (IHDR, IDAT, IEND), each one
|
||||||
|
* length + type + payload + CRC32, every integer big-endian;
|
||||||
|
* - one filter byte per scanline, always 0 here: these images are flat
|
||||||
|
* rectangles, so a predictor would buy nothing;
|
||||||
|
* - zlib-wrapped deflate for IDAT, which is exactly what
|
||||||
|
* `new CompressionStream("deflate")` emits ("deflate-raw" would not);
|
||||||
|
* - CRC32, a 256-entry table built lazily on the first chunk.
|
||||||
|
*
|
||||||
|
* Colour type 2 (truecolour, 8-bit, no alpha): these charts are opaque cards,
|
||||||
|
* so an alpha channel would cost a third more bytes for nothing. That does mean
|
||||||
|
* transparency is unavailable — a caller wanting rounded corners paints the
|
||||||
|
* colour that sits behind the image first, then the rounded card on top.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── crc32 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// One table for every chunk of every image; built on first use.
|
||||||
|
let crcTable: Uint32Array | undefined;
|
||||||
|
|
||||||
|
function crc32(bytes: Uint8Array): number {
|
||||||
|
let table = crcTable;
|
||||||
|
if (!table) {
|
||||||
|
table = new Uint32Array(256);
|
||||||
|
for (let n = 0; n < 256; n++) {
|
||||||
|
let c = n;
|
||||||
|
for (let k = 0; k < 8; k++) c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
table[n] = c;
|
||||||
|
}
|
||||||
|
crcTable = table;
|
||||||
|
}
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (const byte of bytes) crc = table[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── chunks ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||||
|
|
||||||
|
/** length, 4-char type, payload, then CRC32 over the type and payload. */
|
||||||
|
function chunk(type: string, data: Uint8Array): Uint8Array {
|
||||||
|
const out = new Uint8Array(data.length + 12);
|
||||||
|
const view = new DataView(out.buffer);
|
||||||
|
view.setUint32(0, data.length);
|
||||||
|
for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i);
|
||||||
|
out.set(data, 8);
|
||||||
|
view.setUint32(data.length + 8, crc32(out.subarray(4, data.length + 8)));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5x7 bitmap font ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
const GLYPH_W = 5;
|
||||||
|
const GLYPH_H = 7;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seven row bitmasks per glyph, five low bits each, MSB (0b10000) leftmost.
|
||||||
|
* Uppercase only: chart labels are short and mechanical, and a lowercase set
|
||||||
|
* would double the table for no gain. Unlisted characters render blank.
|
||||||
|
*/
|
||||||
|
const GLYPHS: Record<string, number[]> = {
|
||||||
|
A: [0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
|
||||||
|
B: [0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110],
|
||||||
|
C: [0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110],
|
||||||
|
D: [0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110],
|
||||||
|
E: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111],
|
||||||
|
F: [0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000],
|
||||||
|
G: [0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01110],
|
||||||
|
H: [0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001],
|
||||||
|
I: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b11111],
|
||||||
|
J: [0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100],
|
||||||
|
K: [0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001],
|
||||||
|
L: [0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111],
|
||||||
|
M: [0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001],
|
||||||
|
N: [0b10001, 0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001],
|
||||||
|
O: [0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
|
||||||
|
P: [0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000],
|
||||||
|
Q: [0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10011, 0b01101],
|
||||||
|
R: [0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001],
|
||||||
|
S: [0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110],
|
||||||
|
T: [0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100],
|
||||||
|
U: [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110],
|
||||||
|
V: [0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100],
|
||||||
|
W: [0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b11011, 0b10001],
|
||||||
|
X: [0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001],
|
||||||
|
Y: [0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100],
|
||||||
|
Z: [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111],
|
||||||
|
"0": [0b01110, 0b10001, 0b10011, 0b10101, 0b11001, 0b10001, 0b01110],
|
||||||
|
"1": [0b00100, 0b01100, 0b00100, 0b00100, 0b00100, 0b00100, 0b01110],
|
||||||
|
"2": [0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111],
|
||||||
|
"3": [0b11111, 0b00010, 0b00100, 0b00010, 0b00001, 0b10001, 0b01110],
|
||||||
|
"4": [0b00010, 0b00110, 0b01010, 0b10010, 0b11111, 0b00010, 0b00010],
|
||||||
|
"5": [0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110],
|
||||||
|
"6": [0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110],
|
||||||
|
"7": [0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000],
|
||||||
|
"8": [0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110],
|
||||||
|
"9": [0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100],
|
||||||
|
" ": [0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000, 0b00000],
|
||||||
|
"-": [0b00000, 0b00000, 0b00000, 0b11111, 0b00000, 0b00000, 0b00000],
|
||||||
|
"/": [0b00001, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b10000],
|
||||||
|
":": [0b00000, 0b00100, 0b00100, 0b00000, 0b00100, 0b00100, 0b00000],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device pixels `content` occupies at `scale`, inter-glyph gaps included.
|
||||||
|
* Exported because callers need it to size a canvas before constructing one.
|
||||||
|
*/
|
||||||
|
export function textWidth(content: string, scale = 1): number {
|
||||||
|
return content.length === 0 ? 0 : (content.length * (GLYPH_W + 1) - 1) * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── canvas ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** "#rrggbb" to its three channel bytes; called once per draw, not per pixel. */
|
||||||
|
function rgb(color: string): [number, number, number] {
|
||||||
|
const packed = Number.parseInt(color.slice(1), 16);
|
||||||
|
return [(packed >> 16) & 0xff, (packed >> 8) & 0xff, packed & 0xff];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed-size RGB canvas: filled rectangles plus 5x7 bitmap text. */
|
||||||
|
export class Canvas {
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
/** Row-major RGB triples, unpadded; encode() interleaves the filter bytes. */
|
||||||
|
private readonly pixels: Uint8Array;
|
||||||
|
|
||||||
|
constructor(width: number, height: number, background: string) {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.pixels = new Uint8Array(width * height * 3);
|
||||||
|
const [r, g, b] = rgb(background);
|
||||||
|
for (let i = 0; i < this.pixels.length; i += 3) {
|
||||||
|
this.pixels[i] = r;
|
||||||
|
this.pixels[i + 1] = g;
|
||||||
|
this.pixels[i + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filled rectangle, clipped to the canvas. `radius` rounds all four corners
|
||||||
|
* by dropping the pixels whose centre falls outside the corner circle —
|
||||||
|
* enough for the small cell roundings and the card these charts draw.
|
||||||
|
*/
|
||||||
|
rect(x: number, y: number, w: number, h: number, color: string, radius = 0): void {
|
||||||
|
const [r, g, b] = rgb(color);
|
||||||
|
// Device pixels only: a fractional origin would index the buffer between
|
||||||
|
// bytes and silently drop the whole rectangle.
|
||||||
|
const left = Math.round(x);
|
||||||
|
const top = Math.round(y);
|
||||||
|
const rw = Math.round(w);
|
||||||
|
const rh = Math.round(h);
|
||||||
|
const rad = Math.min(radius, rw / 2, rh / 2);
|
||||||
|
|
||||||
|
const y1 = Math.min(this.height, top + rh);
|
||||||
|
const x1 = Math.min(this.width, left + rw);
|
||||||
|
for (let py = Math.max(0, top); py < y1; py++) {
|
||||||
|
// How far this row lies past the nearer corner centre; 0 in between.
|
||||||
|
const cy = py + 0.5;
|
||||||
|
const dy = cy < top + rad ? top + rad - cy : cy > top + rh - rad ? cy - (top + rh - rad) : 0;
|
||||||
|
const rowBase = py * this.width * 3;
|
||||||
|
for (let px = Math.max(0, left); px < x1; px++) {
|
||||||
|
const cx = px + 0.5;
|
||||||
|
const dx = cx < left + rad ? left + rad - cx : cx > left + rw - rad ? cx - (left + rw - rad) : 0;
|
||||||
|
if (dx * dx + dy * dy > rad * rad) continue;
|
||||||
|
const at = rowBase + px * 3;
|
||||||
|
this.pixels[at] = r;
|
||||||
|
this.pixels[at + 1] = g;
|
||||||
|
this.pixels[at + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bitmap text from a top-left origin (not a baseline), `scale` device pixels
|
||||||
|
* per glyph pixel, 1 glyph pixel of advance between characters. Input is
|
||||||
|
* uppercased; unknown characters advance without drawing.
|
||||||
|
*/
|
||||||
|
text(x: number, y: number, content: string, color: string, scale = 1): void {
|
||||||
|
const [r, g, b] = rgb(color);
|
||||||
|
const left = Math.round(x);
|
||||||
|
const top = Math.round(y);
|
||||||
|
const upper = content.toUpperCase();
|
||||||
|
|
||||||
|
for (let i = 0; i < upper.length; i++) {
|
||||||
|
const glyph = GLYPHS[upper[i]!];
|
||||||
|
if (!glyph) continue;
|
||||||
|
const originX = left + i * (GLYPH_W + 1) * scale;
|
||||||
|
for (let gy = 0; gy < GLYPH_H; gy++) {
|
||||||
|
const bits = glyph[gy]!;
|
||||||
|
if (bits === 0) continue;
|
||||||
|
for (let gx = 0; gx < GLYPH_W; gx++) {
|
||||||
|
if ((bits & (1 << (GLYPH_W - 1 - gx))) === 0) continue;
|
||||||
|
// Every set glyph pixel is a scale x scale block of device pixels.
|
||||||
|
const blockX = originX + gx * scale;
|
||||||
|
const blockY = top + gy * scale;
|
||||||
|
const y1 = Math.min(this.height, blockY + scale);
|
||||||
|
const x1 = Math.min(this.width, blockX + scale);
|
||||||
|
for (let py = Math.max(0, blockY); py < y1; py++) {
|
||||||
|
const rowBase = py * this.width * 3;
|
||||||
|
for (let px = Math.max(0, blockX); px < x1; px++) {
|
||||||
|
const at = rowBase + px * 3;
|
||||||
|
this.pixels[at] = r;
|
||||||
|
this.pixels[at + 1] = g;
|
||||||
|
this.pixels[at + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PNG bytes: 8-bit truecolour, one IDAT, zlib via CompressionStream. */
|
||||||
|
async encode(): Promise<Uint8Array> {
|
||||||
|
const stride = this.width * 3;
|
||||||
|
const raw = new Uint8Array((stride + 1) * this.height);
|
||||||
|
for (let y = 0; y < this.height; y++) {
|
||||||
|
// Leading 0 of each scanline slot is the "no filter" byte.
|
||||||
|
raw.set(this.pixels.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1);
|
||||||
|
}
|
||||||
|
const deflated = new Response(raw).body!.pipeThrough(new CompressionStream("deflate"));
|
||||||
|
const idat = new Uint8Array(await new Response(deflated).arrayBuffer());
|
||||||
|
|
||||||
|
const ihdr = new Uint8Array(13);
|
||||||
|
const header = new DataView(ihdr.buffer);
|
||||||
|
header.setUint32(0, this.width);
|
||||||
|
header.setUint32(4, this.height);
|
||||||
|
ihdr[8] = 8; // bit depth
|
||||||
|
ihdr[9] = 2; // colour type 2: truecolour RGB
|
||||||
|
// Bytes 10-12 stay 0: deflate, adaptive filtering, no interlace.
|
||||||
|
|
||||||
|
const chunks = [chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0))];
|
||||||
|
let total = SIGNATURE.length;
|
||||||
|
for (const c of chunks) total += c.length;
|
||||||
|
|
||||||
|
const png = new Uint8Array(total);
|
||||||
|
png.set(SIGNATURE);
|
||||||
|
let at = SIGNATURE.length;
|
||||||
|
for (const c of chunks) {
|
||||||
|
png.set(c, at);
|
||||||
|
at += c.length;
|
||||||
|
}
|
||||||
|
return png;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"lib": ["esnext"],
|
"lib": ["esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user