diff --git a/api/bun.lock b/api/bun.lock index 474d0bf..716aa74 100644 --- a/api/bun.lock +++ b/api/bun.lock @@ -4,7 +4,14 @@ "workspaces": { "": { "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": { + "@types/react": "^19.2.18", "wrangler": "^4.125.0", }, }, @@ -146,36 +153,124 @@ "@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=="], "@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=="], "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=="], + "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=="], "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=="], + "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=="], + "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=="], + "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=="], "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=="], "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=="], + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "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-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=="], } } diff --git a/api/package.json b/api/package.json index c39c251..ad48a25 100644 --- a/api/package.json +++ b/api/package.json @@ -9,6 +9,13 @@ "test": "bun test src" }, "devDependencies": { + "@types/react": "^19.2.18", "wrangler": "^4.125.0" + }, + "dependencies": { + "@react-email/components": "^1.0.12", + "@react-email/render": "^2.1.0", + "react": "^19.2.8", + "react-dom": "^19.2.8" } } diff --git a/api/src/charts.ts b/api/src/charts.ts index 37ba631..6411fd9 100644 --- a/api/src/charts.ts +++ b/api/src/charts.ts @@ -1,17 +1,21 @@ /** * Hand-rolled SVG chart generation for the SRS Worker. * - * Feeds four endpoints — /chart/progress.svg, /chart/ladder.svg, - * /chart/heatmap.svg, /badge/gate.svg — each served with - * `Cache-Control: public, max-age=300`. + * Feeds five endpoints — /chart/progress.svg, /chart/ladder.svg, + * /chart/heatmap.svg, /chart/heatmap.png, /badge/gate.svg — each served with + * `Cache-Control: public, max-age=300`. The heatmap has a raster twin because + * every major email client blocks SVG, so the digest cannot embed the vector. * - * Everything is string-built: no chart library, no runtime imports. The only - * dynamic values entering the markup are numbers, percentages, and dates the - * Worker itself computes, plus the fixed phase/stage labels — so nothing here - * needs XML escaping. Styling is shadcn dark zinc to match the README's - * shieldcn badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub - * dark-mode green ramp. Every function renders valid SVG on a zero-row DB. + * The SVG is string-built: no chart library, no dependencies, and the one + * import is the hand-rolled PNG encoder next door. The only dynamic values + * entering the markup are numbers, percentages, and dates the Worker itself + * computes, plus the fixed phase/stage labels — so nothing here needs XML + * escaping. Styling is shadcn dark zinc to match the README's shieldcn + * badges: rounded #09090b cards, #fafafa/#a1a1aa text, GitHub dark-mode + * green ramp. Every function renders on a zero-row DB. */ +import { Canvas, textWidth } from "./png.ts"; + // D1Database comes from the generated worker-configuration.d.ts runtime types. const FONT = "Verdana,DejaVu Sans,sans-serif"; @@ -195,9 +199,43 @@ export async function ladderChart(db: D1Database): Promise { const DAY_MS = 86_400_000; -export async function heatmapChart(db: D1Database, start: string, days: number): Promise { - // YYYY-MM-DD → UTC midnight; all per-day dates derive from this timestamp, - // so local-timezone drift never shifts a cell. +// Layout in CSS pixels. heatmapPng multiplies every one of these by its device +// scale, so the vector and raster pictures cannot drift apart. +const HEAT_W = 560; +const HEAT_H = 160; +const HEAT_GRID_X = 34; +const HEAT_GRID_Y = 24; +const HEAT_CELL = 16; +const HEAT_STEP = 19; // cell + 3px gap + +// Grid row → left-hand label; both renderers print only these three. +const WEEKDAYS: [number, string][] = [ + [0, "Mon"], + [2, "Wed"], + [4, "Fri"], +]; + +/** One grid square: column, Mon-based row, and index into GREENS. */ +interface HeatmapCell { + col: number; + row: number; + level: number; +} + +/** + * The heatmap's whole data model — one cell per campaign day, plus the week + * count that positions the legend — shared by both renderers. + * + * Every per-day date derives from one UTC-midnight timestamp, so local-timezone + * drift never shifts a cell. The campaign starts on a Monday, so day i sits at + * column i/7; the row comes from the real weekday, so an off-Monday start still + * lands correctly. + */ +async function heatmapCells( + db: D1Database, + start: string, + days: number, +): Promise<{ cells: HeatmapCell[]; weeks: number }> { const [sy, sm, sd] = start.split("-").map(Number); const base = Date.UTC(sy!, sm! - 1, sd!); const end = new Date(base + (days - 1) * DAY_MS).toISOString().slice(0, 10); @@ -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 width = 560; - 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. + const cells: HeatmapCell[] = []; for (let i = 0; i < days; i++) { const day = new Date(base + i * DAY_MS); const attempts = byDate.get(day.toISOString().slice(0, 10)) ?? 0; - const col = Math.floor(i / 7); - const row = (day.getUTCDay() + 6) % 7; // Mon = 0 - const color = GREENS[Math.min(attempts, 4)]; + cells.push({ + col: Math.floor(i / 7), + row: (day.getUTCDay() + 6) % 7, // Mon = 0 + level: Math.min(attempts, 4), + }); + } + + return { cells, weeks: Math.ceil(days / 7) }; +} + +export async function heatmapChart(db: D1Database, start: string, days: number): Promise { + const { cells, weeks } = await heatmapCells(db, start, days); + + const parts = [svgOpen(HEAT_W, HEAT_H)]; + + // Week numbers across the top, Mon/Wed/Fri down the left. + for (let w = 0; w < weeks; w++) { parts.push( - ``, + text(HEAT_GRID_X + w * HEAT_STEP + HEAT_CELL / 2, HEAT_GRID_Y - 7, `W${w + 1}`, { + size: 10, + fill: MUTED, + anchor: "middle", + }), + ); + } + for (const [row, label] of WEEKDAYS) { + parts.push( + text(HEAT_GRID_X - 6, HEAT_GRID_Y + row * HEAT_STEP + HEAT_CELL - 4, label, { + size: 10, + fill: MUTED, + anchor: "end", + }), + ); + } + + for (const { col, row, level } of cells) { + parts.push( + ``, ); } // Less → More ramp fills the space right of the grid. - const legendX = gridX + weeks * step + 40; - const legendY = gridY + 3 * step; - parts.push(text(legendX - 6, legendY + cell - 4, "Less", { size: 10, fill: MUTED, anchor: "end" })); + const legendX = HEAT_GRID_X + weeks * HEAT_STEP + 40; + const legendY = HEAT_GRID_Y + 3 * HEAT_STEP; + parts.push(text(legendX - 6, legendY + HEAT_CELL - 4, "Less", { size: 10, fill: MUTED, anchor: "end" })); GREENS.forEach((color, i) => { - parts.push(``); + parts.push( + ``, + ); }); - 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(""); 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 { + const { cells, weeks } = await heatmapCells(db, start, days); + + const s = HEAT_SCALE; + const gridX = HEAT_GRID_X * s; + const gridY = HEAT_GRID_Y * s; + const cell = HEAT_CELL * s; + const step = HEAT_STEP * s; + const capH = 7 * s; + + const legendX = gridX + weeks * step + 40 * s; + const legendY = gridY + 3 * step; + const moreX = legendX + GREENS.length * step + 3 * s; + const width = moreX + textWidth("More", s) + gridX; + const height = HEAT_H * s; + + const canvas = new Canvas(width, height, EMAIL_CARD); + canvas.rect(0, 0, width, height, BG, 6 * s); + + for (let w = 0; w < weeks; w++) { + const label = `W${w + 1}`; + const middle = gridX + w * step + cell / 2; + canvas.text(middle - textWidth(label, s) / 2, gridY - 7 * s - capH, label, MUTED, s); + } + for (const [row, label] of WEEKDAYS) { + canvas.text( + gridX - 6 * s - textWidth(label, s), + gridY + row * step + cell - 4 * s - capH, + label, + MUTED, + s, + ); + } + + for (const { col, row, level } of cells) { + canvas.rect(gridX + col * step, gridY + row * step, cell, cell, GREENS[level]!, 2 * s); + } + + const legendBase = legendY + cell - 4 * s - capH; + canvas.text(legendX - 6 * s - textWidth("Less", s), legendBase, "Less", MUTED, s); + GREENS.forEach((color, i) => { + canvas.rect(legendX + i * step, legendY, cell, cell, color, 2 * s); + }); + canvas.text(moreX, legendBase, "More", MUTED, s); + + return await canvas.encode(); +} + // ── gate badge: shieldcn-style dark pill ───────────────────────── export async function gateBadge(db: D1Database): Promise { diff --git a/api/src/digest.ts b/api/src/digest.ts index 6a09dbf..fd8b89d 100644 --- a/api/src/digest.ts +++ b/api/src/digest.ts @@ -1,10 +1,13 @@ /** * 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 - * clean in Gmail mobile; always sent with a text alternative. + * 8 AM ET via the send_email binding. This module owns data only: it turns + * D1 rows into a `DigestData` and hands it to email.tsx, which renders both + * the HTML and the plain-text alternative from that single tree (React Email + * `render`), so the two can never drift. * - * Retrieval rules: review and drill lines carry number + difficulty only — - * never the topic, never a solution link. The learning day's core list is + * Retrieval rules: review and drill 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 learning day's core list is * the only labeled section. Reviews + drills ≤ 6, reviews first, overflow * simply stays due (oldest tomorrow). Sunday is a two-line rest note. * @@ -26,62 +29,58 @@ import { streak, weekdayOf, } from "./srs.ts"; +import { type DigestData, type DigestRow, renderDigest } from "./email.tsx"; import { signLink } from "./links.ts"; const DAILY_CAP = 6; -function cap(difficulty: string): string { - return difficulty[0]!.toUpperCase() + difficulty.slice(1); -} - export interface Digest { subject: string; html: string; text: string; } -/** One-tap pass/fail anchor pair for a problem line. */ -async function tapLinks(env: Env, lc: number, date: string): Promise { - const pass = await signLink(env.LINK_KEY, lc, "pass", date); - const fail = await signLink(env.LINK_KEY, lc, "fail", date); - const url = (r: string, sig: string) => - `${env.PUBLIC_URL}/log?p=${lc}&r=${r}&d=${date}&sig=${sig}`; - return ( - `pass` + - ` · fail` - ); +/** A problem row plus its signed one-tap pass/fail URLs. */ +async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): Promise { + const [pass, fail] = await Promise.all([ + signLink(env.LINK_KEY, p.lc_number, "pass", date), + signLink(env.LINK_KEY, p.lc_number, "fail", date), + ]); + const base = `${env.PUBLIC_URL}/log?p=${p.lc_number}&d=${date}&r=`; + return { + lc: p.lc_number, + difficulty: p.difficulty, + ...(staged ? { stage: p.stage } : {}), + passUrl: `${base}pass&sig=${pass}`, + failUrl: `${base}fail&sig=${fail}`, + }; } -export async function buildDigest(env: Env, date: string): Promise { +/** Everything the email needs, read straight out of D1 and the schedule. */ +export async function collectDigest(env: Env, date: string): Promise { const db = env.DB; - const day = campaignDay(date); - const week = campaignWeek(date); - const header = `${prettyDate(date)} — Day ${day}/${CAMPAIGN_DAYS} · Week ${week}`; + const rest = weekdayOf(date) === 0; - if (weekdayOf(date) === 0) { - return { - subject: `SRS — ${prettyDate(date)} — rest day`, - html: `

${header}

Rest day. Nothing is due. Overdue reviews moved to Monday.

`, - text: `${header}\nRest day. Nothing is due. Overdue reviews moved to Monday.`, - }; - } + // Reviews take the cap first; drills fill whatever is left. + const due = rest ? [] : await dueReviews(db, date); + const capped = due.slice(0, DAILY_CAP); + const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - capped.length); - const due = await dueReviews(db, date); - const reviews = due.slice(0, DAILY_CAP); - const carried = due.length - reviews.length; - const drills = await pickDrills(db, date, DAILY_CAP - reviews.length); - const currentStreak = await streak(db, date); + const data: DigestData = { + day: prettyDate(date), + progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`, + streak: 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[] = []; - const textParts: string[] = []; - htmlParts.push( - `

${header}

`, - `

🔥 streak: ${currentStreak} day${currentStreak === 1 ? "" : "s"}

`, - ); - textParts.push(header, `streak: ${currentStreak}`); - - // New topic — the only labeled section. - const topicIssue = SCHEDULE[date]; + // New topic — the only section allowed to name problems and link them. + const topicIssue = rest ? undefined : SCHEDULE[date]; if (topicIssue !== undefined) { const topic = await db .prepare("SELECT name FROM topics WHERE issue = ?") @@ -93,57 +92,14 @@ export async function buildDigest(env: Env, date: string): Promise { ) .bind(topicIssue) .all(); - const links = core - .map( - (p) => - `LC ${p.lc_number}${p.stage === "new" ? "" : " ✓"}`, - ) - .join(" · "); - htmlParts.push( - `

New topic: ${topic?.name ?? `#${topicIssue}`}

`, - `

Core: ${links}

`, - ); - 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(`

Reviews due (${reviews.length})

`); - textParts.push(`Reviews due (${reviews.length})`); - if (reviews.length) { - htmlParts.push( - `

Solve each from scratch. Do not open your old solution first.

`, - ); - for (const p of reviews) { - htmlParts.push( - `

LC ${p.lc_number} — ${cap(p.difficulty)} — stage ${p.stage} — ${await tapLinks(env, p.lc_number, date)}

`, - ); - textParts.push(`- LC ${p.lc_number} — ${cap(p.difficulty)} — stage ${p.stage}`); - } - if (carried > 0) { - htmlParts.push(`

${carried} more carried to tomorrow (cap ${DAILY_CAP}).

`); - textParts.push(`${carried} more carried to tomorrow.`); - } - } else { - htmlParts.push(`

None.

`); - textParts.push("none"); - } - - // Blind drills — unlabeled. - htmlParts.push(`

Blind drills (${drills.length})

`); - textParts.push(`Blind drills (${drills.length})`); - if (drills.length) { - htmlParts.push( - `

No topic given. Name the pattern out loud before you code.

`, - ); - for (const p of drills) { - htmlParts.push( - `

LC ${p.lc_number} — ${cap(p.difficulty)} — ${await tapLinks(env, p.lc_number, date)}

`, - ); - textParts.push(`- LC ${p.lc_number} — ${cap(p.difficulty)}`); - } - } else { - htmlParts.push(`

None today.

`); - textParts.push("none"); + data.topic = { + name: topic?.name ?? `#${topicIssue}`, + core: core.map((p) => ({ + lc: p.lc_number, + url: `https://github.com/${env.REPO}/issues/${p.issue}`, + solved: p.stage !== "new", + })), + }; } // Saturday: the review issue already exists (created at midnight ET). @@ -153,40 +109,38 @@ export async function buildDigest(env: Env, date: string): Promise { .bind(isoWeek(date)) .first<{ issue: number }>(); if (gate) { - htmlParts.push( - `

Saturday gate

`, - `

Review — Week ${campaignWeek(date)} (#${gate.issue}) — timed, blind, then close the issue.

`, - ); - textParts.push(`Saturday gate: https://github.com/${env.REPO}/issues/${gate.issue}`); + data.gate = { + week: campaignWeek(date), + url: `https://github.com/${env.REPO}/issues/${gate.issue}`, + }; } } - // Footer: yesterday's log, gate rate to date, progress page. - const yesterday = addDays(date, -1); + // Footer: yesterday's log summarised (failures named — they are the + // actionable part) and the gate rate to date. const { results: logged } = await db - .prepare("SELECT lc_number, kind, result FROM attempts WHERE date = ? ORDER BY id") - .bind(yesterday) - .all<{ lc_number: number; kind: string; result: string }>(); + .prepare("SELECT lc_number, result FROM attempts WHERE date = ? ORDER BY id") + .bind(addDays(date, -1)) + .all<{ lc_number: number; result: string }>(); + data.yesterday = { + total: logged.length, + failed: logged.filter((a) => a.result !== "pass").map((a) => a.lc_number), + }; const lastGate = await db .prepare("SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1") .first<{ pass_rate: number }>(); - const footerBits = [ - 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( - `
`, - `

${footerBits.join(" · ")} · progress page

`, - ); - textParts.push(...footerBits, `progress: ${env.DOCS_URL}/progress`); + if (lastGate) data.gateRate = lastGate.pass_rate; - const load = reviews.length + drills.length; + return data; +} + +export async function buildDigest(env: Env, date: string): Promise { + const data = await collectDigest(env, date); + const { html, text } = await renderDigest(data); return { - subject: `SRS — ${prettyDate(date)} — ${load} on deck`, - html: `
${htmlParts.join("\n")}
`, - text: textParts.join("\n"), + subject: `(Day ${campaignDay(date)}/${CAMPAIGN_DAYS}) LeetCode Daily Digest`, + html, + text, }; } diff --git a/api/src/email.tsx b/api/src/email.tsx new file mode 100644 index 0000000..f023638 --- /dev/null +++ b/api/src/email.tsx @@ -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
+ * table because Gmail drops styles on . + * + * Other email constraints that shape the markup: inline styles only (clients + * strip