This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: remove time tracking code and implement new db

+333 -1857
+88 -78
bun.lock
··· 9 9 "@types/react-dom": "^19.1.2", 10 10 "bottleneck": "^2.19.5", 11 11 "colors": "^1.4.0", 12 - "drizzle-kit": "^0.30.6", 13 - "drizzle-orm": "^0.41.0", 12 + "drizzle-kit": "^0.31.0", 13 + "drizzle-orm": "^0.42.0", 14 + "pg": "^8.14.1", 14 15 "react": "^19.1.0", 15 16 "react-dom": "^19.1.0", 16 17 "slack-edge": "^1.3.7", ··· 18 19 }, 19 20 "devDependencies": { 20 21 "@types/bun": "latest", 22 + "@types/pg": "^8.11.13", 21 23 }, 22 24 "peerDependencies": { 23 25 "typescript": "^5", ··· 31 33 32 34 "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], 33 35 34 - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], 36 + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag=="], 35 37 36 - "@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], 38 + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.2", "", { "os": "android", "cpu": "arm" }, "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA=="], 37 39 38 - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="], 40 + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w=="], 39 41 40 - "@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="], 42 + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.2", "", { "os": "android", "cpu": "x64" }, "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg=="], 41 43 42 - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="], 44 + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA=="], 43 45 44 - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="], 46 + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA=="], 45 47 46 - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="], 48 + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w=="], 47 49 48 - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="], 50 + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ=="], 49 51 50 - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="], 52 + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.2", "", { "os": "linux", "cpu": "arm" }, "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g=="], 51 53 52 - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="], 54 + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g=="], 53 55 54 - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="], 56 + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ=="], 55 57 56 - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="], 58 + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w=="], 57 59 58 - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="], 60 + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q=="], 59 61 60 - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="], 62 + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g=="], 61 63 62 - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="], 64 + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.2", "", { "os": "linux", "cpu": "none" }, "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw=="], 63 65 64 - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="], 66 + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q=="], 65 67 66 - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="], 68 + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.2", "", { "os": "linux", "cpu": "x64" }, "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg=="], 67 69 68 - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="], 70 + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.2", "", { "os": "none", "cpu": "arm64" }, "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw=="], 69 71 70 - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="], 71 - 72 - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="], 73 - 74 - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="], 75 - 76 - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="], 77 - 78 - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="], 79 - 80 - "@libsql/client": ["@libsql/client@0.15.2", "", { "dependencies": { "@libsql/core": "^0.15.2", "@libsql/hrana-client": "^0.7.0", "js-base64": "^3.7.5", "libsql": "^0.5.4", "promise-limit": "^2.7.0" } }, "sha512-D0No4jqDj5I+buvEyFajBugohzJXCBt9aRHCEXGrJS/9obnAO2z18Os3xgyPsWX0Yw4NQfSYaayRdowqkssmXA=="], 81 - 82 - "@libsql/core": ["@libsql/core@0.15.2", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-+UIN0OlzWa54MqnHbtaJ3FEJj6k2VrwrjX1sSSxzYlM+dWuadjMwOVp7gHpSYJGKWw0RQWLGge4fbW4TCvIm3A=="], 83 - 84 - "@libsql/darwin-arm64": ["@libsql/darwin-arm64@0.5.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4PnRdklaQg27vAZxtQgKl+xBHimCH2KRgKId+h63gkAtz5yFTMmX+Q4Ez804T1BgrZuB5ujIvueEEuust2ceSQ=="], 85 - 86 - "@libsql/darwin-x64": ["@libsql/darwin-x64@0.5.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-r+Z3UXQWxluXKA5cPj5KciNsmSXVTnq9/tmDczngJrogyXwdbbSShYkzov5M+YBlUCKv2VCbNnfxxoIqQnV9Gg=="], 87 - 88 - "@libsql/hrana-client": ["@libsql/hrana-client@0.7.0", "", { "dependencies": { "@libsql/isomorphic-fetch": "^0.3.1", "@libsql/isomorphic-ws": "^0.1.5", "js-base64": "^3.7.5", "node-fetch": "^3.3.2" } }, "sha512-OF8fFQSkbL7vJY9rfuegK1R7sPgQ6kFMkDamiEccNUvieQ+3urzfDFI616oPl8V7T9zRmnTkSjMOImYCAVRVuw=="], 72 + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.2", "", { "os": "none", "cpu": "x64" }, "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg=="], 89 73 90 - "@libsql/isomorphic-fetch": ["@libsql/isomorphic-fetch@0.3.1", "", {}, "sha512-6kK3SUK5Uu56zPq/Las620n5aS9xJq+jMBcNSOmjhNf/MUvdyji4vrMTqD7ptY7/4/CAVEAYDeotUz60LNQHtw=="], 74 + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg=="], 91 75 92 - "@libsql/isomorphic-ws": ["@libsql/isomorphic-ws@0.1.5", "", { "dependencies": { "@types/ws": "^8.5.4", "ws": "^8.13.0" } }, "sha512-DtLWIH29onUYR00i0GlQ3UdcTRC6EP4u9w/h9LxpUZJWRMARk6dQwZ6Jkd+QdwVpuAOrdxt18v0K2uIYR3fwFg=="], 93 - 94 - "@libsql/linux-arm64-gnu": ["@libsql/linux-arm64-gnu@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-QmGXa3TGM6URe7vCOqdvr4Koay+4h5D6y4gdhnPCvXNYrRHgpq5OwEafP9GFalbO32Y1ppLY4enO2LwY0k63Qw=="], 95 - 96 - "@libsql/linux-arm64-musl": ["@libsql/linux-arm64-musl@0.5.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-cx4/7/xUjgNbiRsghRHujSvIqaTNFQC7Oo1gkGXGsh8hBwkdXr1QdOpeitq745sl6OlbInRrW2C7B2juxX3hcQ=="], 76 + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw=="], 97 77 98 - "@libsql/linux-x64-gnu": ["@libsql/linux-x64-gnu@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-oPrE9Zyqd7fElS9uCGW2jn55cautD+gDIflfyF5+W/QYzll5OJ2vyMBZOBgdNopuZHrmHYihbespJn3t0WJDJg=="], 78 + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA=="], 99 79 100 - "@libsql/linux-x64-musl": ["@libsql/linux-x64-musl@0.5.4", "", { "os": "linux", "cpu": "x64" }, "sha512-XzyVdVe43MexkAaHzUvsi4tpPhfSDn3UndIYFrIu0lYkkiz4oKjTK7Iq96j2bcOeJv0pBGxiv+8Z9I6yp/aI2A=="], 80 + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q=="], 101 81 102 - "@libsql/win32-x64-msvc": ["@libsql/win32-x64-msvc@0.5.4", "", { "os": "win32", "cpu": "x64" }, "sha512-xWQyAQEsX+odBrMSXTpm3WOFeoJIX7QncCkaZcsaqdEFueOdNDIdcKAQKMoNlwtj1rCxE72RK4byw/Bflf6Jgg=="], 82 + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg=="], 103 83 104 - "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], 84 + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.2", "", { "os": "win32", "cpu": "x64" }, "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA=="], 105 85 106 86 "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], 107 87 ··· 189 169 190 170 "@types/node": ["@types/node@22.13.17", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-nAJuQXoyPj04uLgu+obZcSmsfOenUg6DxPKogeUy6yNCFwWaj5sBF8/G/pNo8EtBJjAfSVgfIlugR/BCOleO+g=="], 191 171 192 - "@types/pg": ["@types/pg@8.6.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w=="], 172 + "@types/pg": ["@types/pg@8.11.13", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, "sha512-6kXByGkvRvwXLuyaWzsebs2du6+XuAB2CuMsuzP7uaihQahshVgSmB22Pmh0vQMkQ1h5+PZU0d+Di1o+WpVWJg=="], 193 173 194 174 "@types/pg-pool": ["@types/pg-pool@2.0.6", "", { "dependencies": { "@types/pg": "*" } }, "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ=="], 195 175 ··· 219 199 220 200 "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 221 201 222 - "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], 223 - 224 202 "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA=="], 225 203 226 - "detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], 227 - 228 - "drizzle-kit": ["drizzle-kit@0.30.6", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0", "gel": "^2.0.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-U4wWit0fyZuGuP7iNmRleQyK2V8wCuv57vf5l3MnG4z4fzNTjY/U13M8owyQ5RavqvqxBifWORaR3wIUzlN64g=="], 204 + "drizzle-kit": ["drizzle-kit@0.31.0", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.2", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-pcKVT+GbfPA+bUovPIilgVOoq+onNBo/YQBG86sf3/GFHkN6lRJPm1l7dKN0IMAk57RQoIm4GUllRrasLlcaSg=="], 229 205 230 - "drizzle-orm": ["drizzle-orm@0.41.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-7A4ZxhHk9gdlXmTdPj/lREtP+3u8KvZ4yEN6MYVxBzZGex5Wtdc+CWSbu7btgF6TB0N+MNPrvW7RKBbxJchs/Q=="], 206 + "drizzle-orm": ["drizzle-orm@0.42.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-pS8nNJm2kBNZwrOjTHJfdKkaU+KuUQmV/vk5D57NojDq4FG+0uAYGMulXtYT///HfgsMF0hnFFvu1ezI3OwOkg=="], 231 207 232 208 "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], 233 209 234 - "esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], 210 + "esbuild": ["esbuild@0.25.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.2", "@esbuild/android-arm": "0.25.2", "@esbuild/android-arm64": "0.25.2", "@esbuild/android-x64": "0.25.2", "@esbuild/darwin-arm64": "0.25.2", "@esbuild/darwin-x64": "0.25.2", "@esbuild/freebsd-arm64": "0.25.2", "@esbuild/freebsd-x64": "0.25.2", "@esbuild/linux-arm": "0.25.2", "@esbuild/linux-arm64": "0.25.2", "@esbuild/linux-ia32": "0.25.2", "@esbuild/linux-loong64": "0.25.2", "@esbuild/linux-mips64el": "0.25.2", "@esbuild/linux-ppc64": "0.25.2", "@esbuild/linux-riscv64": "0.25.2", "@esbuild/linux-s390x": "0.25.2", "@esbuild/linux-x64": "0.25.2", "@esbuild/netbsd-arm64": "0.25.2", "@esbuild/netbsd-x64": "0.25.2", "@esbuild/openbsd-arm64": "0.25.2", "@esbuild/openbsd-x64": "0.25.2", "@esbuild/sunos-x64": "0.25.2", "@esbuild/win32-arm64": "0.25.2", "@esbuild/win32-ia32": "0.25.2", "@esbuild/win32-x64": "0.25.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ=="], 235 211 236 212 "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], 237 - 238 - "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], 239 - 240 - "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], 241 213 242 214 "forwarded-parse": ["forwarded-parse@2.1.2", "", {}, "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw=="], 243 215 ··· 255 227 256 228 "isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], 257 229 258 - "js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="], 259 - 260 - "libsql": ["libsql@0.5.4", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.4", "@libsql/darwin-x64": "0.5.4", "@libsql/linux-arm64-gnu": "0.5.4", "@libsql/linux-arm64-musl": "0.5.4", "@libsql/linux-x64-gnu": "0.5.4", "@libsql/linux-x64-musl": "0.5.4", "@libsql/win32-x64-msvc": "0.5.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-GEFeWca4SDAQFxjHWJBE6GK52LEtSskiujbG3rqmmeTO9t4sfSBKIURNLLpKDDF7fb7jmTuuRkDAn9BZGITQNw=="], 261 - 262 230 "module-details-from-path": ["module-details-from-path@1.0.3", "", {}, "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A=="], 263 231 264 232 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 265 233 266 - "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], 234 + "obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="], 267 235 268 - "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], 236 + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], 269 237 270 - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], 238 + "pg": ["pg@8.14.1", "", { "dependencies": { "pg-connection-string": "^2.7.0", "pg-pool": "^3.8.0", "pg-protocol": "^1.8.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.1.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw=="], 239 + 240 + "pg-cloudflare": ["pg-cloudflare@1.1.1", "", {}, "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q=="], 241 + 242 + "pg-connection-string": ["pg-connection-string@2.7.0", "", {}, "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA=="], 271 243 272 244 "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], 273 245 246 + "pg-numeric": ["pg-numeric@1.0.2", "", {}, "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw=="], 247 + 248 + "pg-pool": ["pg-pool@3.8.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw=="], 249 + 274 250 "pg-protocol": ["pg-protocol@1.8.0", "", {}, "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g=="], 275 251 276 - "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], 252 + "pg-types": ["pg-types@4.0.2", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng=="], 253 + 254 + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], 277 255 278 - "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], 256 + "postgres-array": ["postgres-array@3.0.4", "", {}, "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ=="], 279 257 280 - "postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="], 258 + "postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw=="], 281 259 282 - "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], 260 + "postgres-date": ["postgres-date@2.1.0", "", {}, "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA=="], 283 261 284 - "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], 262 + "postgres-interval": ["postgres-interval@3.0.0", "", {}, "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw=="], 285 263 286 - "promise-limit": ["promise-limit@2.7.0", "", {}, "sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw=="], 264 + "postgres-range": ["postgres-range@1.1.4", "", {}, "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w=="], 287 265 288 266 "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], 289 267 ··· 311 289 312 290 "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], 313 291 292 + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], 293 + 314 294 "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], 315 295 316 296 "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], 317 297 318 298 "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], 319 299 320 - "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], 321 - 322 300 "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], 323 301 324 - "ws": ["ws@8.18.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w=="], 325 - 326 302 "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], 327 303 328 304 "yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], ··· 333 309 334 310 "@opentelemetry/instrumentation-http/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], 335 311 312 + "@opentelemetry/instrumentation-pg/@types/pg": ["@types/pg@8.6.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w=="], 313 + 336 314 "@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], 337 315 338 316 "@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], 339 317 318 + "@types/pg-pool/@types/pg": ["@types/pg@8.6.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w=="], 319 + 320 + "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], 321 + 340 322 "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], 341 323 342 324 "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], ··· 380 362 "@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="], 381 363 382 364 "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], 365 + 366 + "@opentelemetry/instrumentation-pg/@types/pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], 367 + 368 + "@types/pg-pool/@types/pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], 369 + 370 + "pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], 371 + 372 + "pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="], 373 + 374 + "pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], 375 + 376 + "pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], 377 + 378 + "@opentelemetry/instrumentation-pg/@types/pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], 379 + 380 + "@opentelemetry/instrumentation-pg/@types/pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="], 381 + 382 + "@opentelemetry/instrumentation-pg/@types/pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], 383 + 384 + "@opentelemetry/instrumentation-pg/@types/pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], 385 + 386 + "@types/pg-pool/@types/pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], 387 + 388 + "@types/pg-pool/@types/pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="], 389 + 390 + "@types/pg-pool/@types/pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], 391 + 392 + "@types/pg-pool/@types/pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], 383 393 } 384 394 }
+16 -7
drizzle.config.ts
··· 1 1 import type { Config } from "drizzle-kit"; 2 2 3 + // Parse connection string from environment variable 4 + const databaseUrl = process.env.DATABASE_URL || ""; 5 + const url = new URL(databaseUrl); 6 + 3 7 export default { 4 - schema: "./src/libs/schema.ts", 5 - out: "./migrations", 6 - dialect: "sqlite", 7 - dbCredentials: { 8 - url: "./local.db", 9 - }, 10 - } satisfies Config; 8 + schema: "./src/libs/schema.ts", 9 + out: "./migrations", 10 + dialect: "postgresql", 11 + dbCredentials: { 12 + host: url.hostname, 13 + port: Number.parseInt(url.port), 14 + user: url.username, 15 + password: url.password, 16 + database: url.pathname.slice(1), 17 + ssl: url.searchParams.get("sslmode") === "require", 18 + }, 19 + } satisfies Config;
+5 -3
package.json
··· 14 14 "db:push": "drizzle-kit push" 15 15 }, 16 16 "devDependencies": { 17 - "@types/bun": "latest" 17 + "@types/bun": "latest", 18 + "@types/pg": "^8.11.13" 18 19 }, 19 20 "peerDependencies": { 20 21 "typescript": "^5" ··· 25 26 "@types/react-dom": "^19.1.2", 26 27 "bottleneck": "^2.19.5", 27 28 "colors": "^1.4.0", 28 - "drizzle-kit": "^0.30.6", 29 - "drizzle-orm": "^0.41.0", 29 + "drizzle-kit": "^0.31.0", 30 + "drizzle-orm": "^0.42.0", 31 + "pg": "^8.14.1", 30 32 "react": "^19.1.0", 31 33 "react-dom": "^19.1.0", 32 34 "slack-edge": "^1.3.7",
+2 -4
src/features/api/index.ts
··· 1 - import { recentTakes, takesPerUser } from "./routes/recentTakes"; 1 + import { recentTakes } from "./routes/recentTakes"; 2 2 import video from "./routes/video"; 3 3 import { handleApiError } from "../../libs/apiError"; 4 4 ··· 12 12 case "video": 13 13 return await video(url); 14 14 case "recentTakes": 15 - return await recentTakes(); 16 - case "takesPerUser": 17 - return await takesPerUser(url.pathname.split("/")[3] as string); 15 + return await recentTakes(url); 18 16 default: 19 17 return new Response( 20 18 JSON.stringify({ error: "Route not found" }),
+26 -87
src/features/api/routes/recentTakes.ts
··· 3 3 import { takes as takesTable } from "../../../libs/schema"; 4 4 import { handleApiError } from "../../../libs/apiError"; 5 5 6 - export async function recentTakes(): Promise<Response> { 6 + export type RecentTake = { 7 + id: string; 8 + userId: string; 9 + notes: string; 10 + createdAt: Date; 11 + mediaUrls: string[]; 12 + elapsedTimeMs: number; 13 + }; 14 + 15 + export async function recentTakes(url: URL): Promise<Response> { 7 16 try { 8 - const recentTakes = await db 17 + const userId = url.searchParams.get("user"); 18 + 19 + const query = db 9 20 .select() 10 21 .from(takesTable) 11 - .where( 12 - or( 13 - eq(takesTable.status, "approved"), 14 - eq(takesTable.status, "uploaded"), 15 - ), 16 - ) 17 - .orderBy(desc(takesTable.completedAt)) 22 + .orderBy(desc(takesTable.createdAt)) 23 + .where(eq(takesTable.userId, userId ? userId : takesTable.userId)) 18 24 .limit(40); 25 + 26 + const recentTakes = await query; 19 27 20 28 if (recentTakes.length === 0) { 21 29 return new Response( ··· 30 38 ); 31 39 } 32 40 33 - const takes = recentTakes.map((take) => ({ 34 - id: take.id, 35 - userId: take.userId, 36 - description: take.description, 37 - completedAt: take.completedAt, 38 - status: take.status, 39 - mp4Url: take.takeUrl, 40 - elapsedTime: take.elapsedTimeMs, 41 - })); 41 + const takes: RecentTake[] = 42 + recentTakes.map((take) => ({ 43 + id: take.id, 44 + userId: take.userId, 45 + notes: take.notes, 46 + createdAt: new Date(take.createdAt), 47 + mediaUrls: take.media ? JSON.parse(take.media) : [], 48 + elapsedTimeMs: take.elapsedTimeMs, 49 + })) || []; 42 50 43 51 return new Response( 44 52 JSON.stringify({ ··· 54 62 return handleApiError(error, "recentTakes"); 55 63 } 56 64 } 57 - 58 - export async function takesPerUser(userId: string): Promise<Response> { 59 - try { 60 - const rawTakes = await db 61 - .select() 62 - .from(takesTable) 63 - .where(and(eq(takesTable.userId, userId))) 64 - .orderBy(desc(takesTable.completedAt)); 65 - 66 - const takes = rawTakes.map((take) => ({ 67 - id: take.id, 68 - description: take.description, 69 - completedAt: take.completedAt, 70 - status: take.status, 71 - mp4Url: take.takeUrl, 72 - elapsedTime: take.elapsedTimeMs, 73 - })); 74 - 75 - const approvedTakes = rawTakes.reduce((acc, take) => { 76 - if (take.status !== "approved") return acc; 77 - const multiplier = Number.parseFloat(take.multiplier || "1.0"); 78 - return Number( 79 - ( 80 - acc + 81 - (take.elapsedTimeMs * multiplier) / (1000 * 60 * 60) 82 - ).toFixed(1), 83 - ); 84 - }, 0); 85 - 86 - const waitingTakes = rawTakes.reduce((acc, take) => { 87 - if (take.status !== "waitingUpload" && take.status !== "uploaded") 88 - return acc; 89 - const multiplier = Number.parseFloat(take.multiplier || "1.0"); 90 - return Number( 91 - ( 92 - acc + 93 - (take.elapsedTimeMs * multiplier) / (1000 * 60 * 60) 94 - ).toFixed(1), 95 - ); 96 - }, 0); 97 - 98 - const rejectedTakes = rawTakes.reduce((acc, take) => { 99 - if (take.status !== "rejected") return acc; 100 - const multiplier = Number.parseFloat(take.multiplier || "1.0"); 101 - return Number( 102 - ( 103 - acc + 104 - (take.elapsedTimeMs * multiplier) / (1000 * 60 * 60) 105 - ).toFixed(1), 106 - ); 107 - }, 0); 108 - 109 - return new Response( 110 - JSON.stringify({ 111 - approvedTakes, 112 - waitingTakes, 113 - rejectedTakes, 114 - takes, 115 - }), 116 - { 117 - headers: { 118 - "Content-Type": "application/json", 119 - }, 120 - }, 121 - ); 122 - } catch (error) { 123 - return handleApiError(error, "takesPerUser"); 124 - } 125 - }
+48 -69
src/features/api/routes/video.ts
··· 1 1 import { handleApiError } from "../../../libs/apiError"; 2 - import { db } from "../../../libs/db"; 3 - import { takes as takesTable } from "../../../libs/schema"; 4 - import { eq, and } from "drizzle-orm"; 5 2 6 3 export default async function getVideo(url: URL): Promise<Response> { 7 4 try { 8 - const path = url.pathname.split("/").filter(Boolean); 9 - const videoId = path[2]; 10 - const thumbnail = path[3] === "thumbnail"; 11 - 12 - if (!videoId) { 13 - return new Response(JSON.stringify({ error: "Invalid video id" }), { 14 - status: 400, 15 - headers: { "Content-Type": "application/json" }, 16 - }); 17 - } 18 - 19 - const video = await db 20 - .select() 21 - .from(takesTable) 22 - .where(eq(takesTable.id, videoId)); 5 + const params = new URLSearchParams(url.search); 6 + const mediaSource = params.get("media"); 23 7 24 - if (video.length === 0) { 25 - return new Response(JSON.stringify({ error: "Video not found" }), { 26 - status: 404, 27 - headers: { "Content-Type": "application/json" }, 28 - }); 29 - } 30 - 31 - const videoData = video[0]; 32 - 33 - if (thumbnail) { 34 - return Response.redirect( 35 - `https://cachet.dunkirk.sh/users/${videoData?.userId}/r`, 8 + if (!mediaSource) { 9 + return new Response( 10 + JSON.stringify({ error: "No media source provided" }), 11 + { 12 + status: 400, 13 + headers: { "Content-Type": "application/json" }, 14 + }, 36 15 ); 37 16 } 38 17 39 18 return new Response( 40 19 `<!DOCTYPE html> 41 - <html> 42 - <head> 43 - <title>Video Player</title> 44 - <style> 45 - body, html { 46 - margin: 0; 47 - padding: 0; 48 - height: 100vh; 49 - overflow: hidden; 50 - } 51 - .video-container { 52 - position: fixed; 53 - top: 0; 54 - left: 0; 55 - width: 100vw; 56 - height: 100vh; 57 - display: flex; 58 - flex-direction: column; 59 - justify-content: center; 60 - background: linear-gradient(180deg, #000000 25%, #ffffff 50%, #000000 75%); 61 - } 62 - video { 63 - width: 100vw; 64 - height: 100vh; 65 - object-fit: contain; 66 - position: absolute; 67 - bottom: 0; 68 - } 69 - </style> 70 - </head> 71 - <body> 72 - <div class="video-container"> 73 - <video autoplay controls> 74 - <source src="${videoData?.takeUrl}" type="video/mp4"> 75 - Your browser does not support the video tag. 76 - </video> 77 - </div> 78 - </body> 79 - </html>`, 20 + <html> 21 + <head> 22 + <title>Video Player</title> 23 + <style> 24 + body, html { 25 + margin: 0; 26 + padding: 0; 27 + height: 100vh; 28 + overflow: hidden; 29 + } 30 + .video-container { 31 + position: fixed; 32 + top: 0; 33 + left: 0; 34 + width: 100vw; 35 + height: 100vh; 36 + display: flex; 37 + flex-direction: column; 38 + justify-content: center; 39 + background: linear-gradient(180deg, #000000 25%, #ffffff 50%, #000000 75%); 40 + } 41 + video { 42 + width: 100vw; 43 + height: 100vh; 44 + object-fit: contain; 45 + position: absolute; 46 + bottom: 0; 47 + } 48 + </style> 49 + </head> 50 + <body> 51 + <div class="video-container"> 52 + <video autoplay controls> 53 + <source src="${mediaSource}" type="video/mp4"> 54 + Your browser does not support the video tag. 55 + </video> 56 + </div> 57 + </body> 58 + </html>`, 80 59 { 81 60 headers: { 82 61 "Content-Type": "text/html",
+40 -36
src/features/frontend/app.tsx
··· 1 1 import { useEffect, useState } from "react"; 2 2 import { prettyPrintTime } from "../../libs/time"; 3 3 import { fetchUserData } from "../../libs/cachet"; 4 + import type { RecentTake } from "../api/routes/recentTakes"; 4 5 5 6 export function App() { 6 - const [takes, setTakes] = useState< 7 - { 8 - id: string; 9 - userId: string; 10 - description: string; 11 - completedAt: Date; 12 - status: string; 13 - mp4Url: string; 14 - elapsedTime: number; 15 - }[] 16 - >([]); 7 + const [takes, setTakes] = useState<RecentTake[]>([]); 17 8 18 9 const [userData, setUserData] = useState<{ 19 10 [key: string]: { displayName: string; imageUrl: string }; ··· 44 35 async function getTakes() { 45 36 const res = await fetch("/api/recentTakes"); 46 37 const data = await res.json(); 38 + 39 + console.log(data); 47 40 setTakes(data.takes); 48 41 } 49 42 getTakes(); ··· 56 49 {takes.map((take) => ( 57 50 <div key={take.id} className="take-card"> 58 51 <div className="take-header"> 59 - <h2 className="take-title">{take.description}</h2> 52 + <h2 className="take-title">{take.notes}</h2> 60 53 <div className="user-pill"> 61 54 <div className="user-info"> 62 55 <img ··· 69 62 take.userId} 70 63 </span> 71 64 </div> 72 - <span 73 - className={`status-badge status-${take.status}`} 74 - > 75 - {take.status} 76 - </span> 77 65 </div> 78 66 </div> 79 67 ··· 81 69 <div className="meta-item"> 82 70 <span className="meta-label">Completed:</span> 83 71 <span className="meta-value"> 84 - {new Date( 85 - take.completedAt, 86 - ).toLocaleString()} 72 + {new Date(take.createdAt).toLocaleString()} 87 73 </span> 88 74 </div> 89 75 <div className="meta-item"> 90 76 <span className="meta-label">Duration:</span> 91 77 <span className="meta-value"> 92 - {prettyPrintTime(take.elapsedTime)} 78 + {prettyPrintTime(take.elapsedTimeMs)} 93 79 </span> 94 80 </div> 95 81 </div> 96 82 97 - {take.mp4Url && ( 98 - <div className="video-container"> 99 - <video controls className="take-video"> 100 - <source 101 - src={take.mp4Url} 102 - type="video/mp4" 103 - /> 104 - <track 105 - kind="captions" 106 - src="" 107 - label="Captions" 108 - /> 109 - </video> 110 - </div> 111 - )} 83 + {take.mediaUrls?.map((url: string, index: number) => { 84 + const isVideo = url.endsWith(".mp4"); 85 + return ( 86 + <div 87 + key={`media-${take.id}-${index}`} 88 + className={ 89 + isVideo 90 + ? "video-container" 91 + : "image-container" 92 + } 93 + > 94 + {isVideo ? ( 95 + <video controls className="take-video"> 96 + <source 97 + src={url} 98 + type="video/mp4" 99 + /> 100 + <track 101 + kind="captions" 102 + src="" 103 + label="Captions" 104 + /> 105 + </video> 106 + ) : ( 107 + <img 108 + src={url} 109 + alt="" 110 + className="take-image" 111 + /> 112 + )} 113 + </div> 114 + ); 115 + })} 112 116 </div> 113 117 ))} 114 118 </div>
+5 -6
src/features/takes/handlers/help.ts
··· 1 - import TakesConfig from "../../../libs/config"; 2 1 import type { MessageResponse } from "../types"; 3 2 4 3 export default async function handleHelp(): Promise<MessageResponse> { 5 4 return { 6 - text: `*Takes Commands*\n\n• \`/takes start [description]\` - Start a new takes session, optionally specifying description\n• \`/takes pause\` - Pause your current session (max ${TakesConfig.MAX_PAUSE_DURATION} min)\n• \`/takes resume\` - Resume your paused session\n• \`/takes stop [notes]\` - End your current session with optional notes\n• \`/takes status\` - Check the status of your session\n• \`/takes history\` - View your past takes sessions`, 5 + text: "*Takes Commands*\n\n• `/takes history` - View your past takes sessions", 7 6 response_type: "ephemeral", 8 7 blocks: [ 9 8 { ··· 17 16 type: "section", 18 17 text: { 19 18 type: "mrkdwn", 20 - text: `• \`/takes start [minutes]\` - Start a new session (default: ${TakesConfig.DEFAULT_SESSION_LENGTH} min)\n• \`/takes pause\` - Pause your session (max ${TakesConfig.MAX_PAUSE_DURATION} min)\n• \`/takes resume\` - Resume your paused session\n• \`/takes stop [notes]\` - End session with optional notes\n• \`/takes status\` - Check status\n• \`/takes history\` - View past sessions`, 19 + text: "• `/takes history` - View your past takes sessions", 21 20 }, 22 21 }, 23 22 { ··· 27 26 type: "button", 28 27 text: { 29 28 type: "plain_text", 30 - text: "🎬 Start New Session", 29 + text: "🏡 Home", 31 30 emoji: true, 32 31 }, 33 - value: "start", 34 - action_id: "takes_start", 32 + value: "status", 33 + action_id: "takes_home", 35 34 }, 36 35 { 37 36 type: "button",
+21 -38
src/features/takes/handlers/history.ts
··· 1 1 import type { AnyMessageBlock } from "slack-edge"; 2 - import TakesConfig from "../../../libs/config"; 3 - import { getCompletedTakes } from "../services/database"; 4 2 import type { MessageResponse } from "../types"; 5 - import { calculateElapsedTime } from "../../../libs/time-periods"; 6 3 import { prettyPrintTime } from "../../../libs/time"; 4 + import { db } from "../../../libs/db"; 5 + import { takes as takesTable } from "../../../libs/schema"; 6 + import { eq, and, desc } from "drizzle-orm"; 7 7 8 8 export async function handleHistory(userId: string): Promise<MessageResponse> { 9 - // Get completed takes for the user 10 - const completedTakes = ( 11 - await getCompletedTakes(userId, TakesConfig.MAX_HISTORY_ITEMS) 12 - ).sort( 13 - (a, b) => 14 - (b.completedAt?.getTime() ?? 0) - (a.completedAt?.getTime() ?? 0), 15 - ); 9 + const takes = await db 10 + .select() 11 + .from(takesTable) 12 + .where(and(eq(takesTable.userId, userId))) 13 + .orderBy(desc(takesTable.createdAt)); 16 14 17 - if (completedTakes.length === 0) { 18 - return { 19 - text: "You haven't completed any takes sessions yet.", 20 - response_type: "ephemeral", 21 - }; 22 - } 15 + const takeTimeMs = takes.reduce( 16 + (acc, take) => acc + take.elapsedTimeMs * Number(take.multiplier), 17 + 0, 18 + ); 19 + const takeTime = prettyPrintTime(takeTimeMs); 23 20 24 21 // Create blocks for each completed take 25 22 const historyBlocks: AnyMessageBlock[] = [ ··· 27 24 type: "header", 28 25 text: { 29 26 type: "plain_text", 30 - text: `📋 Your most recent ${completedTakes.length} Takes sessions`, 27 + text: `📋 you have uploaded ${takes.length} notes for a total of ${takeTime}`, 31 28 emoji: true, 32 29 }, 33 30 }, 34 31 ]; 35 32 36 - for (const take of completedTakes) { 37 - const elapsedTime = calculateElapsedTime(JSON.parse(take.periods)); 38 - 33 + for (const take of takes) { 39 34 const notes = take.notes ? `\n• Notes: ${take.notes}` : ""; 40 - const description = take.description 41 - ? `\n• Description: ${take.description}\n` 42 - : ""; 35 + const duration = prettyPrintTime(take.elapsedTimeMs); 43 36 44 37 historyBlocks.push({ 45 38 type: "section", 46 39 text: { 47 40 type: "mrkdwn", 48 - text: `*Duration:* \`${prettyPrintTime(elapsedTime)}\`\n*Status:* ${take.status}\n${notes ? `*Notes:* ${take.notes}\n` : ""}${description ? `*Description:* ${take.description}\n` : ""}`, 41 + text: `*Duration:* \`${duration}\`\n${notes ? `*Notes:* ${take.notes}\n` : ""}${take.multiplier !== "1.0" ? `\n*Multiplier:* ${take.multiplier}\n` : ""}`, 49 42 }, 50 43 }); 51 44 52 45 // Add a divider between entries 53 - if (take !== completedTakes[completedTakes.length - 1]) { 46 + if (take !== takes[takes.length - 1]) { 54 47 historyBlocks.push({ 55 48 type: "divider", 56 49 }); ··· 65 58 type: "button", 66 59 text: { 67 60 type: "plain_text", 68 - text: "🎬 Start New Session", 69 - emoji: true, 70 - }, 71 - value: "start", 72 - action_id: "takes_start", 73 - }, 74 - { 75 - type: "button", 76 - text: { 77 - type: "plain_text", 78 - text: "👁️ Status", 61 + text: "🏡 Home", 79 62 emoji: true, 80 63 }, 81 64 value: "status", 82 - action_id: "takes_status", 65 + action_id: "takes_home", 83 66 }, 84 67 { 85 68 type: "button", ··· 95 78 }); 96 79 97 80 return { 98 - text: `Your recent takes history (${completedTakes.length} sessions)`, 81 + text: `${takes.length} notes for a total of ${takeTime}`, 99 82 response_type: "ephemeral", 100 83 blocks: historyBlocks, 101 84 };
+12 -29
src/features/takes/handlers/home.ts
··· 13 13 .where(and(eq(takesTable.userId, userId))) 14 14 .orderBy(desc(takesTable.createdAt)); 15 15 16 - const approvedTakes = takes.reduce((acc, take) => { 17 - if (take.status !== "approved") return acc; 18 - const multiplier = Number.parseFloat(take.multiplier || "1.0"); 19 - const hoursElapsed = 20 - (take.elapsedTimeMs * multiplier) / (1000 * 60 * 60); 21 - return Number((acc + hoursElapsed).toFixed(1)); 22 - }, 0); 23 - 24 - const waitingTakesStats = takes.reduce( 25 - (acc: { count: number; hours: number }, take) => { 26 - if (take.status !== "waitingUpload" && take.status !== "uploaded") 27 - return acc; 28 - const multiplier = Number.parseFloat(take.multiplier || "1.0"); 29 - const hoursElapsed = 30 - (take.elapsedTimeMs * multiplier) / (1000 * 60 * 60); 31 - return { 32 - count: acc.count + 1, 33 - hours: Number((acc.hours + hoursElapsed).toFixed(1)), 34 - }; 35 - }, 36 - { count: 0, hours: 0 }, 16 + const takeTimeMs = takes.reduce( 17 + (acc, take) => acc + take.elapsedTimeMs * Number(take.multiplier), 18 + 0, 37 19 ); 20 + const takeTime = prettyPrintTime(takeTimeMs); 38 21 39 22 return { 40 - text: `You have logged \`${approvedTakes}\` approved takes!`, 23 + text: `You have logged ${takeTime} of takes!`, 41 24 response_type: "ephemeral", 42 25 blocks: [ 43 26 { ··· 51 34 type: "section", 52 35 text: { 53 36 type: "mrkdwn", 54 - text: `You have logged \`${approvedTakes}\` takes! \n\n*Pending Approval:* \`${waitingTakesStats.count}\` sessions, \`${waitingTakesStats.hours}\` hours total`, 37 + text: `You have logged ${takeTime} of takes!`, 55 38 }, 56 39 }, 57 40 { ··· 61 44 type: "button", 62 45 text: { 63 46 type: "plain_text", 64 - text: "🎬 Start New Session", 47 + text: "📋 History", 65 48 emoji: true, 66 49 }, 67 - value: "start", 68 - action_id: "takes_start", 50 + value: "history", 51 + action_id: "takes_history", 69 52 }, 70 53 { 71 54 type: "button", 72 55 text: { 73 56 type: "plain_text", 74 - text: "📋 History", 57 + text: "🔄 Refresh", 75 58 emoji: true, 76 59 }, 77 - value: "history", 78 - action_id: "takes_history", 60 + value: "status", 61 + action_id: "takes_home", 79 62 }, 80 63 ], 81 64 },
-122
src/features/takes/handlers/pause.ts
··· 1 - import { db } from "../../../libs/db"; 2 - import { takes as takesTable } from "../../../libs/schema"; 3 - import { eq } from "drizzle-orm"; 4 - import TakesConfig from "../../../libs/config"; 5 - import { getActiveTake } from "../services/database"; 6 - import type { MessageResponse } from "../types"; 7 - import { generateSlackDate, prettyPrintTime } from "../../../libs/time"; 8 - import { 9 - addNewPeriod, 10 - getPausedTimeRemaining, 11 - getRemainingTime, 12 - } from "../../../libs/time-periods"; 13 - 14 - export default async function handlePause( 15 - userId: string, 16 - ): Promise<MessageResponse | undefined> { 17 - const activeTake = await getActiveTake(userId); 18 - if (activeTake.length === 0) { 19 - return { 20 - text: `You don't have an active takes session! Use \`/takes start\` to begin.`, 21 - response_type: "ephemeral", 22 - }; 23 - } 24 - 25 - const takeToUpdate = activeTake[0]; 26 - if (!takeToUpdate) { 27 - return; 28 - } 29 - 30 - const newPeriods = JSON.stringify( 31 - addNewPeriod(takeToUpdate.periods, "paused"), 32 - ); 33 - 34 - const pausedTime = getPausedTimeRemaining(newPeriods); 35 - const endTime = getRemainingTime( 36 - takeToUpdate.targetDurationMs, 37 - takeToUpdate.periods, 38 - ); 39 - 40 - if (pausedTime > TakesConfig.MAX_PAUSE_DURATION * 60000) { 41 - return { 42 - text: `You can't pause for more than ${TakesConfig.MAX_PAUSE_DURATION} minutes!`, 43 - response_type: "ephemeral", 44 - }; 45 - } 46 - 47 - // Update the takes entry to paused status 48 - await db 49 - .update(takesTable) 50 - .set({ 51 - status: "paused", 52 - periods: newPeriods, 53 - notifiedPauseExpiration: false, // Reset pause expiration notification 54 - }) 55 - .where(eq(takesTable.id, takeToUpdate.id)); 56 - 57 - const descriptionText = takeToUpdate.description 58 - ? `\n\n*Working on:* ${takeToUpdate.description}` 59 - : ""; 60 - 61 - return { 62 - text: `⏸️ Session paused! You have ${prettyPrintTime(endTime.remaining)} remaining. It will automatically finish at ${generateSlackDate(new Date(Date.now() + TakesConfig.MAX_PAUSE_DURATION * 60000))}`, 63 - response_type: "ephemeral", 64 - blocks: [ 65 - { 66 - type: "section", 67 - text: { 68 - type: "mrkdwn", 69 - text: `⏸️ Session paused! You have ${prettyPrintTime(endTime.remaining)} remaining.${descriptionText}`, 70 - }, 71 - }, 72 - { 73 - type: "divider", 74 - }, 75 - { 76 - type: "context", 77 - elements: [ 78 - { 79 - type: "mrkdwn", 80 - text: `It will automatically finish in ${prettyPrintTime(pausedTime)} (by ${generateSlackDate(new Date(new Date().getTime() - pausedTime))}) if not resumed.`, 81 - }, 82 - ], 83 - }, 84 - { 85 - type: "actions", 86 - elements: [ 87 - { 88 - type: "button", 89 - text: { 90 - type: "plain_text", 91 - text: "▶️ Resume", 92 - emoji: true, 93 - }, 94 - value: "resume", 95 - action_id: "takes_resume", 96 - }, 97 - { 98 - type: "button", 99 - text: { 100 - type: "plain_text", 101 - text: "⏹️ Stop", 102 - emoji: true, 103 - }, 104 - value: "stop", 105 - action_id: "takes_stop", 106 - style: "danger", 107 - }, 108 - { 109 - type: "button", 110 - text: { 111 - type: "plain_text", 112 - text: "🔄 Refresh", 113 - emoji: true, 114 - }, 115 - value: "status", 116 - action_id: "takes_status", 117 - }, 118 - ], 119 - }, 120 - ], 121 - }; 122 - }
-121
src/features/takes/handlers/resume.ts
··· 1 - import { db } from "../../../libs/db"; 2 - import { takes as takesTable } from "../../../libs/schema"; 3 - import { eq } from "drizzle-orm"; 4 - import { generateSlackDate, prettyPrintTime } from "../../../libs/time"; 5 - import { getPausedTake } from "../services/database"; 6 - import type { MessageResponse } from "../types"; 7 - import { addNewPeriod, getRemainingTime } from "../../../libs/time-periods"; 8 - 9 - export default async function handleResume( 10 - userId: string, 11 - ): Promise<MessageResponse | undefined> { 12 - const pausedTake = await getPausedTake(userId); 13 - if (pausedTake.length === 0) { 14 - return { 15 - text: `You don't have a paused takes session!`, 16 - response_type: "ephemeral", 17 - }; 18 - } 19 - 20 - const pausedSession = pausedTake[0]; 21 - if (!pausedSession) { 22 - return; 23 - } 24 - 25 - const now = new Date(); 26 - const newPeriods = JSON.stringify( 27 - addNewPeriod(pausedSession.periods, "active"), 28 - ); 29 - 30 - // Update the takes entry to active status 31 - await db 32 - .update(takesTable) 33 - .set({ 34 - status: "active", 35 - lastResumeAt: now, 36 - periods: newPeriods, 37 - notifiedLowTime: false, // Reset low time notification 38 - }) 39 - .where(eq(takesTable.id, pausedSession.id)); 40 - 41 - const endTime = getRemainingTime( 42 - pausedSession.targetDurationMs, 43 - pausedSession.periods, 44 - ); 45 - 46 - const descriptionText = pausedSession.description 47 - ? `\n\n*Working on:* ${pausedSession.description}` 48 - : ""; 49 - 50 - return { 51 - text: `▶️ Takes session resumed! You have ${prettyPrintTime(endTime.remaining)} remaining in your session.`, 52 - response_type: "ephemeral", 53 - blocks: [ 54 - { 55 - type: "section", 56 - text: { 57 - type: "mrkdwn", 58 - text: `▶️ Takes session resumed!${descriptionText}`, 59 - }, 60 - }, 61 - { 62 - type: "divider", 63 - }, 64 - { 65 - type: "context", 66 - elements: [ 67 - { 68 - type: "mrkdwn", 69 - text: `You have ${prettyPrintTime(endTime.remaining)} remaining until ${generateSlackDate(endTime.endTime)}.`, 70 - }, 71 - ], 72 - }, 73 - { 74 - type: "actions", 75 - elements: [ 76 - { 77 - type: "button", 78 - text: { 79 - type: "plain_text", 80 - text: "✍️ edit", 81 - emoji: true, 82 - }, 83 - value: "edit", 84 - action_id: "takes_edit", 85 - }, 86 - { 87 - type: "button", 88 - text: { 89 - type: "plain_text", 90 - text: "⏸️ Pause", 91 - emoji: true, 92 - }, 93 - value: "pause", 94 - action_id: "takes_pause", 95 - }, 96 - { 97 - type: "button", 98 - text: { 99 - type: "plain_text", 100 - text: "⏹️ Stop", 101 - emoji: true, 102 - }, 103 - value: "stop", 104 - action_id: "takes_stop", 105 - style: "danger", 106 - }, 107 - { 108 - type: "button", 109 - text: { 110 - type: "plain_text", 111 - text: "🔄 Refresh", 112 - emoji: true, 113 - }, 114 - value: "status", 115 - action_id: "takes_status", 116 - }, 117 - ], 118 - }, 119 - ], 120 - }; 121 - }
-123
src/features/takes/handlers/start.ts
··· 1 - import type { MessageResponse } from "../types"; 2 - import { getActiveTake } from "../services/database"; 3 - import { db } from "../../../libs/db"; 4 - import { takes as takesTable } from "../../../libs/schema"; 5 - import TakesConfig from "../../../libs/config"; 6 - import { generateSlackDate, prettyPrintTime } from "../../../libs/time"; 7 - import { getRemainingTime } from "../../../libs/time-periods"; 8 - 9 - export default async function handleStart( 10 - userId: string, 11 - channelId: string, 12 - description?: string, 13 - ): Promise<MessageResponse> { 14 - const activeTake = await getActiveTake(userId); 15 - if (activeTake.length > 0) { 16 - return { 17 - text: "You already have an active takes session! Use `/takes status` to check it.", 18 - response_type: "ephemeral", 19 - }; 20 - } 21 - 22 - // Create new takes session 23 - const newTake = { 24 - id: Bun.randomUUIDv7(), 25 - userId, 26 - status: "active", 27 - targetDurationMs: TakesConfig.DEFAULT_SESSION_LENGTH * 60000, 28 - periods: JSON.stringify([ 29 - { 30 - type: "active", 31 - startTime: Date.now(), 32 - endTime: null, 33 - }, 34 - ]), 35 - elapsedTimeMs: 0, 36 - description: description || null, 37 - notifiedLowTime: false, 38 - notifiedPauseExpiration: false, 39 - }; 40 - 41 - await db.insert(takesTable).values(newTake); 42 - 43 - // Calculate end time for message 44 - const endTime = getRemainingTime( 45 - TakesConfig.DEFAULT_SESSION_LENGTH * 60000, 46 - newTake.periods, 47 - ); 48 - 49 - const descriptionText = description 50 - ? `\n\n*Working on:* ${description}` 51 - : ""; 52 - return { 53 - text: `🎬 Takes session started! You have ${prettyPrintTime(endTime.remaining)} until ${generateSlackDate(endTime.endTime)}.${descriptionText}`, 54 - response_type: "ephemeral", 55 - blocks: [ 56 - { 57 - type: "section", 58 - text: { 59 - type: "mrkdwn", 60 - text: `🎬 Takes session started!${descriptionText}`, 61 - }, 62 - }, 63 - { 64 - type: "divider", 65 - }, 66 - { 67 - type: "context", 68 - elements: [ 69 - { 70 - type: "mrkdwn", 71 - text: `You have ${prettyPrintTime(endTime.remaining)} left until ${generateSlackDate(endTime.endTime)}.`, 72 - }, 73 - ], 74 - }, 75 - { 76 - type: "actions", 77 - elements: [ 78 - { 79 - type: "button", 80 - text: { 81 - type: "plain_text", 82 - text: "✍️ edit", 83 - emoji: true, 84 - }, 85 - value: "edit", 86 - action_id: "takes_edit", 87 - }, 88 - { 89 - type: "button", 90 - text: { 91 - type: "plain_text", 92 - text: "⏸️ Pause", 93 - emoji: true, 94 - }, 95 - value: "pause", 96 - action_id: "takes_pause", 97 - }, 98 - { 99 - type: "button", 100 - text: { 101 - type: "plain_text", 102 - text: "⏹️ Stop", 103 - emoji: true, 104 - }, 105 - value: "stop", 106 - action_id: "takes_stop", 107 - style: "danger", 108 - }, 109 - { 110 - type: "button", 111 - text: { 112 - type: "plain_text", 113 - text: "🔄 Refresh", 114 - emoji: true, 115 - }, 116 - value: "status", 117 - action_id: "takes_status", 118 - }, 119 - ], 120 - }, 121 - ], 122 - }; 123 - }
-253
src/features/takes/handlers/status.ts
··· 1 - import TakesConfig from "../../../libs/config"; 2 - import { generateSlackDate, prettyPrintTime } from "../../../libs/time"; 3 - import { 4 - getPausedTimeRemaining, 5 - getRemainingTime, 6 - } from "../../../libs/time-periods"; 7 - import { 8 - getActiveTake, 9 - getCompletedTakes, 10 - getPausedTake, 11 - } from "../services/database"; 12 - import { expirePausedSessions } from "../services/notifications"; 13 - import type { MessageResponse } from "../types"; 14 - 15 - export default async function handleStatus( 16 - userId: string, 17 - ): Promise<MessageResponse | undefined> { 18 - const activeTake = await getActiveTake(userId); 19 - 20 - // First, check for expired paused sessions 21 - await expirePausedSessions(); 22 - 23 - if (activeTake.length > 0) { 24 - const take = activeTake[0]; 25 - if (!take) { 26 - return; 27 - } 28 - 29 - const endTime = getRemainingTime(take.targetDurationMs, take.periods); 30 - 31 - // Add description to display if present 32 - const descriptionText = take.description 33 - ? `\n\n*Working on:* ${take.description}` 34 - : ""; 35 - 36 - return { 37 - text: `🎬 You have an active takes session with ${prettyPrintTime(endTime.remaining)} remaining.${descriptionText}`, 38 - response_type: "ephemeral", 39 - blocks: [ 40 - { 41 - type: "section", 42 - text: { 43 - type: "mrkdwn", 44 - text: `🎬 You have an active takes session${descriptionText}`, 45 - }, 46 - }, 47 - { 48 - type: "divider", 49 - }, 50 - { 51 - type: "context", 52 - elements: [ 53 - { 54 - type: "mrkdwn", 55 - text: `You have ${prettyPrintTime(endTime.remaining)} remaining until ${generateSlackDate(endTime.endTime)}.`, 56 - }, 57 - ], 58 - }, 59 - { 60 - type: "actions", 61 - elements: [ 62 - { 63 - type: "button", 64 - text: { 65 - type: "plain_text", 66 - text: "✍️ edit", 67 - emoji: true, 68 - }, 69 - value: "edit", 70 - action_id: "takes_edit", 71 - }, 72 - { 73 - type: "button", 74 - text: { 75 - type: "plain_text", 76 - text: "⏸️ Pause", 77 - emoji: true, 78 - }, 79 - value: "pause", 80 - action_id: "takes_pause", 81 - }, 82 - { 83 - type: "button", 84 - text: { 85 - type: "plain_text", 86 - text: "⏹️ Stop", 87 - emoji: true, 88 - }, 89 - value: "stop", 90 - action_id: "takes_stop", 91 - style: "danger", 92 - }, 93 - 94 - { 95 - type: "button", 96 - text: { 97 - type: "plain_text", 98 - text: "🔄 Refresh", 99 - emoji: true, 100 - }, 101 - value: "status", 102 - action_id: "takes_status", 103 - }, 104 - ], 105 - }, 106 - ], 107 - }; 108 - } 109 - 110 - // Check for paused session 111 - const pausedTakeStatus = await getPausedTake(userId); 112 - 113 - if (pausedTakeStatus.length > 0) { 114 - const pausedTake = pausedTakeStatus[0]; 115 - if (!pausedTake) { 116 - return; 117 - } 118 - 119 - // Calculate how much time remains before auto-completion 120 - const endTime = getRemainingTime( 121 - pausedTake.targetDurationMs, 122 - pausedTake.periods, 123 - ); 124 - const pauseExpires = getPausedTimeRemaining(pausedTake.periods); 125 - 126 - // Add notes to display if present 127 - const descriptionText = pausedTake.description 128 - ? `\n\n*Working on:* ${pausedTake.description}` 129 - : ""; 130 - 131 - return { 132 - text: `⏸️ You have a paused takes session. It will auto-complete in ${prettyPrintTime(pauseExpires)} if not resumed.`, 133 - response_type: "ephemeral", 134 - blocks: [ 135 - { 136 - type: "section", 137 - text: { 138 - type: "mrkdwn", 139 - text: `⏸️ Session paused! You have ${prettyPrintTime(endTime.remaining)} remaining.${descriptionText}`, 140 - }, 141 - }, 142 - { 143 - type: "divider", 144 - }, 145 - { 146 - type: "context", 147 - elements: [ 148 - { 149 - type: "mrkdwn", 150 - text: `It will automatically finish in ${prettyPrintTime(pauseExpires)} (by ${generateSlackDate(new Date(new Date().getTime() - pauseExpires))}) if not resumed.`, 151 - }, 152 - ], 153 - }, 154 - { 155 - type: "actions", 156 - elements: [ 157 - { 158 - type: "button", 159 - text: { 160 - type: "plain_text", 161 - text: "▶️ Resume", 162 - emoji: true, 163 - }, 164 - value: "resume", 165 - action_id: "takes_resume", 166 - }, 167 - { 168 - type: "button", 169 - text: { 170 - type: "plain_text", 171 - text: "⏹️ Stop", 172 - emoji: true, 173 - }, 174 - value: "stop", 175 - action_id: "takes_stop", 176 - style: "danger", 177 - }, 178 - { 179 - type: "button", 180 - text: { 181 - type: "plain_text", 182 - text: "🔄 Refresh", 183 - emoji: true, 184 - }, 185 - value: "status", 186 - action_id: "takes_status", 187 - }, 188 - ], 189 - }, 190 - ], 191 - }; 192 - } 193 - 194 - // Check history of completed sessions 195 - const completedSessions = await getCompletedTakes(userId); 196 - const takeTime = completedSessions.length 197 - ? (() => { 198 - const diffMs = 199 - new Date().getTime() - 200 - // @ts-expect-error - TS doesn't know that we are checking the length 201 - completedSessions[completedSessions.length - 1] 202 - ?.completedAt; 203 - 204 - const hours = Math.ceil(diffMs / (1000 * 60 * 60)); 205 - if (hours < 24) return `${hours} hours`; 206 - 207 - const weeks = Math.floor(diffMs / (1000 * 60 * 60 * 24 * 7)); 208 - if (weeks > 0 && weeks < 4) return `${weeks} weeks`; 209 - 210 - const months = Math.floor(diffMs / (1000 * 60 * 60 * 24 * 30)); 211 - return `${months} months`; 212 - })() 213 - : 0; 214 - 215 - return { 216 - text: `You have no active takes sessions. You've completed ${completedSessions.length} sessions in the last ${takeTime}.`, 217 - response_type: "ephemeral", 218 - blocks: [ 219 - { 220 - type: "section", 221 - text: { 222 - type: "mrkdwn", 223 - text: `You have no active takes sessions. You've completed ${completedSessions.length} sessions in the last ${takeTime}.`, 224 - }, 225 - }, 226 - { 227 - type: "actions", 228 - elements: [ 229 - { 230 - type: "button", 231 - text: { 232 - type: "plain_text", 233 - text: "🎬 Start New Session", 234 - emoji: true, 235 - }, 236 - value: "start", 237 - action_id: "takes_start", 238 - }, 239 - { 240 - type: "button", 241 - text: { 242 - type: "plain_text", 243 - text: "📋 History", 244 - emoji: true, 245 - }, 246 - value: "history", 247 - action_id: "takes_history", 248 - }, 249 - ], 250 - }, 251 - ], 252 - }; 253 - }
-171
src/features/takes/handlers/stop.ts
··· 1 - import { slackClient } from "../../../index"; 2 - import { db } from "../../../libs/db"; 3 - import { takes as takesTable } from "../../../libs/schema"; 4 - import { eq } from "drizzle-orm"; 5 - import { getActiveTake, getPausedTake } from "../services/database"; 6 - import type { MessageResponse } from "../types"; 7 - import { prettyPrintTime } from "../../../libs/time"; 8 - import { calculateElapsedTime } from "../../../libs/time-periods"; 9 - 10 - export default async function handleStop( 11 - userId: string, 12 - args?: string[], 13 - ): Promise<MessageResponse | undefined> { 14 - const activeTake = await getActiveTake(userId); 15 - 16 - if (activeTake.length === 0) { 17 - const pausedTake = await getPausedTake(userId); 18 - 19 - if (pausedTake.length === 0) { 20 - return { 21 - text: `You don't have an active or paused takes session!`, 22 - response_type: "ephemeral", 23 - }; 24 - } 25 - 26 - // Mark the paused session as completed 27 - const pausedTakeToStop = pausedTake[0]; 28 - if (!pausedTakeToStop) { 29 - return; 30 - } 31 - 32 - // Extract notes if provided 33 - let notes = undefined; 34 - if (args && args.length > 1) { 35 - notes = args.slice(1).join(" "); 36 - } 37 - 38 - const elapsed = calculateElapsedTime( 39 - JSON.parse(pausedTakeToStop.periods), 40 - ); 41 - 42 - const res = await slackClient.chat.postMessage({ 43 - channel: userId, 44 - text: "🎬 Your paused takes session has been completed. Please upload your takes video in this thread within the next 24 hours!", 45 - blocks: [ 46 - { 47 - type: "section", 48 - text: { 49 - type: "mrkdwn", 50 - text: "🎬 Your paused takes session has been completed. Please upload your takes video in this thread within the next 24 hours!", 51 - }, 52 - }, 53 - { 54 - type: "divider", 55 - }, 56 - { 57 - type: "context", 58 - elements: [ 59 - { 60 - type: "mrkdwn", 61 - text: `*Elapsed Time:* \`${prettyPrintTime(elapsed)}\`${pausedTakeToStop.description ? ` working on: *${pausedTakeToStop.description}*` : ""}`, 62 - }, 63 - ], 64 - }, 65 - ], 66 - }); 67 - 68 - await db 69 - .update(takesTable) 70 - .set({ 71 - status: "waitingUpload", 72 - ts: res.ts, 73 - completedAt: new Date(), 74 - elapsedTimeMs: elapsed, 75 - ...(notes && { notes }), 76 - }) 77 - .where(eq(takesTable.id, pausedTakeToStop.id)); 78 - } else { 79 - // Mark the active session as completed 80 - const activeTakeToStop = activeTake[0]; 81 - if (!activeTakeToStop) { 82 - return; 83 - } 84 - 85 - // Extract notes if provided 86 - let notes = undefined; 87 - if (args && args.length > 1) { 88 - notes = args.slice(1).join(" "); 89 - } 90 - 91 - const elapsed = calculateElapsedTime( 92 - JSON.parse(activeTakeToStop.periods), 93 - ); 94 - 95 - const res = await slackClient.chat.postMessage({ 96 - channel: userId, 97 - text: "🎬 Your takes session has been completed. Please upload your takes video in this thread within the next 24 hours!", 98 - blocks: [ 99 - { 100 - type: "section", 101 - text: { 102 - type: "mrkdwn", 103 - text: "🎬 Your takes session has been completed. Please upload your takes video in this thread within the next 24 hours!", 104 - }, 105 - }, 106 - { 107 - type: "divider", 108 - }, 109 - { 110 - type: "context", 111 - elements: [ 112 - { 113 - type: "mrkdwn", 114 - text: `\`${prettyPrintTime(elapsed)}\`${activeTakeToStop.description ? ` working on: *${activeTakeToStop.description}*` : ""}`, 115 - }, 116 - ], 117 - }, 118 - ], 119 - }); 120 - 121 - await db 122 - .update(takesTable) 123 - .set({ 124 - status: "waitingUpload", 125 - ts: res.ts, 126 - completedAt: new Date(), 127 - elapsedTimeMs: elapsed, 128 - ...(notes && { notes }), 129 - }) 130 - .where(eq(takesTable.id, activeTakeToStop.id)); 131 - } 132 - 133 - return { 134 - text: "✅ Takes session completed! I hope you had fun!", 135 - response_type: "ephemeral", 136 - blocks: [ 137 - { 138 - type: "section", 139 - text: { 140 - type: "mrkdwn", 141 - text: "✅ Takes session completed! I hope you had fun!", 142 - }, 143 - }, 144 - { 145 - type: "actions", 146 - elements: [ 147 - { 148 - type: "button", 149 - text: { 150 - type: "plain_text", 151 - text: "🎬 Start New Session", 152 - emoji: true, 153 - }, 154 - value: "start", 155 - action_id: "takes_start", 156 - }, 157 - { 158 - type: "button", 159 - text: { 160 - type: "plain_text", 161 - text: "📋 History", 162 - emoji: true, 163 - }, 164 - value: "history", 165 - action_id: "takes_history", 166 - }, 167 - ], 168 - }, 169 - ], 170 - }; 171 - }
-2
src/features/takes/index.ts
··· 1 1 import setupCommands from "./setup/commands"; 2 2 import setupActions from "./setup/actions"; 3 - import setupNotifications from "./setup/notifications"; 4 3 5 4 const takes = async () => { 6 5 setupCommands(); 7 6 setupActions(); 8 - setupNotifications(); 9 7 }; 10 8 11 9 export default takes;
-40
src/features/takes/services/database.ts
··· 1 - import { db } from "../../../libs/db"; 2 - import { takes as takesTable } from "../../../libs/schema"; 3 - import { eq, and, desc, not } from "drizzle-orm"; 4 - 5 - export async function getActiveTake(userId: string) { 6 - return db 7 - .select() 8 - .from(takesTable) 9 - .where( 10 - and(eq(takesTable.userId, userId), eq(takesTable.status, "active")), 11 - ) 12 - .limit(1); 13 - } 14 - 15 - export async function getPausedTake(userId: string) { 16 - return db 17 - .select() 18 - .from(takesTable) 19 - .where( 20 - and(eq(takesTable.userId, userId), eq(takesTable.status, "paused")), 21 - ) 22 - .limit(1); 23 - } 24 - 25 - export async function getCompletedTakes(userId: string, limit = 5) { 26 - return db 27 - .select() 28 - .from(takesTable) 29 - .where( 30 - and( 31 - eq(takesTable.userId, userId), 32 - and( 33 - not(eq(takesTable.status, "active")), 34 - not(eq(takesTable.status, "paused")), 35 - ), 36 - ), 37 - ) 38 - .orderBy(desc(takesTable.completedAt)) 39 - .limit(limit); 40 - }
-199
src/features/takes/services/notifications.ts
··· 1 - import { slackApp } from "../../../index"; 2 - import TakesConfig from "../../../libs/config"; 3 - import { db } from "../../../libs/db"; 4 - import { takes as takesTable } from "../../../libs/schema"; 5 - import { eq } from "drizzle-orm"; 6 - import { 7 - calculateElapsedTime, 8 - getPausedDuration, 9 - getRemainingTime, 10 - } from "../../../libs/time-periods"; 11 - import { prettyPrintTime } from "../../../libs/time"; 12 - 13 - // Check for paused sessions that have exceeded the max pause duration 14 - export async function expirePausedSessions() { 15 - const now = new Date(); 16 - const pausedTakes = await db 17 - .select() 18 - .from(takesTable) 19 - .where(eq(takesTable.status, "paused")); 20 - 21 - for (const take of pausedTakes) { 22 - const pausedDuration = getPausedDuration(take.periods) / 60000; // Convert to minutes 23 - 24 - // Send warning notification when getting close to expiration 25 - if ( 26 - pausedDuration > 27 - TakesConfig.MAX_PAUSE_DURATION - 28 - TakesConfig.NOTIFICATIONS.PAUSE_EXPIRATION_WARNING && 29 - !take.notifiedPauseExpiration 30 - ) { 31 - // Update notification flag 32 - await db 33 - .update(takesTable) 34 - .set({ 35 - notifiedPauseExpiration: true, 36 - }) 37 - .where(eq(takesTable.id, take.id)); 38 - 39 - // Send warning message 40 - try { 41 - const timeRemaining = Math.round( 42 - TakesConfig.MAX_PAUSE_DURATION - pausedDuration, 43 - ); 44 - await slackApp.client.chat.postMessage({ 45 - channel: take.userId, 46 - text: `⚠️ Reminder: Your paused takes session will automatically complete in about ${timeRemaining} minutes if not resumed.`, 47 - }); 48 - } catch (error) { 49 - console.error( 50 - "Failed to send pause expiration warning:", 51 - error, 52 - ); 53 - } 54 - } 55 - 56 - // Calculate elapsed time 57 - const elapsedTime = calculateElapsedTime(JSON.parse(take.periods)); 58 - 59 - // Auto-expire paused sessions that exceed the max pause duration 60 - if (pausedDuration > TakesConfig.MAX_PAUSE_DURATION) { 61 - let ts: string | undefined; 62 - // Notify user that their session was auto-completed 63 - try { 64 - const res = await slackApp.client.chat.postMessage({ 65 - channel: take.userId, 66 - text: `⏰ Your paused takes session has been automatically completed because it was paused for more than ${TakesConfig.MAX_PAUSE_DURATION} minutes.\n\nPlease upload your takes video in this thread within the next 24 hours!`, 67 - blocks: [ 68 - { 69 - type: "section", 70 - text: { 71 - type: "mrkdwn", 72 - text: `⏰ Your paused takes session has been automatically completed because it was paused for more than ${TakesConfig.MAX_PAUSE_DURATION} minutes.\n\nPlease upload your takes video in this thread within the next 24 hours!`, 73 - }, 74 - }, 75 - { 76 - type: "divider", 77 - }, 78 - { 79 - type: "context", 80 - elements: [ 81 - { 82 - type: "mrkdwn", 83 - text: `\`${prettyPrintTime(elapsedTime)}\`${take.description ? ` working on: *${take.description}*` : ""}`, 84 - }, 85 - ], 86 - }, 87 - ], 88 - }); 89 - ts = res.ts; 90 - } catch (error) { 91 - console.error( 92 - "Failed to notify user of auto-completed session:", 93 - error, 94 - ); 95 - } 96 - 97 - await db 98 - .update(takesTable) 99 - .set({ 100 - status: "waitingUpload", 101 - completedAt: now, 102 - elapsedTimeMs: elapsedTime, 103 - ts, 104 - notes: take.notes 105 - ? `${take.notes} (Automatically completed due to pause timeout)` 106 - : "Automatically completed due to pause timeout", 107 - }) 108 - .where(eq(takesTable.id, take.id)); 109 - } 110 - } 111 - } 112 - 113 - // Check for active sessions that are almost done 114 - export async function checkActiveSessions() { 115 - const now = new Date(); 116 - const activeTakes = await db 117 - .select() 118 - .from(takesTable) 119 - .where(eq(takesTable.status, "active")); 120 - 121 - for (const take of activeTakes) { 122 - const endTime = getRemainingTime(take.targetDurationMs, take.periods); 123 - 124 - const remainingMinutes = endTime.remaining / 60000; 125 - 126 - if ( 127 - remainingMinutes <= TakesConfig.NOTIFICATIONS.LOW_TIME_WARNING && 128 - remainingMinutes > 0 && 129 - !take.notifiedLowTime 130 - ) { 131 - await db 132 - .update(takesTable) 133 - .set({ notifiedLowTime: true }) 134 - .where(eq(takesTable.id, take.id)); 135 - 136 - try { 137 - await slackApp.client.chat.postMessage({ 138 - channel: take.userId, 139 - text: `⏱️ Your takes session has less than ${TakesConfig.NOTIFICATIONS.LOW_TIME_WARNING} minutes remaining.`, 140 - }); 141 - } catch (error) { 142 - console.error("Failed to send low time warning:", error); 143 - } 144 - } 145 - 146 - const elapsedTime = calculateElapsedTime(JSON.parse(take.periods)); 147 - 148 - if (endTime.remaining <= 0) { 149 - let ts: string | undefined; 150 - try { 151 - const res = await slackApp.client.chat.postMessage({ 152 - channel: take.userId, 153 - text: "⏰ Your takes session has automatically completed because the time is up. Please upload your takes video in this thread within the next 24 hours!", 154 - blocks: [ 155 - { 156 - type: "section", 157 - text: { 158 - type: "mrkdwn", 159 - text: "⏰ Your takes session has automatically completed because the time is up. Please upload your takes video in this thread within the next 24 hours!", 160 - }, 161 - }, 162 - { 163 - type: "divider", 164 - }, 165 - { 166 - type: "context", 167 - elements: [ 168 - { 169 - type: "mrkdwn", 170 - text: `\`${prettyPrintTime(elapsedTime)}\`${take.description ? ` working on: *${take.description}*` : ""}`, 171 - }, 172 - ], 173 - }, 174 - ], 175 - }); 176 - 177 - ts = res.ts; 178 - } catch (error) { 179 - console.error( 180 - "Failed to notify user of completed session:", 181 - error, 182 - ); 183 - } 184 - 185 - await db 186 - .update(takesTable) 187 - .set({ 188 - status: "waitingUpload", 189 - completedAt: now, 190 - elapsedTimeMs: elapsedTime, 191 - ts, 192 - notes: take.notes 193 - ? `${take.notes} (Automatically completed - time expired)` 194 - : "Automatically completed - time expired", 195 - }) 196 - .where(eq(takesTable.id, take.id)); 197 - } 198 - } 199 - }
+8 -12
src/features/takes/services/upload.ts
··· 22 22 and( 23 23 eq(takesTable.userId, payload.user as string), 24 24 eq(takesTable.ts, payload.thread_ts as string), 25 - eq(takesTable.status, "waitingUpload"), 25 + eq(takesTable.media, "[]"), 26 26 ), 27 27 ); 28 28 ··· 66 66 await db 67 67 .update(takesTable) 68 68 .set({ 69 - status: "uploaded", 70 - takeUploadedAt, 71 - takeUrl: takePublicUrl, 69 + media: JSON.stringify([takePublicUrl]), 72 70 }) 73 71 .where(eq(takesTable.id, take.id)); 74 72 ··· 98 96 elements: [ 99 97 { 100 98 type: "mrkdwn", 101 - text: `take by <@${user}> for \`${prettyPrintTime(take.elapsedTimeMs)}\` working on: *${take.description}*`, 99 + text: `take by <@${user}> for \`${prettyPrintTime(take.elapsedTimeMs)}\` working on: *${take.notes}*`, 102 100 }, 103 101 ], 104 102 }, ··· 113 111 type: "section", 114 112 text: { 115 113 type: "mrkdwn", 116 - text: `:video_camera: new take uploaded by <@${user}> for \`${prettyPrintTime(take.elapsedTimeMs)}\` working on: *${take.description}*`, 114 + text: `:video_camera: new take uploaded by <@${user}> for \`${prettyPrintTime(take.elapsedTimeMs)}\` working on: *${take.notes}*`, 117 115 }, 118 116 }, 119 117 { ··· 121 119 }, 122 120 { 123 121 type: "video", 124 - video_url: `${process.env.API_URL}/api/video/${take.id}`, 125 - title_url: `${process.env.API_URL}/api/video/${take.id}`, 122 + video_url: `${process.env.API_URL}/api/video/?media=${take.media[0]}`, 123 + title_url: `${process.env.API_URL}/api/video/?media=${take.media[0]}`, 126 124 title: { 127 125 type: "plain_text", 128 - text: `${take.description} by <@${user}> uploaded at ${generateSlackDate(takeUploadedAt)}`, 126 + text: `${take.notes} by <@${user}> uploaded at ${generateSlackDate(takeUploadedAt)}`, 129 127 }, 130 128 thumbnail_url: `https://cachet.dunkirk.sh/users/${payload.user}/r`, 131 - alt_text: `takes from ${takeUploadedAt?.toLocaleString("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })} uploaded with the description: *${take.description}*`, 129 + alt_text: `takes from ${takeUploadedAt?.toLocaleString("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })} uploaded with the description: *${take.notes}*`, 132 130 }, 133 131 { 134 132 type: "divider", ··· 300 298 await db 301 299 .update(takesTable) 302 300 .set({ 303 - status: "approved", 304 301 multiplier: multiplier, 305 302 }) 306 303 .where(eq(takesTable.id, takeId)); ··· 353 350 await db 354 351 .update(takesTable) 355 352 .set({ 356 - status: "rejected", 357 353 multiplier: "0", 358 354 }) 359 355 .where(eq(takesTable.id, takeId));
-78
src/features/takes/setup/actions.ts
··· 1 1 import { slackApp } from "../../../index"; 2 - import { db } from "../../../libs/db"; 3 2 import { blog } from "../../../libs/Logger"; 4 - import { takes as takesTable } from "../../../libs/schema"; 5 3 import handleHelp from "../handlers/help"; 6 4 import { handleHistory } from "../handlers/history"; 7 5 import handleHome from "../handlers/home"; 8 - import handlePause from "../handlers/pause"; 9 - import handleResume from "../handlers/resume"; 10 - import handleStart from "../handlers/start"; 11 - import handleStatus from "../handlers/status"; 12 - import handleStop from "../handlers/stop"; 13 - import { getActiveTake } from "../services/database"; 14 6 import upload from "../services/upload"; 15 7 import type { MessageResponse } from "../types"; 16 - import { getDescriptionBlocks, getEditDescriptionBlocks } from "../ui/blocks"; 17 8 import * as Sentry from "@sentry/bun"; 18 9 19 10 export default function setupActions() { ··· 21 12 slackApp.action(/^takes_(\w+)$/, async ({ payload, context }) => { 22 13 try { 23 14 const userId = payload.user.id; 24 - const channelId = context.channelId || ""; 25 15 const actionId = payload.actions[0]?.action_id as string; 26 16 const command = actionId.replace("takes_", ""); 27 - const descriptionInput = 28 - payload.state.values.note_block?.note_input; 29 17 30 18 let response: MessageResponse | undefined; 31 19 32 - const activeTake = await getActiveTake(userId); 33 - 34 20 // Route to the appropriate handler function 35 21 switch (command) { 36 - case "start": { 37 - if (activeTake.length > 0) { 38 - if (context.respond) { 39 - response = await handleStatus(userId); 40 - } 41 - } else { 42 - if (!descriptionInput?.value?.trim()) { 43 - response = getDescriptionBlocks( 44 - "Please enter a note for your session.", 45 - ); 46 - } else { 47 - response = await handleStart( 48 - userId, 49 - channelId, 50 - descriptionInput?.value?.trim(), 51 - ); 52 - } 53 - } 54 - break; 55 - } 56 - case "pause": 57 - response = await handlePause(userId); 58 - break; 59 - case "resume": 60 - response = await handleResume(userId); 61 - break; 62 - case "stop": 63 - response = await handleStop(userId); 64 - break; 65 - case "edit": { 66 - if (!activeTake.length && context.respond) { 67 - await context.respond({ 68 - text: "You don't have an active takes session to edit!", 69 - response_type: "ephemeral", 70 - }); 71 - return; 72 - } 73 - 74 - if (!descriptionInput) { 75 - response = getEditDescriptionBlocks( 76 - activeTake[0]?.description || "", 77 - ); 78 - } else if (descriptionInput.value?.trim()) { 79 - const takeToUpdate = activeTake[0]; 80 - if (!takeToUpdate) return; 81 - 82 - // Update the note for the active session 83 - await db.update(takesTable).set({ 84 - description: descriptionInput.value.trim(), 85 - }); 86 - 87 - response = await handleStatus(userId); 88 - } else { 89 - response = getEditDescriptionBlocks( 90 - "", 91 - "Please enter a note for your session.", 92 - ); 93 - } 94 - break; 95 - } 96 - 97 - case "status": 98 - response = await handleStatus(userId); 99 - break; 100 22 case "history": 101 23 response = await handleHistory(userId); 102 24 break;
+1 -75
src/features/takes/setup/commands.ts
··· 1 1 import { environment, slackApp } from "../../../index"; 2 2 import handleHelp from "../handlers/help"; 3 3 import { handleHistory } from "../handlers/history"; 4 - import handlePause from "../handlers/pause"; 5 - import handleResume from "../handlers/resume"; 6 - import handleStart from "../handlers/start"; 7 - import handleStatus from "../handlers/status"; 8 - import handleStop from "../handlers/stop"; 9 - import { getActiveTake, getPausedTake } from "../services/database"; 10 - import { 11 - checkActiveSessions, 12 - expirePausedSessions, 13 - } from "../services/notifications"; 14 4 import type { MessageResponse } from "../types"; 15 - import { getDescriptionBlocks, getEditDescriptionBlocks } from "../ui/blocks"; 16 5 import * as Sentry from "@sentry/bun"; 17 6 import { blog } from "../../../libs/Logger"; 18 7 import handleHome from "../handlers/home"; ··· 27 16 const channelId = payload.channel_id; 28 17 const text = payload.text || ""; 29 18 const args = text.trim().split(/\s+/); 30 - let subcommand = args[0]?.toLowerCase() || ""; 31 - 32 - // Check for active takes session 33 - const activeTake = await getActiveTake(userId); 34 - 35 - // Check for paused session if no active one 36 - const pausedTakeCheck = 37 - activeTake.length === 0 ? await getPausedTake(userId) : []; 38 - 39 - // Run checks for expired or about-to-expire sessions 40 - await expirePausedSessions(); 41 - await checkActiveSessions(); 42 - 43 - // Default to status if we have an active or paused session and no command specified 44 - if ( 45 - subcommand === "" && 46 - (activeTake.length > 0 || pausedTakeCheck.length > 0) 47 - ) { 48 - subcommand = "status"; 49 - } 19 + const subcommand = args[0]?.toLowerCase() || ""; 50 20 51 21 let response: MessageResponse | undefined; 52 22 53 - // Special handling for start command to show modal 54 - if (subcommand === "start" && !activeTake.length) { 55 - response = getDescriptionBlocks(); 56 - } 57 - 58 23 // Route to the appropriate handler function 59 24 switch (subcommand) { 60 - case "start": { 61 - if (args.length < 2) { 62 - response = getDescriptionBlocks(); 63 - break; 64 - } 65 - 66 - const descriptionInput = args.slice(1).join(" "); 67 - 68 - if (!descriptionInput.trim()) { 69 - response = getDescriptionBlocks( 70 - "Please enter a note for your session.", 71 - ); 72 - break; 73 - } 74 - 75 - response = await handleStart( 76 - userId, 77 - channelId, 78 - descriptionInput, 79 - ); 80 - break; 81 - } 82 - case "pause": 83 - response = await handlePause(userId); 84 - break; 85 - case "resume": 86 - response = await handleResume(userId); 87 - break; 88 - case "stop": 89 - response = await handleStop(userId, args); 90 - break; 91 - case "edit": 92 - response = getEditDescriptionBlocks( 93 - activeTake[0]?.description || "", 94 - ); 95 - break; 96 - case "status": 97 - response = await handleStatus(userId); 98 - break; 99 25 case "history": 100 26 response = await handleHistory(userId); 101 27 break;
-47
src/features/takes/setup/notifications.ts
··· 1 - import * as Sentry from "@sentry/bun"; 2 - import TakesConfig from "../../../libs/config"; 3 - import { blog } from "../../../libs/Logger"; 4 - import { 5 - checkActiveSessions, 6 - expirePausedSessions, 7 - } from "../services/notifications"; 8 - 9 - export default function setupNotifications() { 10 - try { 11 - const notificationInterval = TakesConfig.NOTIFICATIONS.CHECK_INTERVAL; 12 - 13 - setInterval(async () => { 14 - try { 15 - await checkActiveSessions(); 16 - await expirePausedSessions(); 17 - } catch (error) { 18 - if (error instanceof Error) 19 - blog( 20 - `Error in notifications check: ${error.message}`, 21 - "error", 22 - ); 23 - Sentry.captureException(error, { 24 - extra: { 25 - context: "notifications check", 26 - checkInterval: notificationInterval, 27 - }, 28 - tags: { 29 - type: "notification_check", 30 - }, 31 - }); 32 - } 33 - }, notificationInterval); 34 - } catch (error) { 35 - if (error instanceof Error) 36 - blog(`Error setting up notifications: ${error.message}`, "error"); 37 - Sentry.captureException(error, { 38 - extra: { 39 - context: "notifications setup", 40 - }, 41 - tags: { 42 - type: "notification_setup", 43 - }, 44 - }); 45 - throw error; // Re-throw to prevent the app from starting with broken notifications 46 - } 47 - }
-148
src/features/takes/ui/blocks.ts
··· 1 - import type { AnyMessageBlock } from "slack-edge"; 2 - import type { MessageResponse } from "../types"; 3 - 4 - export function getDescriptionBlocks(error?: string): MessageResponse { 5 - const blocks: AnyMessageBlock[] = [ 6 - { 7 - type: "input", 8 - block_id: "note_block", 9 - element: { 10 - type: "plain_text_input", 11 - action_id: "note_input", 12 - placeholder: { 13 - type: "plain_text", 14 - text: "Enter a note for your session", 15 - }, 16 - multiline: true, 17 - }, 18 - label: { 19 - type: "plain_text", 20 - text: "Note", 21 - }, 22 - }, 23 - { 24 - type: "actions", 25 - elements: [ 26 - { 27 - type: "button", 28 - text: { 29 - type: "plain_text", 30 - text: "🎬 Start Session", 31 - emoji: true, 32 - }, 33 - value: "start", 34 - action_id: "takes_start", 35 - }, 36 - { 37 - type: "button", 38 - text: { 39 - type: "plain_text", 40 - text: "⛔ Cancel", 41 - emoji: true, 42 - }, 43 - value: "cancel", 44 - action_id: "takes_status", 45 - style: "danger", 46 - }, 47 - ], 48 - }, 49 - ]; 50 - 51 - if (error) { 52 - blocks.push( 53 - { 54 - type: "divider", 55 - }, 56 - { 57 - type: "context", 58 - elements: [ 59 - { 60 - type: "mrkdwn", 61 - text: `⚠️ ${error}`, 62 - }, 63 - ], 64 - }, 65 - ); 66 - } 67 - 68 - return { 69 - text: "Please enter a note for your session:", 70 - response_type: "ephemeral", 71 - blocks, 72 - }; 73 - } 74 - 75 - export function getEditDescriptionBlocks( 76 - description: string, 77 - error?: string, 78 - ): MessageResponse { 79 - const blocks: AnyMessageBlock[] = [ 80 - { 81 - type: "input", 82 - block_id: "note_block", 83 - element: { 84 - type: "plain_text_input", 85 - action_id: "note_input", 86 - placeholder: { 87 - type: "plain_text", 88 - text: "Enter a note for your session", 89 - }, 90 - multiline: true, 91 - initial_value: description, 92 - }, 93 - label: { 94 - type: "plain_text", 95 - text: "Note", 96 - }, 97 - }, 98 - { 99 - type: "actions", 100 - elements: [ 101 - { 102 - type: "button", 103 - text: { 104 - type: "plain_text", 105 - text: "✍️ Update Note", 106 - emoji: true, 107 - }, 108 - value: "start", 109 - action_id: "takes_edit", 110 - }, 111 - { 112 - type: "button", 113 - text: { 114 - type: "plain_text", 115 - text: "⛔ Cancel", 116 - emoji: true, 117 - }, 118 - value: "cancel", 119 - action_id: "takes_status", 120 - style: "danger", 121 - }, 122 - ], 123 - }, 124 - ]; 125 - 126 - if (error) { 127 - blocks.push( 128 - { 129 - type: "divider", 130 - }, 131 - { 132 - type: "context", 133 - elements: [ 134 - { 135 - type: "mrkdwn", 136 - text: `⚠️ ${error}`, 137 - }, 138 - ], 139 - }, 140 - ); 141 - } 142 - 143 - return { 144 - text: "Please enter a note for your session:", 145 - response_type: "ephemeral", 146 - blocks, 147 - }; 148 - }
+9 -10
src/libs/db.ts
··· 1 - import { drizzle } from "drizzle-orm/bun-sqlite"; 2 - import { Database } from "bun:sqlite"; 1 + import { drizzle } from "drizzle-orm/node-postgres"; 2 + import { Pool } from "pg"; 3 3 import * as schema from "./schema"; 4 4 5 - // Use environment variable for the database path in production 6 - const dbPath = process.env.DATABASE_PATH || "./local.db"; 5 + const pool = new Pool({ 6 + connectionString: process.env.DATABASE_URL, 7 + }); 7 8 8 - // Create a SQLite database instance using Bun's built-in driver 9 - const sqlite = new Database(dbPath); 9 + export const db = drizzle(pool, { schema }); 10 10 11 - // Create a Drizzle instance with the database and schema 12 - export const db = drizzle(sqlite, { schema }); 11 + // Set up triggers when initializing the database 12 + schema.setupTriggers(pool).catch(console.error); 13 13 14 - // Export the sqlite instance and schema for use in other files 15 - export { sqlite, schema }; 14 + export { pool, schema };
+52 -21
src/libs/schema.ts
··· 1 - import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; 1 + import { pgTable, text, integer } from "drizzle-orm/pg-core"; 2 + import type { Pool } from "pg"; 2 3 3 4 // Define the takes table 4 - export const takes = sqliteTable("takes", { 5 + export const takes = pgTable("takes", { 5 6 id: text("id").primaryKey(), 6 7 userId: text("user_id").notNull(), 7 - ts: text("ts"), 8 - status: text("status").notNull().default("active"), // active, paused, waitingUpload, completed 8 + ts: text("ts").notNull(), 9 9 elapsedTimeMs: integer("elapsed_time_ms").notNull().default(0), 10 - targetDurationMs: integer("target_duration_ms").notNull(), 11 - periods: text("periods").notNull(), // JSON string of time periods 12 - lastResumeAt: integer("last_resume_at", { mode: "timestamp" }), 13 - createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( 14 - () => new Date(), 15 - ), 16 - completedAt: integer("completed_at", { mode: "timestamp" }), 17 - takeUploadedAt: integer("take_uploaded_at", { mode: "timestamp" }), 18 - takeUrl: text("take_url"), 10 + createdAt: integer("created_at") 11 + .$defaultFn(() => Math.floor(new Date().getTime() / 1000)) 12 + .notNull(), 13 + media: text("media").notNull().default("[]"), // array of media urls 19 14 multiplier: text("multiplier").notNull().default("1.0"), 20 - notes: text("notes"), 21 - description: text("description"), 22 - notifiedLowTime: integer("notified_low_time", { mode: "boolean" }).default( 23 - false, 24 - ), // has user been notified about low time 25 - notifiedPauseExpiration: integer("notified_pause_expiration", { 26 - mode: "boolean", 27 - }).default(false), // has user been notified about pause expiration 15 + notes: text("notes").notNull().default(""), 16 + }); 17 + 18 + export const users = pgTable("users", { 19 + id: text("id").primaryKey(), 20 + totalTakesTime: integer("total_takes_time").default(0), 28 21 }); 22 + 23 + export async function setupTriggers(pool: Pool) { 24 + await pool.query(` 25 + CREATE INDEX IF NOT EXISTS idx_takes_user_id ON takes(user_id); 26 + 27 + CREATE OR REPLACE FUNCTION update_user_total_time() 28 + RETURNS TRIGGER AS $$ 29 + BEGIN 30 + IF TG_OP = 'INSERT' THEN 31 + UPDATE users 32 + SET total_takes_time = COALESCE(total_takes_time, 0) + NEW.elapsed_time_ms 33 + WHERE id = NEW.user_id; 34 + ELSIF TG_OP = 'DELETE' THEN 35 + UPDATE users 36 + SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time_ms 37 + WHERE id = OLD.user_id; 38 + ELSIF TG_OP = 'UPDATE' THEN 39 + UPDATE users 40 + SET total_takes_time = COALESCE(total_takes_time, 0) - OLD.elapsed_time_ms + NEW.elapsed_time_ms 41 + WHERE id = NEW.user_id; 42 + END IF; 43 + 44 + EXCEPTION WHEN OTHERS THEN 45 + RAISE NOTICE 'Error updating user total time: %', SQLERRM; 46 + RETURN NULL; 47 + 48 + RETURN NEW; 49 + END; 50 + $$ LANGUAGE plpgsql; 51 + 52 + DROP TRIGGER IF EXISTS update_user_total_time_trigger ON takes; 53 + 54 + CREATE TRIGGER update_user_total_time_trigger 55 + AFTER INSERT OR UPDATE OR DELETE ON takes 56 + FOR EACH ROW 57 + EXECUTE FUNCTION update_user_total_time(); 58 + `); 59 + }
-78
src/libs/time-periods.ts
··· 1 - import type { PeriodType, TimePeriod } from "../features/takes/types"; 2 - import TakesConfig from "./config"; 3 - 4 - export function calculateElapsedTime(periods: TimePeriod[]): number { 5 - return Math.min( 6 - periods.reduce((total, period) => { 7 - if (period.type !== "active") return total; 8 - 9 - const endTime = period.endTime || Date.now(); 10 - return total + (endTime - period.startTime); 11 - }, 0), 12 - TakesConfig.DEFAULT_SESSION_LENGTH * 60 * 1000, 13 - ); 14 - } 15 - 16 - export function addNewPeriod( 17 - periodsString: string, 18 - type: PeriodType, 19 - ): TimePeriod[] { 20 - const periods = JSON.parse(periodsString); 21 - 22 - // Close previous period if exists 23 - if (periods.length > 0) { 24 - const lastPeriod = periods[periods.length - 1]; 25 - if (!lastPeriod.endTime) { 26 - lastPeriod.endTime = Date.now(); 27 - } 28 - } 29 - 30 - // Add new period 31 - periods.push({ 32 - type, 33 - startTime: Date.now(), 34 - endTime: null, 35 - }); 36 - 37 - return periods; 38 - } 39 - 40 - export function getRemainingTime( 41 - targetDurationMs: number, 42 - periods: string, 43 - ): { 44 - remaining: number; 45 - endTime: Date; 46 - } { 47 - const elapsedMs = calculateElapsedTime(JSON.parse(periods)); 48 - const remaining = Math.max(0, targetDurationMs - elapsedMs); 49 - const endTime = new Date(Date.now() + remaining); 50 - return { remaining, endTime }; 51 - } 52 - 53 - export function getPausedTimeRemaining(periods: string): number { 54 - const parsedPeriods = JSON.parse(periods); 55 - const currentPeriod = parsedPeriods[parsedPeriods.length - 1]; 56 - 57 - if (currentPeriod.type !== "paused" || !currentPeriod.startTime) { 58 - return 0; 59 - } 60 - 61 - const now = new Date(); 62 - const pausedDuration = now.getTime() - currentPeriod.startTime; 63 - 64 - return Math.max( 65 - 0, 66 - TakesConfig.MAX_PAUSE_DURATION * 60 * 1000 - pausedDuration, 67 - ); 68 - } 69 - 70 - export function getPausedDuration(periods: string): number { 71 - const parsedPeriods = JSON.parse(periods); 72 - return parsedPeriods.reduce((total: number, period: TimePeriod) => { 73 - if (period.type !== "paused") return total; 74 - 75 - const endTime = period.endTime || Date.now(); 76 - return total + (endTime - period.startTime); 77 - }, 0); 78 - }