From eb1b021f00a5d7155bc9a6e09d4308313e516486 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 23 Mar 2026 08:04:33 +0000
Subject: [PATCH 01/59] chore(deps): bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)
---
updated-dependencies:
- dependency-name: actions/setup-python
dependency-version: '6'
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
---
.github/workflows/docs.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 135d926d..8a497a4b 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -42,7 +42,7 @@ jobs:
steps:
- uses: actions/checkout@v6
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@v6
with:
python-version: "3.x"
From df530e8c1f4e613572d2a7bf7d6207c208248f2b Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 23 Mar 2026 08:04:38 +0000
Subject: [PATCH 02/59] chore(deps): bump anchore/sbom-action from 0.23.1 to
0.24.0
Bumps [anchore/sbom-action](https://github.com/anchore/sbom-action) from 0.23.1 to 0.24.0.
- [Release notes](https://github.com/anchore/sbom-action/releases)
- [Changelog](https://github.com/anchore/sbom-action/blob/main/RELEASE.md)
- [Commits](https://github.com/anchore/sbom-action/compare/v0.23.1...v0.24.0)
---
updated-dependencies:
- dependency-name: anchore/sbom-action
dependency-version: 0.24.0
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot]
---
.github/workflows/release.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index bb3fe6fb..e1d9495d 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -105,7 +105,7 @@ jobs:
- name: Generate SBOM
if: steps.tag.outputs.should_release == 'true'
- uses: anchore/sbom-action@v0.23.1
+ uses: anchore/sbom-action@v0.24.0
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.version }}
artifact-name: sbom-tracker-tracker.spdx.json
From c87bf981977886e342d0bdb0de1f7d388264a58d Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 23 Mar 2026 08:04:43 +0000
Subject: [PATCH 03/59] chore(deps): bump
marocchino/sticky-pull-request-comment from 2 to 3
Bumps [marocchino/sticky-pull-request-comment](https://github.com/marocchino/sticky-pull-request-comment) from 2 to 3.
- [Release notes](https://github.com/marocchino/sticky-pull-request-comment/releases)
- [Commits](https://github.com/marocchino/sticky-pull-request-comment/compare/v2...v3)
---
updated-dependencies:
- dependency-name: marocchino/sticky-pull-request-comment
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
---
.github/workflows/knip-report.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/knip-report.yml b/.github/workflows/knip-report.yml
index cbebce4a..85bad135 100644
--- a/.github/workflows/knip-report.yml
+++ b/.github/workflows/knip-report.yml
@@ -105,7 +105,7 @@ jobs:
fi
- name: Post PR Comment
- uses: marocchino/sticky-pull-request-comment@v2
+ uses: marocchino/sticky-pull-request-comment@v3
with:
header: knip-report
path: knip-report.md
From a42a8f8667abc8ddeafdd1ab7769592e3ee1cb7b Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 23 Mar 2026 08:05:15 +0000
Subject: [PATCH 04/59] chore(deps): bump @tanstack/react-query from 5.91.2 to
5.95.0
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.91.2 to 5.95.0.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.95.0/packages/react-query)
---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
dependency-version: 5.95.0
dependency-type: direct:production
update-type: version-update:semver-minor
...
Signed-off-by: dependabot[bot]
---
package.json | 2 +-
pnpm-lock.yaml | 26 +++++++++-----------------
2 files changed, 10 insertions(+), 18 deletions(-)
diff --git a/package.json b/package.json
index 09df15c7..7694c1a1 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@formkit/auto-animate": "^0.9.0",
"@tailwindcss/typography": "^0.5.19",
- "@tanstack/react-query": "^5.91.2",
+ "@tanstack/react-query": "^5.95.0",
"argon2": "^0.44.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 21b10099..9971d134 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -27,8 +27,8 @@ importers:
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.2.2)
'@tanstack/react-query':
- specifier: ^5.91.2
- version: 5.91.2(react@19.2.4)
+ specifier: ^5.95.0
+ version: 5.95.0(react@19.2.4)
argon2:
specifier: ^0.44.0
version: 0.44.0
@@ -407,9 +407,6 @@ packages:
'@emnapi/core@1.9.1':
resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==}
- '@emnapi/runtime@1.9.0':
- resolution: {integrity: sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==}
-
'@emnapi/runtime@1.9.1':
resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==}
@@ -1336,11 +1333,11 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
- '@tanstack/query-core@5.91.2':
- resolution: {integrity: sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==}
+ '@tanstack/query-core@5.95.0':
+ resolution: {integrity: sha512-H1/CWCe8tGL3YIVeo770Z6kPbt0B3M1d/iQXIIK1qlFiFt6G2neYdkHgLapOC8uMYNt9DmHjmGukEKgdMk1P+A==}
- '@tanstack/react-query@5.91.2':
- resolution: {integrity: sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==}
+ '@tanstack/react-query@5.95.0':
+ resolution: {integrity: sha512-EMP8B+BK9zvnAemT8M/y3z/WO0NjZ7fIUY3T3wnHYK6AA3qK/k33i7tPgCXCejhX0cd4I6bJIXN2GmjrHjDBzg==}
peerDependencies:
react: ^18 || ^19
@@ -3722,11 +3719,6 @@ snapshots:
tslib: 2.8.1
optional: true
- '@emnapi/runtime@1.9.0':
- dependencies:
- tslib: 2.8.1
- optional: true
-
'@emnapi/runtime@1.9.1':
dependencies:
tslib: 2.8.1
@@ -4289,11 +4281,11 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.2.2
- '@tanstack/query-core@5.91.2': {}
+ '@tanstack/query-core@5.95.0': {}
- '@tanstack/react-query@5.91.2(react@19.2.4)':
+ '@tanstack/react-query@5.95.0(react@19.2.4)':
dependencies:
- '@tanstack/query-core': 5.91.2
+ '@tanstack/query-core': 5.95.0
react: 19.2.4
'@testing-library/dom@10.4.1':
From bdfca1f2e5716d86e2db6f277cfb361321595d75 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 23 Mar 2026 08:06:01 +0000
Subject: [PATCH 05/59] chore(deps): bump jose from 6.2.1 to 6.2.2
Bumps [jose](https://github.com/panva/jose) from 6.2.1 to 6.2.2.
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.1...v6.2.2)
---
updated-dependencies:
- dependency-name: jose
dependency-version: 6.2.2
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
---
package.json | 2 +-
pnpm-lock.yaml | 18 +++++-------------
2 files changed, 6 insertions(+), 14 deletions(-)
diff --git a/package.json b/package.json
index 09df15c7..e1c3c8af 100644
--- a/package.json
+++ b/package.json
@@ -59,7 +59,7 @@
"echarts-gl": "^2.0.9",
"emoji-picker-react": "^4.18.0",
"https-proxy-agent": "^8.0.0",
- "jose": "^6.2.1",
+ "jose": "^6.2.2",
"next": "16.2.0",
"node-cron": "^4.2.1",
"otpauth": "^9.5.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 21b10099..7c532993 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -60,8 +60,8 @@ importers:
specifier: ^8.0.0
version: 8.0.0
jose:
- specifier: ^6.2.1
- version: 6.2.1
+ specifier: ^6.2.2
+ version: 6.2.2
next:
specifier: 16.2.0
version: 16.2.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -407,9 +407,6 @@ packages:
'@emnapi/core@1.9.1':
resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==}
- '@emnapi/runtime@1.9.0':
- resolution: {integrity: sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==}
-
'@emnapi/runtime@1.9.1':
resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==}
@@ -2279,8 +2276,8 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
- jose@6.2.1:
- resolution: {integrity: sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==}
+ jose@6.2.2:
+ resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
joycon@3.1.1:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
@@ -3722,11 +3719,6 @@ snapshots:
tslib: 2.8.1
optional: true
- '@emnapi/runtime@1.9.0':
- dependencies:
- tslib: 2.8.1
- optional: true
-
'@emnapi/runtime@1.9.1':
dependencies:
tslib: 2.8.1
@@ -5161,7 +5153,7 @@ snapshots:
jiti@2.6.1: {}
- jose@6.2.1: {}
+ jose@6.2.2: {}
joycon@3.1.1: {}
From 9250d58a8ee983be1c8e4bbde8d6b340e97c5a17 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 23 Mar 2026 08:16:23 +0000
Subject: [PATCH 06/59] chore(deps): bump next from 16.2.0 to 16.2.1
Bumps [next](https://github.com/vercel/next.js) from 16.2.0 to 16.2.1.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.0...v16.2.1)
---
updated-dependencies:
- dependency-name: next
dependency-version: 16.2.1
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
---
package.json | 2 +-
pnpm-lock.yaml | 98 +++++++++++++++++++++++++-------------------------
2 files changed, 50 insertions(+), 50 deletions(-)
diff --git a/package.json b/package.json
index 0099cd59..a2aa4ba8 100644
--- a/package.json
+++ b/package.json
@@ -60,7 +60,7 @@
"emoji-picker-react": "^4.18.0",
"https-proxy-agent": "^8.0.0",
"jose": "^6.2.2",
- "next": "16.2.0",
+ "next": "16.2.1",
"node-cron": "^4.2.1",
"otpauth": "^9.5.0",
"pino": "^10.3.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4110e457..a5e4ffff 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -63,8 +63,8 @@ importers:
specifier: ^6.2.2
version: 6.2.2
next:
- specifier: 16.2.0
- version: 16.2.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ specifier: 16.2.1
+ version: 16.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
node-cron:
specifier: ^4.2.1
version: 4.2.1
@@ -768,57 +768,57 @@ packages:
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
- '@next/env@16.2.0':
- resolution: {integrity: sha512-OZIbODWWAi0epQRCRjNe1VO45LOFBzgiyqmTLzIqWq6u1wrxKnAyz1HH6tgY/Mc81YzIjRPoYsPAEr4QV4l9TA==}
+ '@next/env@16.2.1':
+ resolution: {integrity: sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==}
- '@next/swc-darwin-arm64@16.2.0':
- resolution: {integrity: sha512-/JZsqKzKt01IFoiLLAzlNqys7qk2F3JkcUhj50zuRhKDQkZNOz9E5N6wAQWprXdsvjRP4lTFj+/+36NSv5AwhQ==}
+ '@next/swc-darwin-arm64@16.2.1':
+ resolution: {integrity: sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
- '@next/swc-darwin-x64@16.2.0':
- resolution: {integrity: sha512-/hV8erWq4SNlVgglUiW5UmQ5Hwy5EW/AbbXlJCn6zkfKxTy/E/U3V8U1Ocm2YCTUoFgQdoMxRyRMOW5jYy4ygg==}
+ '@next/swc-darwin-x64@16.2.1':
+ resolution: {integrity: sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
- '@next/swc-linux-arm64-gnu@16.2.0':
- resolution: {integrity: sha512-GkjL/Q7MWOwqWR9zoxu1TIHzkOI2l2BHCf7FzeQG87zPgs+6WDh+oC9Sw9ARuuL/FUk6JNCgKRkA6rEQYadUaw==}
+ '@next/swc-linux-arm64-gnu@16.2.1':
+ resolution: {integrity: sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-arm64-musl@16.2.0':
- resolution: {integrity: sha512-1ffhC6KY5qWLg5miMlKJp3dZbXelEfjuXt1qcp5WzSCQy36CV3y+JT7OC1WSFKizGQCDOcQbfkH/IjZP3cdRNA==}
+ '@next/swc-linux-arm64-musl@16.2.1':
+ resolution: {integrity: sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
- '@next/swc-linux-x64-gnu@16.2.0':
- resolution: {integrity: sha512-FmbDcZQ8yJRq93EJSL6xaE0KK/Rslraf8fj1uViGxg7K4CKBCRYSubILJPEhjSgZurpcPQq12QNOJQ0DRJl6Hg==}
+ '@next/swc-linux-x64-gnu@16.2.1':
+ resolution: {integrity: sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
- '@next/swc-linux-x64-musl@16.2.0':
- resolution: {integrity: sha512-HzjIHVkmGAwRbh/vzvoBWWEbb8BBZPxBvVbDQDvzHSf3D8RP/4vjw7MNLDXFF9Q1WEzeQyEj2zdxBtVAHu5Oyw==}
+ '@next/swc-linux-x64-musl@16.2.1':
+ resolution: {integrity: sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
- '@next/swc-win32-arm64-msvc@16.2.0':
- resolution: {integrity: sha512-UMiFNQf5H7+1ZsZPxEsA064WEuFbRNq/kEXyepbCnSErp4f5iut75dBA8UeerFIG3vDaQNOfCpevnERPp2V+nA==}
+ '@next/swc-win32-arm64-msvc@16.2.1':
+ resolution: {integrity: sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
- '@next/swc-win32-x64-msvc@16.2.0':
- resolution: {integrity: sha512-DRrNJKW+/eimrZgdhVN1uvkN1OI4j6Lpefwr44jKQ0YQzztlmOBUUzHuV5GxOMPK3nmodAYElUVCY8ZXo/IWeA==}
+ '@next/swc-win32-x64-msvc@16.2.1':
+ resolution: {integrity: sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -1523,8 +1523,8 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
- baseline-browser-mapping@2.10.9:
- resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==}
+ baseline-browser-mapping@2.10.10:
+ resolution: {integrity: sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -1553,8 +1553,8 @@ packages:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
- caniuse-lite@1.0.30001780:
- resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==}
+ caniuse-lite@1.0.30001781:
+ resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -2648,8 +2648,8 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
- next@16.2.0:
- resolution: {integrity: sha512-NLBVrJy1pbV1Yn00L5sU4vFyAHt5XuSjzrNyFnxo6Com0M0KrL6hHM5B99dbqXb2bE9pm4Ow3Zl1xp6HVY9edQ==}
+ next@16.2.1:
+ resolution: {integrity: sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
@@ -3950,30 +3950,30 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
- '@next/env@16.2.0': {}
+ '@next/env@16.2.1': {}
- '@next/swc-darwin-arm64@16.2.0':
+ '@next/swc-darwin-arm64@16.2.1':
optional: true
- '@next/swc-darwin-x64@16.2.0':
+ '@next/swc-darwin-x64@16.2.1':
optional: true
- '@next/swc-linux-arm64-gnu@16.2.0':
+ '@next/swc-linux-arm64-gnu@16.2.1':
optional: true
- '@next/swc-linux-arm64-musl@16.2.0':
+ '@next/swc-linux-arm64-musl@16.2.1':
optional: true
- '@next/swc-linux-x64-gnu@16.2.0':
+ '@next/swc-linux-x64-gnu@16.2.1':
optional: true
- '@next/swc-linux-x64-musl@16.2.0':
+ '@next/swc-linux-x64-musl@16.2.1':
optional: true
- '@next/swc-win32-arm64-msvc@16.2.0':
+ '@next/swc-win32-arm64-msvc@16.2.1':
optional: true
- '@next/swc-win32-x64-msvc@16.2.0':
+ '@next/swc-win32-x64-msvc@16.2.1':
optional: true
'@noble/hashes@2.0.1': {}
@@ -4476,7 +4476,7 @@ snapshots:
balanced-match@1.0.2: {}
- baseline-browser-mapping@2.10.9: {}
+ baseline-browser-mapping@2.10.10: {}
bidi-js@1.0.3:
dependencies:
@@ -4503,7 +4503,7 @@ snapshots:
camelcase@5.3.1: {}
- caniuse-lite@1.0.30001780: {}
+ caniuse-lite@1.0.30001781: {}
ccount@2.0.1: {}
@@ -5720,25 +5720,25 @@ snapshots:
neo-async@2.6.2: {}
- next@16.2.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
+ next@16.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
- '@next/env': 16.2.0
+ '@next/env': 16.2.1
'@swc/helpers': 0.5.15
- baseline-browser-mapping: 2.10.9
- caniuse-lite: 1.0.30001780
+ baseline-browser-mapping: 2.10.10
+ caniuse-lite: 1.0.30001781
postcss: 8.4.31
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
styled-jsx: 5.1.6(react@19.2.4)
optionalDependencies:
- '@next/swc-darwin-arm64': 16.2.0
- '@next/swc-darwin-x64': 16.2.0
- '@next/swc-linux-arm64-gnu': 16.2.0
- '@next/swc-linux-arm64-musl': 16.2.0
- '@next/swc-linux-x64-gnu': 16.2.0
- '@next/swc-linux-x64-musl': 16.2.0
- '@next/swc-win32-arm64-msvc': 16.2.0
- '@next/swc-win32-x64-msvc': 16.2.0
+ '@next/swc-darwin-arm64': 16.2.1
+ '@next/swc-darwin-x64': 16.2.1
+ '@next/swc-linux-arm64-gnu': 16.2.1
+ '@next/swc-linux-arm64-musl': 16.2.1
+ '@next/swc-linux-x64-gnu': 16.2.1
+ '@next/swc-linux-x64-musl': 16.2.1
+ '@next/swc-win32-arm64-msvc': 16.2.1
+ '@next/swc-win32-x64-msvc': 16.2.1
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
From 4e09b53c018e5a81351c6fb301d5a8cbe92ad97d Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 23 Mar 2026 14:51:46 +0000
Subject: [PATCH 07/59] chore(deps-dev): bump knip from 6.0.2 to 6.0.3
Bumps [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) from 6.0.2 to 6.0.3.
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.0.3/packages/knip)
---
updated-dependencies:
- dependency-name: knip
dependency-version: 6.0.3
dependency-type: direct:development
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
---
package.json | 2 +-
pnpm-lock.yaml | 10 +++++-----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/package.json b/package.json
index a2aa4ba8..526d4af5 100644
--- a/package.json
+++ b/package.json
@@ -90,7 +90,7 @@
"dotenv": "^17.3.1",
"husky": "^9.1.7",
"jsdom": "^29.0.1",
- "knip": "^6.0.2",
+ "knip": "^6.0.3",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"tailwindcss": "^4.2.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a5e4ffff..ca41af9b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -148,8 +148,8 @@ importers:
specifier: ^29.0.1
version: 29.0.1(@noble/hashes@2.0.1)
knip:
- specifier: ^6.0.2
- version: 6.0.2
+ specifier: ^6.0.3
+ version: 6.0.3
postcss:
specifier: ^8.5.8
version: 8.5.8
@@ -2319,8 +2319,8 @@ packages:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
- knip@6.0.2:
- resolution: {integrity: sha512-W17Bo5N9AYn0ZkgWHGBmK/01SrSmr3B6iStr3zudDa2eqi+Kc8VmPjSpTYKDV2Uy/kojrlcH/gS1wypAXfXRRA==}
+ knip@6.0.3:
+ resolution: {integrity: sha512-6Ai+Iv41dVpBYH6mReFejhniWq4eiaKrBw4kghqz2Ew5psQMYEqYxJtXLdj/7vRJ3nVaHpakhYUCKO8p3ftNsQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -5201,7 +5201,7 @@ snapshots:
kind-of@6.0.3: {}
- knip@6.0.2:
+ knip@6.0.3:
dependencies:
'@nodelib/fs.walk': 1.2.8
fast-glob: 3.3.3
From 69e6a3d490c9e98141335ab7a4cbf550eb1778e7 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 23 Mar 2026 14:52:03 +0000
Subject: [PATCH 08/59] chore(deps): bump @tanstack/react-query from 5.95.0 to
5.95.1
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.95.0 to 5.95.1.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.95.1/packages/react-query)
---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
dependency-version: 5.95.1
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
---
package.json | 2 +-
pnpm-lock.yaml | 18 +++++++++---------
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/package.json b/package.json
index a2aa4ba8..e80a4d64 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@formkit/auto-animate": "^0.9.0",
"@tailwindcss/typography": "^0.5.19",
- "@tanstack/react-query": "^5.95.0",
+ "@tanstack/react-query": "^5.95.1",
"argon2": "^0.44.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a5e4ffff..77919e61 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -27,8 +27,8 @@ importers:
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.2.2)
'@tanstack/react-query':
- specifier: ^5.95.0
- version: 5.95.0(react@19.2.4)
+ specifier: ^5.95.1
+ version: 5.95.1(react@19.2.4)
argon2:
specifier: ^0.44.0
version: 0.44.0
@@ -1333,11 +1333,11 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
- '@tanstack/query-core@5.95.0':
- resolution: {integrity: sha512-H1/CWCe8tGL3YIVeo770Z6kPbt0B3M1d/iQXIIK1qlFiFt6G2neYdkHgLapOC8uMYNt9DmHjmGukEKgdMk1P+A==}
+ '@tanstack/query-core@5.95.1':
+ resolution: {integrity: sha512-RTa+CECs9hNMDR2qH4x6bZEjXXDqnjolwecCgwk7ItW1K9WDjUrnyTrtGv4o6aZc1/6uUMkF6DWlYt78uJpgxw==}
- '@tanstack/react-query@5.95.0':
- resolution: {integrity: sha512-EMP8B+BK9zvnAemT8M/y3z/WO0NjZ7fIUY3T3wnHYK6AA3qK/k33i7tPgCXCejhX0cd4I6bJIXN2GmjrHjDBzg==}
+ '@tanstack/react-query@5.95.1':
+ resolution: {integrity: sha512-lqWkRuVtcz9p68/LxxKlWS+M4ACuizJUkVPZryKj0RKE8Se6TD8HU7FNy1c1Mv9C1CPBfzj/p/xiXWK9VCsKJQ==}
peerDependencies:
react: ^18 || ^19
@@ -4281,11 +4281,11 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.2.2
- '@tanstack/query-core@5.95.0': {}
+ '@tanstack/query-core@5.95.1': {}
- '@tanstack/react-query@5.95.0(react@19.2.4)':
+ '@tanstack/react-query@5.95.1(react@19.2.4)':
dependencies:
- '@tanstack/query-core': 5.95.0
+ '@tanstack/query-core': 5.95.1
react: 19.2.4
'@testing-library/dom@10.4.1':
From 3b96ff964058f33d3fe8fd65bf7a6fcde9dbcd3b Mon Sep 17 00:00:00 2001
From: Jordy
Date: Mon, 23 Mar 2026 22:58:15 -0500
Subject: [PATCH 09/59] refactor(Dockerfile): cleaned up build stages
---
Dockerfile | 34 +++++++++++++++-------------------
1 file changed, 15 insertions(+), 19 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 1ebeb2d4..bfab0f1a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,9 +1,4 @@
# Dockerfile
-#
-# Multi-stage build for Tracker Tracker.
-# Stages: deps → builder → prod-deps → runner
-#
-# Uses Next.js standalone output for minimal image size (~150MB vs ~1GB).
# ---------------------------------------------------------------------------
# Base
@@ -23,7 +18,7 @@ COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# ---------------------------------------------------------------------------
-# Stage 2 — Build the Next.js application
+# Stage 2 — Build the Next.js app
# ---------------------------------------------------------------------------
FROM base AS builder
WORKDIR /app
@@ -31,23 +26,25 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Dummy DATABASE_URL so Next.js can evaluate route modules during build
-# (the DB is never actually queried at build time)
+# (In case DB is never actually queried at build time)
ENV DATABASE_URL=postgresql://build:build@localhost:5432/build
RUN pnpm build
# ---------------------------------------------------------------------------
-# Stage 3 — Prune to production deps for schema-sync
+# Stage 3 — deps for drizzle-kit
# ---------------------------------------------------------------------------
-FROM deps AS prod-deps
-WORKDIR /app
-RUN pnpm prune --prod --ignore-scripts
+FROM base AS schema-deps
+WORKDIR /schema-sync
+RUN echo '{"private":true}' > package.json \
+ && pnpm add drizzle-kit drizzle-orm postgres
# ---------------------------------------------------------------------------
# Stage 4 — Production runner
# ---------------------------------------------------------------------------
FROM node:24-alpine AS runner
-# Targeted upgrade for CVE-2026-22184 (zlib). Remove once node:24-alpine ships zlib >= 1.3.2-r0.
-RUN apk add --no-cache libc6-compat bash && apk upgrade --no-cache zlib
+RUN apk add --no-cache libc6-compat bash && apk upgrade --no-cache \
+ && rm -rf /usr/lib/node_modules /usr/local/lib/node_modules \
+ /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack
WORKDIR /app
ENV NODE_ENV=production
@@ -55,22 +52,21 @@ ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
-# Non-root user for security
+# Non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
RUN mkdir -p /data/backups /data/logs && chown -R nextjs:nodejs /data
-# --- Standalone server (traced dependencies only) ---
+# --- Standalone server ---
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
# --- Drizzle schema-sync (for drizzle-kit push at startup) ---
-# Use production-only dependencies to minimize image size and CVE surface.
RUN mkdir -p /schema-sync/src/lib
-COPY --from=prod-deps /app/node_modules /schema-sync/node_modules
-COPY --from=builder /app/package.json /schema-sync/
+COPY --from=schema-deps /schema-sync/node_modules /schema-sync/node_modules
+COPY --from=schema-deps /schema-sync/package.json /schema-sync/
COPY --from=builder /app/drizzle.config.ts /schema-sync/
COPY --from=builder /app/src/lib/db /schema-sync/src/lib/db
COPY --from=builder /app/tsconfig.json /schema-sync/
@@ -86,6 +82,6 @@ USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
- CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
+ CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
ENTRYPOINT ["./docker-entrypoint.sh"]
From bce0854d8ea18cf4a99488ed6d9243b25fc71658 Mon Sep 17 00:00:00 2001
From: Jordy
Date: Mon, 23 Mar 2026 23:54:12 -0500
Subject: [PATCH 10/59] fix(Dockerfile): update package.json for drizzle-kit
with esbuild overrides
---
.trivyignore | 15 ++++++++++-----
Dockerfile | 4 ++--
2 files changed, 12 insertions(+), 7 deletions(-)
diff --git a/.trivyignore b/.trivyignore
index 6fcc4463..9caa1f77 100644
--- a/.trivyignore
+++ b/.trivyignore
@@ -1,19 +1,20 @@
-# Alpine zlib — fix requires base image rebuild. Will resolve when node:24-alpine or node:25-alpine ships with zlib 1.3.2-r0.
+# Alpine zlib — fix requires base image rebuild. Will resolve when node:24-alpine ships with zlib 1.3.2-r0.
CVE-2026-22184
-# minimatch — transitive via npm internals. No direct dep to bump.
+# minimatch — transitive via npm internals baked into base image. Stripped from runner but may appear in intermediate stages.
CVE-2026-26996
CVE-2026-27903
CVE-2026-27904
-# node-tar — transitive via npm internals. No direct dep to bump.
+# node-tar — transitive via npm internals baked into base image. Stripped from runner but may appear in intermediate stages.
CVE-2026-26960
CVE-2026-29786
CVE-2026-31802
-# esbuild Go stdlib — build tool binary, not runtime; CVEs apply to the Go runtime bundled in esbuild (transitive via drizzle-kit).
-# Cannot fix until esbuild releases a version compiled with a patched Go toolchain.
+# esbuild Go stdlib (1.23.x) — build tool binary in schema-sync, not a runtime service. Runs once at startup for drizzle-kit push.
+# Cannot fix until esbuild ships a release built with Go 1.24.13+.
CVE-2025-47912
+CVE-2025-58183
CVE-2025-58185
CVE-2025-58186
CVE-2025-58187
@@ -22,8 +23,12 @@ CVE-2025-58189
CVE-2025-61723
CVE-2025-61724
CVE-2025-61725
+CVE-2025-61726
CVE-2025-61727
+CVE-2025-61728
+CVE-2025-61729
CVE-2025-61730
+CVE-2025-68121
CVE-2026-25679
CVE-2026-27139
CVE-2026-27142
diff --git a/Dockerfile b/Dockerfile
index bfab0f1a..a629cb17 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -31,11 +31,11 @@ ENV DATABASE_URL=postgresql://build:build@localhost:5432/build
RUN pnpm build
# ---------------------------------------------------------------------------
-# Stage 3 — deps for drizzle-kit
+# Stage 3 — Minimal deps for drizzle-kit
# ---------------------------------------------------------------------------
FROM base AS schema-deps
WORKDIR /schema-sync
-RUN echo '{"private":true}' > package.json \
+RUN printf '{"private":true,"pnpm":{"overrides":{"esbuild":">=0.25.0"}}}\n' > package.json \
&& pnpm add drizzle-kit drizzle-orm postgres
# ---------------------------------------------------------------------------
From d70a9b8f4282e244745936af20c9a7aaeeefd9ca Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 24 Mar 2026 14:29:21 +0000
Subject: [PATCH 11/59] chore(deps-dev): bump typescript from 5.9.3 to 6.0.2
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)
---
updated-dependencies:
- dependency-name: typescript
dependency-version: 6.0.2
dependency-type: direct:development
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
---
package.json | 2 +-
pnpm-lock.yaml | 46 ++++++++++++++++++++++++++++------------------
2 files changed, 29 insertions(+), 19 deletions(-)
diff --git a/package.json b/package.json
index 1b44af3e..22f87a02 100644
--- a/package.json
+++ b/package.json
@@ -95,7 +95,7 @@
"prettier": "^3.8.1",
"tailwindcss": "^4.2.2",
"tsx": "^4.21.0",
- "typescript": "^5.9.3",
+ "typescript": "^6.0.2",
"vitest": "^4.1.0"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index df6eb239..653af45d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -107,7 +107,7 @@ importers:
version: 2.4.8
'@commitlint/cli':
specifier: ^20.5.0
- version: 20.5.0(@types/node@25.5.0)(conventional-commits-parser@6.3.0)(typescript@5.9.3)
+ version: 20.5.0(@types/node@25.5.0)(conventional-commits-parser@6.3.0)(typescript@6.0.2)
'@commitlint/config-conventional':
specifier: ^20.5.0
version: 20.5.0
@@ -163,8 +163,8 @@ importers:
specifier: ^4.21.0
version: 4.21.0
typescript:
- specifier: ^5.9.3
- version: 5.9.3
+ specifier: ^6.0.2
+ version: 6.0.2
vitest:
specifier: ^4.1.0
version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
@@ -2799,6 +2799,10 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
@@ -3241,8 +3245,8 @@ packages:
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
- typescript@5.9.3:
- resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+ typescript@6.0.2:
+ resolution: {integrity: sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==}
engines: {node: '>=14.17'}
hasBin: true
@@ -3540,11 +3544,11 @@ snapshots:
dependencies:
css-tree: 3.2.1
- '@commitlint/cli@20.5.0(@types/node@25.5.0)(conventional-commits-parser@6.3.0)(typescript@5.9.3)':
+ '@commitlint/cli@20.5.0(@types/node@25.5.0)(conventional-commits-parser@6.3.0)(typescript@6.0.2)':
dependencies:
'@commitlint/format': 20.5.0
'@commitlint/lint': 20.5.0
- '@commitlint/load': 20.5.0(@types/node@25.5.0)(typescript@5.9.3)
+ '@commitlint/load': 20.5.0(@types/node@25.5.0)(typescript@6.0.2)
'@commitlint/read': 20.5.0(conventional-commits-parser@6.3.0)
'@commitlint/types': 20.5.0
tinyexec: 1.0.4
@@ -3593,14 +3597,14 @@ snapshots:
'@commitlint/rules': 20.5.0
'@commitlint/types': 20.5.0
- '@commitlint/load@20.5.0(@types/node@25.5.0)(typescript@5.9.3)':
+ '@commitlint/load@20.5.0(@types/node@25.5.0)(typescript@6.0.2)':
dependencies:
'@commitlint/config-validator': 20.5.0
'@commitlint/execute-rule': 20.0.0
'@commitlint/resolve-extends': 20.5.0
'@commitlint/types': 20.5.0
- cosmiconfig: 9.0.1(typescript@5.9.3)
- cosmiconfig-typescript-loader: 6.2.0(@types/node@25.5.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3)
+ cosmiconfig: 9.0.1(typescript@6.0.2)
+ cosmiconfig-typescript-loader: 6.2.0(@types/node@25.5.0)(cosmiconfig@9.0.1(typescript@6.0.2))(typescript@6.0.2)
is-plain-obj: 4.1.0
lodash.mergewith: 4.6.2
picocolors: 1.1.1
@@ -4698,21 +4702,21 @@ snapshots:
core-util-is@1.0.3: {}
- cosmiconfig-typescript-loader@6.2.0(@types/node@25.5.0)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3):
+ cosmiconfig-typescript-loader@6.2.0(@types/node@25.5.0)(cosmiconfig@9.0.1(typescript@6.0.2))(typescript@6.0.2):
dependencies:
'@types/node': 25.5.0
- cosmiconfig: 9.0.1(typescript@5.9.3)
+ cosmiconfig: 9.0.1(typescript@6.0.2)
jiti: 2.6.1
- typescript: 5.9.3
+ typescript: 6.0.2
- cosmiconfig@9.0.1(typescript@5.9.3):
+ cosmiconfig@9.0.1(typescript@6.0.2):
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.1
js-yaml: 4.1.1
parse-json: 5.2.0
optionalDependencies:
- typescript: 5.9.3
+ typescript: 6.0.2
cross-env@10.1.0:
dependencies:
@@ -4931,6 +4935,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
figures@3.2.0:
dependencies:
escape-string-regexp: 1.0.5
@@ -5908,6 +5916,8 @@ snapshots:
picomatch@4.0.3: {}
+ picomatch@4.0.4: {}
+
pify@2.3.0: {}
pify@3.0.0: {}
@@ -6409,7 +6419,7 @@ snapshots:
typedarray@0.0.6: {}
- typescript@5.9.3: {}
+ typescript@6.0.2: {}
uglify-js@3.19.3:
optional: true
@@ -6473,8 +6483,8 @@ snapshots:
vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.27.4
- fdir: 6.5.0(picomatch@4.0.3)
- picomatch: 4.0.3
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.0
tinyglobby: 0.2.15
From f9650b08bd6814dc2c2cfc5c53b4a849e6eaf0b4 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 24 Mar 2026 14:29:51 +0000
Subject: [PATCH 12/59] chore(deps): bump @tanstack/react-query from 5.95.1 to
5.95.2
Bumps [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) from 5.95.1 to 5.95.2.
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.95.2/packages/react-query)
---
updated-dependencies:
- dependency-name: "@tanstack/react-query"
dependency-version: 5.95.2
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
---
package.json | 2 +-
pnpm-lock.yaml | 32 +++++++++++++++++++++-----------
2 files changed, 22 insertions(+), 12 deletions(-)
diff --git a/package.json b/package.json
index 1b44af3e..da5ac998 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,7 @@
"@dnd-kit/utilities": "^3.2.2",
"@formkit/auto-animate": "^0.9.0",
"@tailwindcss/typography": "^0.5.19",
- "@tanstack/react-query": "^5.95.1",
+ "@tanstack/react-query": "^5.95.2",
"argon2": "^0.44.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index df6eb239..94393e53 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -27,8 +27,8 @@ importers:
specifier: ^0.5.19
version: 0.5.19(tailwindcss@4.2.2)
'@tanstack/react-query':
- specifier: ^5.95.1
- version: 5.95.1(react@19.2.4)
+ specifier: ^5.95.2
+ version: 5.95.2(react@19.2.4)
argon2:
specifier: ^0.44.0
version: 0.44.0
@@ -1333,11 +1333,11 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
- '@tanstack/query-core@5.95.1':
- resolution: {integrity: sha512-RTa+CECs9hNMDR2qH4x6bZEjXXDqnjolwecCgwk7ItW1K9WDjUrnyTrtGv4o6aZc1/6uUMkF6DWlYt78uJpgxw==}
+ '@tanstack/query-core@5.95.2':
+ resolution: {integrity: sha512-o4T8vZHZET4Bib3jZ/tCW9/7080urD4c+0/AUaYVpIqOsr7y0reBc1oX3ttNaSW5mYyvZHctiQ/UOP2PfdmFEQ==}
- '@tanstack/react-query@5.95.1':
- resolution: {integrity: sha512-lqWkRuVtcz9p68/LxxKlWS+M4ACuizJUkVPZryKj0RKE8Se6TD8HU7FNy1c1Mv9C1CPBfzj/p/xiXWK9VCsKJQ==}
+ '@tanstack/react-query@5.95.2':
+ resolution: {integrity: sha512-/wGkvLj/st5Ud1Q76KF1uFxScV7WeqN1slQx5280ycwAyYkIPGaRZAEgHxe3bjirSd5Zpwkj6zNcR4cqYni/ZA==}
peerDependencies:
react: ^18 || ^19
@@ -2799,6 +2799,10 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
@@ -4281,11 +4285,11 @@ snapshots:
postcss-selector-parser: 6.0.10
tailwindcss: 4.2.2
- '@tanstack/query-core@5.95.1': {}
+ '@tanstack/query-core@5.95.2': {}
- '@tanstack/react-query@5.95.1(react@19.2.4)':
+ '@tanstack/react-query@5.95.2(react@19.2.4)':
dependencies:
- '@tanstack/query-core': 5.95.1
+ '@tanstack/query-core': 5.95.2
react: 19.2.4
'@testing-library/dom@10.4.1':
@@ -4931,6 +4935,10 @@ snapshots:
optionalDependencies:
picomatch: 4.0.3
+ fdir@6.5.0(picomatch@4.0.4):
+ optionalDependencies:
+ picomatch: 4.0.4
+
figures@3.2.0:
dependencies:
escape-string-regexp: 1.0.5
@@ -5908,6 +5916,8 @@ snapshots:
picomatch@4.0.3: {}
+ picomatch@4.0.4: {}
+
pify@2.3.0: {}
pify@3.0.0: {}
@@ -6473,8 +6483,8 @@ snapshots:
vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.27.4
- fdir: 6.5.0(picomatch@4.0.3)
- picomatch: 4.0.3
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.0
tinyglobby: 0.2.15
From 79f9baa2dfc3d2258e904710191a7f01aa42a61b Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 24 Mar 2026 14:30:08 +0000
Subject: [PATCH 13/59] chore(deps-dev): bump knip from 6.0.3 to 6.0.4
Bumps [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) from 6.0.3 to 6.0.4.
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.0.4/packages/knip)
---
updated-dependencies:
- dependency-name: knip
dependency-version: 6.0.4
dependency-type: direct:development
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
---
package.json | 2 +-
pnpm-lock.yaml | 46 ++++++++++++++++++++++++++--------------------
2 files changed, 27 insertions(+), 21 deletions(-)
diff --git a/package.json b/package.json
index 1b44af3e..e9984759 100644
--- a/package.json
+++ b/package.json
@@ -90,7 +90,7 @@
"dotenv": "^17.3.1",
"husky": "^9.1.7",
"jsdom": "^29.0.1",
- "knip": "^6.0.3",
+ "knip": "^6.0.4",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"tailwindcss": "^4.2.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index df6eb239..b86eadeb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -148,8 +148,8 @@ importers:
specifier: ^29.0.1
version: 29.0.1(@noble/hashes@2.0.1)
knip:
- specifier: ^6.0.3
- version: 6.0.3
+ specifier: ^6.0.4
+ version: 6.0.4
postcss:
specifier: ^8.5.8
version: 8.5.8
@@ -2319,8 +2319,8 @@ packages:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
- knip@6.0.3:
- resolution: {integrity: sha512-6Ai+Iv41dVpBYH6mReFejhniWq4eiaKrBw4kghqz2Ew5psQMYEqYxJtXLdj/7vRJ3nVaHpakhYUCKO8p3ftNsQ==}
+ knip@6.0.4:
+ resolution: {integrity: sha512-r/9F7wcxiFM71WgDFQiToE2hQHwZ/UkGmr74o8eiNFPIg80f7rlQHVrZiRX46Tj2yE3s96wUVNGMnsDMylgInw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -2791,14 +2791,18 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
- picomatch@2.3.1:
- resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
@@ -3031,8 +3035,8 @@ packages:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
- smol-toml@1.6.0:
- resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==}
+ smol-toml@1.6.1:
+ resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
engines: {node: '>= 18'}
socks-proxy-agent@9.0.0:
@@ -4927,9 +4931,9 @@ snapshots:
dependencies:
walk-up-path: 4.0.0
- fdir@6.5.0(picomatch@4.0.3):
+ fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
- picomatch: 4.0.3
+ picomatch: 4.0.4
figures@3.2.0:
dependencies:
@@ -5201,7 +5205,7 @@ snapshots:
kind-of@6.0.3: {}
- knip@6.0.3:
+ knip@6.0.4:
dependencies:
'@nodelib/fs.walk': 1.2.8
fast-glob: 3.3.3
@@ -5212,8 +5216,8 @@ snapshots:
oxc-parser: 0.120.0
oxc-resolver: 11.19.1
picocolors: 1.1.1
- picomatch: 4.0.3
- smol-toml: 1.6.0
+ picomatch: 4.0.4
+ smol-toml: 1.6.1
strip-json-comments: 5.0.3
unbash: 2.2.0
yaml: 2.8.3
@@ -5696,7 +5700,7 @@ snapshots:
micromatch@4.0.8:
dependencies:
braces: 3.0.3
- picomatch: 2.3.1
+ picomatch: 2.3.2
min-indent@1.0.1: {}
@@ -5904,10 +5908,12 @@ snapshots:
picocolors@1.1.1: {}
- picomatch@2.3.1: {}
+ picomatch@2.3.2: {}
picomatch@4.0.3: {}
+ picomatch@4.0.4: {}
+
pify@2.3.0: {}
pify@3.0.0: {}
@@ -6226,7 +6232,7 @@ snapshots:
smart-buffer@4.2.0: {}
- smol-toml@1.6.0: {}
+ smol-toml@1.6.1: {}
socks-proxy-agent@9.0.0:
dependencies:
@@ -6361,8 +6367,8 @@ snapshots:
tinyglobby@0.2.15:
dependencies:
- fdir: 6.5.0(picomatch@4.0.3)
- picomatch: 4.0.3
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
tinyrainbow@3.1.0: {}
@@ -6473,8 +6479,8 @@ snapshots:
vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.27.4
- fdir: 6.5.0(picomatch@4.0.3)
- picomatch: 4.0.3
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.0
tinyglobby: 0.2.15
From b2a790245ca76f1ec3ef8220c273a4ab9ca508fd Mon Sep 17 00:00:00 2001
From: Jordy
Date: Wed, 25 Mar 2026 11:02:12 -0500
Subject: [PATCH 14/59] fix(auth): decouple cookie secure flag from node_env
for self-hosted http deployments. Closes #101
---
.env.example | 1 +
.vscode/settings.json | 3 +-
README.md | 1 +
SECURITY.md | 8 +-
docker-compose.yml | 1 +
docs/kb/docs/getting-started/docker-config.md | 3 +-
docs/kb/docs/reference/settings.md | 1 +
docs/kb/docs/troubleshooting/common-errors.md | 9 ++
pnpm-lock.yaml | 68 ++++++---------
src/instrumentation.ts | 8 ++
src/lib/auth-session.test.ts | 73 +++++++++++++++-
src/lib/auth.ts | 7 +-
src/lib/cookie-security.test.ts | 84 +++++++++++++++++++
src/lib/cookie-security.ts | 7 ++
src/proxy.test.ts | 8 +-
src/proxy.ts | 7 +-
16 files changed, 233 insertions(+), 56 deletions(-)
create mode 100644 src/lib/cookie-security.test.ts
create mode 100644 src/lib/cookie-security.ts
diff --git a/.env.example b/.env.example
index ee1f7925..aaf22d7e 100644
--- a/.env.example
+++ b/.env.example
@@ -17,6 +17,7 @@ TZ=America/Chicago
# PORT=3000
# BASE_URL=https://trackertracker.example.com
+# SECURE_COOKIES=true
# LOG_LEVEL=debug
# POSTGRES_DB=tracker_tracker
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 9e30bb88..f92fe287 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -15,5 +15,6 @@
},
"[css]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
- }
+ },
+ "conventionalCommits.scopes": ["auth"]
}
diff --git a/README.md b/README.md
index 1791a47a..40dd2272 100644
--- a/README.md
+++ b/README.md
@@ -162,6 +162,7 @@ pnpm dev
| `POSTGRES_USER` | No | `postgres` | Database user |
| `POSTGRES_DB` | No | `tracker_tracker` | Database name |
| `DATABASE_URL` | No\* | _(auto-built)_ | Override to use an external Postgres instance |
+| `SECURE_COOKIES` | No | _(auto)_ | Set `true` for HTTPS. Auto-enabled by `BASE_URL`. |
\* Set either `POSTGRES_PASSWORD` (bundled DB) or `DATABASE_URL` (external DB).
diff --git a/SECURITY.md b/SECURITY.md
index e4f87644..2cc996a6 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -62,7 +62,7 @@ Last audited: `2026-03-17`
- **Password hashing**: Argon2 (memory-hard KDF) — `src/lib/auth.ts`
- **Session tokens**: Encrypted JWE (A256GCM) via `jose` — `src/lib/auth.ts`
-- **Cookie security**: httpOnly, secure (in production), sameSite=strict, 7-day hard expiry
+- **Cookie security**: httpOnly, secure (when BASE_URL is HTTPS or SECURE_COOKIES=true), sameSite=strict, 7-day hard expiry
- **Password policy**: 8-128 characters enforced on setup and login
- **Username**: Optional login username (6-100 chars), case-insensitive
- **TOTP 2FA**: Optional TOTP via `otpauth` (SHA1, 6 digits, 30s period, ±1 window). Secret encrypted at rest with AES-256-GCM. Stateless enrollment via JWE setup tokens (5min TTL).
@@ -307,7 +307,7 @@ When `backupEncryptionEnabled` is true, the entire backup JSON is wrapped in an
- **Read-only filesystem:** Mount the application container root as read-only (`read_only: true` in docker-compose) with tmpfs for `/tmp`.
- **Network isolation:** Place PostgreSQL on an internal Docker network with no published ports.
- **Reverse proxy:** Deploy behind Nginx, Caddy, or Traefik with TLS termination and rate limiting on `/api/auth/login`.
-- **`NODE_ENV=production`:** Required for the `secure` cookie flag and Next.js production optimizations.
+- **`NODE_ENV=production`:** Required for Next.js production optimizations. Cookie `secure` flag is controlled separately via `BASE_URL` scheme or `SECURE_COOKIES=true`.
- **`SESSION_SECRET`:** Minimum 32 characters of cryptographically random data. Generate with: `openssl rand -base64 48`.
---
@@ -474,7 +474,7 @@ npx tsx scripts/security-audit.ts # Static security audit (28 checks)
#### 5. Session Security
-- [ ] Session cookie set with: `httpOnly: true`, `sameSite: "strict"`, `secure: NODE_ENV === "production"`, `path: "/"`
+- [ ] Session cookie set with: `httpOnly: true`, `sameSite: "strict"`, `secure: shouldSecureCookies()`, `path: "/"`
- [ ] Session has hard expiry (7 days) encoded in the JWE payload — not just cookie `maxAge`
- [ ] Destructive operations (lockdown, nuke, password change, restore) zero-fill the encryption key buffer and stop the scheduler. Logout preserves the scheduler for 24/7 polling.
- [ ] Login returns the encryption key only inside the JWE session — never in the response body
@@ -516,7 +516,7 @@ Verify in `next.config.ts`:
#### 10. Docker & Deployment
- [ ] Container runs as non-root user (UID 1001 `nextjs`)
-- [ ] `NODE_ENV=production` set in Docker Compose
+- [ ] `NODE_ENV=production` set in Dockerfile (cookie `secure` flag derived from `BASE_URL`/`SECURE_COOKIES`, not `NODE_ENV`)
- [ ] PostgreSQL on internal network — no published ports
- [ ] No `.env` files tracked by git (checked by security audit #7)
- [ ] `scripts/reset-password-nuclear.mjs` not included in production Docker image
diff --git a/docker-compose.yml b/docker-compose.yml
index 583cbb6f..8e214687 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -26,6 +26,7 @@ services:
# LOG_LEVEL: ${LOG_LEVEL} # optional, defaults to 'info'
# LOG_FILE: /data/logs/tracker-tracker.log # optional
BASE_URL: ${BASE_URL:-} # optional, i.e https://trackertracker.example.com
+ # SECURE_COOKIES: ${SECURE_COOKIES:-} # set to 'true' if serving over HTTPS
volumes:
- ./data:/data
depends_on:
diff --git a/docs/kb/docs/getting-started/docker-config.md b/docs/kb/docs/getting-started/docker-config.md
index ff5bf27f..e8d440de 100644
--- a/docs/kb/docs/getting-started/docker-config.md
+++ b/docs/kb/docs/getting-started/docker-config.md
@@ -29,6 +29,7 @@ Everything you need to customize how Tracker Tracker runs: environment variables
| `DATABASE_URL` | _(auto-built)_ | Full connection string. Set this to use an external Postgres instance instead of the `POSTGRES_*` variables. Format: `postgresql://user:password@host:5432/dbname` |
| `PORT` | `3000` | Port the app listens on inside the container. The host-side port mapping in `docker-compose.yml` follows this value. |
| `BASE_URL` | _(empty)_ | The public URL where your app is reachable, e.g. `https://trackertracker.example.com`. Used in backup file metadata and notification links. |
+| `SECURE_COOKIES` | _(auto)_ | Set to `true` to mark session cookies as `Secure`. Auto-enabled when `BASE_URL` starts with `https://`. Only needed if you serve over HTTPS without setting `BASE_URL`. |
| `TZ` | `UTC` | Timezone for scheduled tasks and log timestamps. Uses standard tz database names, e.g. `America/Chicago`, `Europe/London`. |
| `LOG_LEVEL` | `info` | Log verbosity. Options: `error`, `warn`, `info`, `debug`. |
| `LOG_FILE` | _(none)_ | Absolute path inside the container to write logs to disk, e.g. `/data/logs/tracker-tracker.log`. |
@@ -143,7 +144,7 @@ If Tracker Tracker sits behind Nginx, Caddy, or Traefik, you don't need to expos
This assumes Traefik is already running with a `websecure` entrypoint and a `letsencrypt` certificate resolver.
!!! info "Set BASE_URL when using a reverse proxy"
-Set `BASE_URL=https://trackertracker.example.com` in `.env` so backup files and notification links use your public address instead of localhost.
+Set `BASE_URL=https://trackertracker.example.com` in `.env`. This enables secure session cookies automatically and ensures backup files and notification links use your public address.
---
diff --git a/docs/kb/docs/reference/settings.md b/docs/kb/docs/reference/settings.md
index a9be5140..ad296585 100644
--- a/docs/kb/docs/reference/settings.md
+++ b/docs/kb/docs/reference/settings.md
@@ -90,3 +90,4 @@ Notification targets are configured individually. Each target is an independent
- **Encrypted storage** — Your proxy password, backup password, API tokens, and download client credentials are all encrypted at rest. Changing your master password re-encrypts everything automatically.
- **Restoring a backup** — Your master password and its associated encryption salt are never included in a backup and are never overwritten when you restore one. Your session stays valid after a restore.
- **Lockout and restores** — Restoring a backup always clears any active lockout, regardless of what was in the backup file.
+- **Secure cookies** — Session cookies are marked `Secure` (HTTPS-only) when `BASE_URL` starts with `https://` or `SECURE_COOKIES=true` is set. If you access the app over plain HTTP, cookies are not marked `Secure` and this is expected.
diff --git a/docs/kb/docs/troubleshooting/common-errors.md b/docs/kb/docs/troubleshooting/common-errors.md
index df447152..4f05fb58 100644
--- a/docs/kb/docs/troubleshooting/common-errors.md
+++ b/docs/kb/docs/troubleshooting/common-errors.md
@@ -165,6 +165,15 @@ Wait for the lockout duration to expire — the remaining time is shown on the l
---
+### Login succeeds but redirects back to login
+
+**Cause:** The server logs show "Login successful" but the browser keeps returning to the login screen. This happens when session cookies are marked `Secure` but the app is accessed over plain HTTP — browsers silently discard `Secure` cookies on non-HTTPS connections.
+
+!!! success "Solution"
+If you access Tracker Tracker over plain HTTP (e.g. `http://192.168.1.x:3000`), no action is needed on recent versions — cookies default to non-secure. If you are on an older version, update to the latest image. If you serve over HTTPS via a reverse proxy, set `BASE_URL=https://your-domain.com` in `.env` to enable secure cookies.
+
+---
+
### TOTP — Invalid code
**Cause:** The 6-digit code was incorrect or expired. TOTP codes are valid for 30 seconds. Clock drift between your authenticator app and the server can cause valid-looking codes to fail.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6969f259..2256ecc5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -149,7 +149,7 @@ importers:
version: 29.0.1(@noble/hashes@2.0.1)
knip:
specifier: ^6.0.4
- version: 6.0.4
+ version: 6.0.5
postcss:
specifier: ^8.5.8
version: 8.5.8
@@ -1523,8 +1523,8 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
- baseline-browser-mapping@2.10.10:
- resolution: {integrity: sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==}
+ baseline-browser-mapping@2.10.9:
+ resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==}
engines: {node: '>=6.0.0'}
hasBin: true
@@ -1553,8 +1553,8 @@ packages:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
- caniuse-lite@1.0.30001781:
- resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==}
+ caniuse-lite@1.0.30001780:
+ resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -2319,8 +2319,8 @@ packages:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
- knip@6.0.4:
- resolution: {integrity: sha512-r/9F7wcxiFM71WgDFQiToE2hQHwZ/UkGmr74o8eiNFPIg80f7rlQHVrZiRX46Tj2yE3s96wUVNGMnsDMylgInw==}
+ knip@6.0.5:
+ resolution: {integrity: sha512-+i9e/ZKuYlECB5iIK82NQwnYso4oNLBhzsTbXhSqCG1qfGi6D84GNtRENafmS3C0lABX8Wf3BKM434nPXi2AbQ==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
@@ -2791,18 +2791,14 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
- picomatch@2.3.2:
- resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
+ picomatch@2.3.1:
+ resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
- picomatch@4.0.4:
- resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
- engines: {node: '>=12'}
-
pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
@@ -3035,8 +3031,8 @@ packages:
resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==}
engines: {node: '>= 6.0.0', npm: '>= 3.0.0'}
- smol-toml@1.6.1:
- resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
+ smol-toml@1.6.0:
+ resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==}
engines: {node: '>= 18'}
socks-proxy-agent@9.0.0:
@@ -4480,7 +4476,7 @@ snapshots:
balanced-match@1.0.2: {}
- baseline-browser-mapping@2.10.10: {}
+ baseline-browser-mapping@2.10.9: {}
bidi-js@1.0.3:
dependencies:
@@ -4507,7 +4503,7 @@ snapshots:
camelcase@5.3.1: {}
- caniuse-lite@1.0.30001781: {}
+ caniuse-lite@1.0.30001780: {}
ccount@2.0.1: {}
@@ -4931,17 +4927,9 @@ snapshots:
dependencies:
walk-up-path: 4.0.0
- fdir@6.5.0(picomatch@4.0.4):
- optionalDependencies:
- picomatch: 4.0.4
-
- fdir@6.5.0(picomatch@4.0.4):
- optionalDependencies:
- picomatch: 4.0.4
-
- fdir@6.5.0(picomatch@4.0.4):
+ fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
- picomatch: 4.0.4
+ picomatch: 4.0.3
figures@3.2.0:
dependencies:
@@ -5213,7 +5201,7 @@ snapshots:
kind-of@6.0.3: {}
- knip@6.0.4:
+ knip@6.0.5:
dependencies:
'@nodelib/fs.walk': 1.2.8
fast-glob: 3.3.3
@@ -5224,8 +5212,8 @@ snapshots:
oxc-parser: 0.120.0
oxc-resolver: 11.19.1
picocolors: 1.1.1
- picomatch: 4.0.4
- smol-toml: 1.6.1
+ picomatch: 4.0.3
+ smol-toml: 1.6.0
strip-json-comments: 5.0.3
unbash: 2.2.0
yaml: 2.8.3
@@ -5708,7 +5696,7 @@ snapshots:
micromatch@4.0.8:
dependencies:
braces: 3.0.3
- picomatch: 2.3.2
+ picomatch: 2.3.1
min-indent@1.0.1: {}
@@ -5736,8 +5724,8 @@ snapshots:
dependencies:
'@next/env': 16.2.1
'@swc/helpers': 0.5.15
- baseline-browser-mapping: 2.10.10
- caniuse-lite: 1.0.30001781
+ baseline-browser-mapping: 2.10.9
+ caniuse-lite: 1.0.30001780
postcss: 8.4.31
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
@@ -5916,12 +5904,10 @@ snapshots:
picocolors@1.1.1: {}
- picomatch@2.3.2: {}
+ picomatch@2.3.1: {}
picomatch@4.0.3: {}
- picomatch@4.0.4: {}
-
pify@2.3.0: {}
pify@3.0.0: {}
@@ -6240,7 +6226,7 @@ snapshots:
smart-buffer@4.2.0: {}
- smol-toml@1.6.1: {}
+ smol-toml@1.6.0: {}
socks-proxy-agent@9.0.0:
dependencies:
@@ -6375,8 +6361,8 @@ snapshots:
tinyglobby@0.2.15:
dependencies:
- fdir: 6.5.0(picomatch@4.0.4)
- picomatch: 4.0.4
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
tinyrainbow@3.1.0: {}
@@ -6487,8 +6473,8 @@ snapshots:
vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.27.4
- fdir: 6.5.0(picomatch@4.0.4)
- picomatch: 4.0.4
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
postcss: 8.5.8
rollup: 4.60.0
tinyglobby: 0.2.15
diff --git a/src/instrumentation.ts b/src/instrumentation.ts
index e5f85563..b46c9cf9 100644
--- a/src/instrumentation.ts
+++ b/src/instrumentation.ts
@@ -11,6 +11,14 @@ export async function register() {
const { startScheduler } = await import("@/lib/scheduler")
const { log } = await import("@/lib/logger")
+ const { shouldSecureCookies } = await import("@/lib/cookie-security")
+ if (!shouldSecureCookies()) {
+ log.warn(
+ "Session cookies are not marked Secure. If this instance is served over HTTPS, " +
+ "set BASE_URL=https://... or SECURE_COOKIES=true in your environment."
+ )
+ }
+
try {
const key = await loadSchedulerKey()
if (!key) {
diff --git a/src/lib/auth-session.test.ts b/src/lib/auth-session.test.ts
index af566a9a..99f4dcde 100644
--- a/src/lib/auth-session.test.ts
+++ b/src/lib/auth-session.test.ts
@@ -76,10 +76,21 @@ vi.mock("jose", () => ({
jwtDecrypt: vi.fn(async (token: string) => ({ payload: JSON.parse(token).payload })),
}))
+vi.mock("@/lib/cookie-security", async () => {
+ return {
+ shouldSecureCookies: () => {
+ if (process.env.SECURE_COOKIES === "true") return true
+ if (process.env.BASE_URL?.toLowerCase().startsWith("https://")) return true
+ return false
+ },
+ }
+})
+
describe("auth session cookies", () => {
beforeEach(() => {
resetCookieState()
tokenCounter = 0
+ vi.unstubAllEnvs()
vi.stubEnv("SESSION_SECRET", "x".repeat(32))
vi.stubEnv("NODE_ENV", "test")
})
@@ -113,8 +124,8 @@ describe("auth session cookies", () => {
await expect(getSession()).resolves.toEqual({ encryptionKey: "a".repeat(64) })
})
- it("uses secure cookies in production and different tokens per session", async () => {
- vi.stubEnv("NODE_ENV", "production")
+ it("uses secure cookies when SECURE_COOKIES=true and different tokens per session", async () => {
+ vi.stubEnv("SECURE_COOKIES", "true")
const { createSession } = await loadAuthModule()
const first = await createSession("b".repeat(64), null)
@@ -127,6 +138,64 @@ describe("auth session cookies", () => {
})
})
+ it("enables secure cookies when BASE_URL uses https", async () => {
+ vi.stubEnv("BASE_URL", "https://trackertracker.example.com")
+ const { createSession } = await loadAuthModule()
+
+ await createSession("f".repeat(64), null)
+
+ expect(getCookieOptions("tt_session")).toMatchObject({
+ secure: true,
+ })
+ })
+
+ it("does not set secure cookies when NODE_ENV is production but no SECURE_COOKIES or HTTPS BASE_URL", async () => {
+ vi.stubEnv("NODE_ENV", "production")
+ const { createSession } = await loadAuthModule()
+
+ await createSession("g".repeat(64), null)
+
+ expect(getCookieOptions("tt_session")).toMatchObject({
+ secure: false,
+ })
+ })
+
+ it("does not set secure cookies when BASE_URL uses http://", async () => {
+ vi.stubEnv("BASE_URL", "http://trackertracker.local")
+ const { createSession } = await loadAuthModule()
+
+ await createSession("h".repeat(64), null)
+
+ expect(getCookieOptions("tt_session")).toMatchObject({
+ secure: false,
+ })
+ expect(getCookieOptions("tt_max_age")).toMatchObject({
+ secure: false,
+ })
+ })
+
+ it("does not set secure cookies when SECURE_COOKIES is the string 'false'", async () => {
+ vi.stubEnv("SECURE_COOKIES", "false")
+ const { createSession } = await loadAuthModule()
+
+ await createSession("i".repeat(64), null)
+
+ expect(getCookieOptions("tt_session")).toMatchObject({
+ secure: false,
+ })
+ })
+
+ it("sets secure cookies when BASE_URL uses HTTPS:// with uppercase scheme", async () => {
+ vi.stubEnv("BASE_URL", "HTTPS://trackertracker.example.com")
+ const { createSession } = await loadAuthModule()
+
+ await createSession("j".repeat(64), null)
+
+ expect(getCookieOptions("tt_session")).toMatchObject({
+ secure: true,
+ })
+ })
+
it("clears both cookies on logout", async () => {
const { clearSession, createSession } = await loadAuthModule()
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 7f8d2d8c..b35146c7 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -8,6 +8,7 @@ import { hkdfSync } from "node:crypto"
import argon2 from "argon2"
import { EncryptJWT, jwtDecrypt } from "jose"
import { cookies } from "next/headers"
+import { shouldSecureCookies } from "@/lib/cookie-security"
const SESSION_COOKIE = "tt_session"
const MAX_AGE_COOKIE = "tt_max_age"
@@ -47,12 +48,12 @@ export async function createSession(
.setExpirationTime(`${jweMaxAge}s`)
.encrypt(getSessionKey())
- const isProduction = process.env.NODE_ENV === "production"
+ const secureCookies = shouldSecureCookies()
const cookieStore = await cookies()
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
- secure: isProduction,
+ secure: secureCookies,
sameSite: "strict",
maxAge: cookieMaxAge,
path: "/",
@@ -61,7 +62,7 @@ export async function createSession(
// Companion cookie so middleware can refresh maxAge without decrypting the JWE
cookieStore.set(MAX_AGE_COOKIE, String(cookieMaxAge), {
httpOnly: true,
- secure: isProduction,
+ secure: secureCookies,
sameSite: "strict",
maxAge: cookieMaxAge,
path: "/",
diff --git a/src/lib/cookie-security.test.ts b/src/lib/cookie-security.test.ts
new file mode 100644
index 00000000..6775cbcc
--- /dev/null
+++ b/src/lib/cookie-security.test.ts
@@ -0,0 +1,84 @@
+// src/lib/cookie-security.test.ts
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+import { shouldSecureCookies } from "./cookie-security"
+
+describe("shouldSecureCookies", () => {
+ beforeEach(() => {
+ vi.unstubAllEnvs()
+ })
+
+ afterEach(() => {
+ vi.unstubAllEnvs()
+ })
+
+ it("returns true when SECURE_COOKIES is the string 'true'", () => {
+ vi.stubEnv("SECURE_COOKIES", "true")
+ expect(shouldSecureCookies()).toBe(true)
+ })
+
+ it("returns false when SECURE_COOKIES is the string 'false'", () => {
+ vi.stubEnv("SECURE_COOKIES", "false")
+ expect(shouldSecureCookies()).toBe(false)
+ })
+
+ it("returns false when SECURE_COOKIES is '1' (non-canonical truthy string)", () => {
+ vi.stubEnv("SECURE_COOKIES", "1")
+ expect(shouldSecureCookies()).toBe(false)
+ })
+
+ it("returns false when SECURE_COOKIES is 'TRUE' (wrong case)", () => {
+ vi.stubEnv("SECURE_COOKIES", "TRUE")
+ expect(shouldSecureCookies()).toBe(false)
+ })
+
+ it("returns true when BASE_URL starts with https://", () => {
+ vi.stubEnv("BASE_URL", "https://trackertracker.example.com")
+ expect(shouldSecureCookies()).toBe(true)
+ })
+
+ it("returns true when BASE_URL starts with HTTPS:// (uppercase scheme)", () => {
+ vi.stubEnv("BASE_URL", "HTTPS://trackertracker.example.com")
+ expect(shouldSecureCookies()).toBe(true)
+ })
+
+ it("returns true when BASE_URL starts with Https:// (mixed case scheme)", () => {
+ vi.stubEnv("BASE_URL", "Https://trackertracker.example.com")
+ expect(shouldSecureCookies()).toBe(true)
+ })
+
+ it("returns false when BASE_URL starts with http://", () => {
+ vi.stubEnv("BASE_URL", "http://trackertracker.local")
+ expect(shouldSecureCookies()).toBe(false)
+ })
+
+ it("returns false when BASE_URL starts with HTTP:// (uppercase http)", () => {
+ vi.stubEnv("BASE_URL", "HTTP://trackertracker.local")
+ expect(shouldSecureCookies()).toBe(false)
+ })
+
+ it("returns false when neither SECURE_COOKIES nor BASE_URL is set", () => {
+ expect(shouldSecureCookies()).toBe(false)
+ })
+
+ it("returns false when NODE_ENV is production but no SECURE_COOKIES or https BASE_URL", () => {
+ vi.stubEnv("NODE_ENV", "production")
+ expect(shouldSecureCookies()).toBe(false)
+ })
+
+ it("SECURE_COOKIES=true takes precedence regardless of BASE_URL scheme", () => {
+ vi.stubEnv("SECURE_COOKIES", "true")
+ vi.stubEnv("BASE_URL", "http://trackertracker.local")
+ expect(shouldSecureCookies()).toBe(true)
+ })
+
+ it("returns false when BASE_URL is an empty string", () => {
+ vi.stubEnv("BASE_URL", "")
+ expect(shouldSecureCookies()).toBe(false)
+ })
+
+ it("returns false when BASE_URL contains https in the path but not the scheme", () => {
+ vi.stubEnv("BASE_URL", "http://example.com/redirect?to=https://other.com")
+ expect(shouldSecureCookies()).toBe(false)
+ })
+})
diff --git a/src/lib/cookie-security.ts b/src/lib/cookie-security.ts
new file mode 100644
index 00000000..7b053133
--- /dev/null
+++ b/src/lib/cookie-security.ts
@@ -0,0 +1,7 @@
+// src/lib/cookie-security.ts
+
+export function shouldSecureCookies(): boolean {
+ if (process.env.SECURE_COOKIES === "true") return true
+ if (process.env.BASE_URL?.toLowerCase().startsWith("https://")) return true
+ return false
+}
diff --git a/src/proxy.test.ts b/src/proxy.test.ts
index 25eb485b..0dc2fe3f 100644
--- a/src/proxy.test.ts
+++ b/src/proxy.test.ts
@@ -1,9 +1,13 @@
// src/proxy.test.ts
import { NextRequest } from "next/server"
-import { describe, expect, it } from "vitest"
+import { describe, expect, it, vi } from "vitest"
import { proxy } from "./proxy"
+vi.mock("@/lib/cookie-security", () => ({
+ shouldSecureCookies: () => false,
+}))
+
describe("auth middleware", () => {
it("allows public auth routes without a session", () => {
const response = proxy(new NextRequest("http://localhost/api/auth/status"))
@@ -42,6 +46,8 @@ describe("auth middleware", () => {
expect(setCookie).toContain("HttpOnly")
expect(setCookie.toLowerCase()).toContain("samesite=strict")
expect(setCookie).toContain("Max-Age=1800")
+ // shouldSecureCookies() is mocked to return false — verify Secure is absent
+ expect(setCookie).not.toContain("Secure")
})
it("does not honor oversized tt_max_age cookie values", () => {
diff --git a/src/proxy.ts b/src/proxy.ts
index 7c2425d8..33a07b15 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -1,5 +1,6 @@
// src/proxy.ts
import { type NextRequest, NextResponse } from "next/server"
+import { shouldSecureCookies } from "@/lib/cookie-security"
const PUBLIC_EXACT = ["/login", "/setup", "/api/health"]
const PUBLIC_PREFIX = ["/api/auth/", "/api/verify-report", "/_next/", "/img/", "/favicon"]
@@ -31,17 +32,17 @@ export function proxy(request: NextRequest) {
const maxAge = maxAgeStr ? parseInt(maxAgeStr, 10) : null
if (maxAge && maxAge > 0 && maxAge <= MAX_COOKIE_AGE) {
- const isProduction = process.env.NODE_ENV === "production"
+ const secureCookies = shouldSecureCookies()
response.cookies.set(SESSION_COOKIE, session.value, {
httpOnly: true,
- secure: isProduction,
+ secure: secureCookies,
sameSite: "strict",
maxAge,
path: "/",
})
response.cookies.set(MAX_AGE_COOKIE, String(maxAge), {
httpOnly: true,
- secure: isProduction,
+ secure: secureCookies,
sameSite: "strict",
maxAge,
path: "/",
From d1cc79d9e732de6251117b81c25560d2467da75a Mon Sep 17 00:00:00 2001
From: Jordy
Date: Wed, 25 Mar 2026 11:07:31 -0500
Subject: [PATCH 15/59] chore(release): 2.4.2
---
CHANGELOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
package.json | 2 +-
2 files changed, 47 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9bdcc602..5b2c0aac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,51 @@
# Changelog
+## [2.4.2](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.3.0...v2.4.2) (2026-03-25)
+
+
+### Features
+
+- add development image to docker hub ([8785094](https://github.com/jordanlambrecht/tracker-tracker/commit/87850942f118ccfd23c0a04d537928cd3db82976))
+- add fetchTrackerStats for future live transit paper data ([ed6662f](https://github.com/jordanlambrecht/tracker-tracker/commit/ed6662f0ac0c9990b6c96e8ae5616452385b6c26))
+- add GitHub Actions workflow for building and pushing development Docker image ([dca0af0](https://github.com/jordanlambrecht/tracker-tracker/commit/dca0af0a75a4ce21fbe14414d86d4ecbe4d459d3))
+- add per-tracker pause polling ([14a6c43](https://github.com/jordanlambrecht/tracker-tracker/commit/14a6c43cd6764924aba6e2fa592ef13d208b528a))
+- add system events viewer and log management ([b01ed22](https://github.com/jordanlambrecht/tracker-tracker/commit/b01ed22ec2ecfefe7f12bb448998d2726cb6b7f9))
+- remote image upload ([a480c34](https://github.com/jordanlambrecht/tracker-tracker/commit/a480c3470b7e97e44764c1d9c6d1bee356d22728))
+- **ui:** add pause/resume button ([c89299e](https://github.com/jordanlambrecht/tracker-tracker/commit/c89299ee80b9ce5ab22743039b6abd40be5a27b6))
+- **ui:** lazy-load chart sections, prefetch sidebar links, and fix scroll-to-top on navigation ([ba8f59e](https://github.com/jordanlambrecht/tracker-tracker/commit/ba8f59ee33a21be72034f296f5da1ae795e03c70))
+
+
+### Bug Fixes
+
+- **api:** orpheus was not matching seeding/leeching to response ([4569238](https://github.com/jordanlambrecht/tracker-tracker/commit/456923879b2970c46432bc9a0b604da2685bc31d))
+- **auth:** decouple cookie secure flag from node_env for self-hosted http deployments. Closes [#101](https://github.com/jordanlambrecht/tracker-tracker/issues/101) ([b2a7902](https://github.com/jordanlambrecht/tracker-tracker/commit/b2a790245ca76f1ec3ef8220c273a4ab9ca508fd))
+- better regex for splitting comparison values in timing safe check ([8c67a50](https://github.com/jordanlambrecht/tracker-tracker/commit/8c67a50cb64196831d7b021249eb76e84766e009))
+- convert bold numbered rules to markdown list items ([6e96454](https://github.com/jordanlambrecht/tracker-tracker/commit/6e964541330cca791818aca5d703e03ac2165694))
+- deploy issues ([cf45ea1](https://github.com/jordanlambrecht/tracker-tracker/commit/cf45ea19726b8f31db5080ebe93909dd0825e995))
+- **Dockerfile:** update package.json for drizzle-kit with esbuild overrides ([bce0854](https://github.com/jordanlambrecht/tracker-tracker/commit/bce0854d8ea18cf4a99488ed6d9243b25fc71658))
+- preload fleet dashboard tab ([5f08951](https://github.com/jordanlambrecht/tracker-tracker/commit/5f0895192f39a740927594ab6617f2c5c04b5708))
+- resolve biome lint warnings ([af8807d](https://github.com/jordanlambrecht/tracker-tracker/commit/af8807d72847e139be396778a096a7695fc49123))
+- **trackers:** markdown rendering ([a5fbdde](https://github.com/jordanlambrecht/tracker-tracker/commit/a5fbdde7056848bb58fdbe6f1e77a765a543842d))
+- update type imports for CollapsibleCard ([23979d1](https://github.com/jordanlambrecht/tracker-tracker/commit/23979d1f6323bd3fa209c9ed19172dbd7d05b6db))
+- update workflow triggers to include development branch for pull requests ([d159775](https://github.com/jordanlambrecht/tracker-tracker/commit/d15977566a9dbd341c0db6f3b67e0ace9bb70f16))
+- wrong postgres setup in docker-compose (closes [#78](https://github.com/jordanlambrecht/tracker-tracker/issues/78)) ([a0a3e0e](https://github.com/jordanlambrecht/tracker-tracker/commit/a0a3e0e16fe4e3b97dea9c7ebc5616cb54e22332))
+
+
+### Performance
+
+- add 5s per-client fetch deadline ([558c4be](https://github.com/jordanlambrecht/tracker-tracker/commit/558c4be0f9b05b198fd09ca5df4aad0dc6cde637))
+- **settings:** settings page optimizations ([63aabab](https://github.com/jordanlambrecht/tracker-tracker/commit/63aabab9afdfde3d492b81b86f581c4c035269d1))
+
+
+### Refactoring
+
+- **charts:** consolidate duplicate Fleet/Torrent chart pairs and normalize upstream data flow ([ca051a8](https://github.com/jordanlambrecht/tracker-tracker/commit/ca051a8f4284565f6b0c09e4c9f0522a70ff7e2c))
+- **charts:** migrate time-series charts to time axis with shared helpers and quality fixes ([596396e](https://github.com/jordanlambrecht/tracker-tracker/commit/596396e205fe387799986af7f1ff8386e8f77d13))
+- **charts:** reorganize chart support files into lib/ subfolder ([98a26d4](https://github.com/jordanlambrecht/tracker-tracker/commit/98a26d4fb4bf2fb7c3b02c4d38151a6b07fbb887))
+- **Dockerfile:** cleaned up build stages ([3b96ff9](https://github.com/jordanlambrecht/tracker-tracker/commit/3b96ff964058f33d3fe8fd65bf7a6fcde9dbcd3b))
+- **settings:** extract CollapsibleCard ([5487d19](https://github.com/jordanlambrecht/tracker-tracker/commit/5487d19d1ac2dd56c6bf2136f072edbcb7868fe5))
+- **settings:** extract SettingsSection wrapper ([ea0572c](https://github.com/jordanlambrecht/tracker-tracker/commit/ea0572c603ef191494a37cd9fe7ca64447bce1d4))
+
## [2.4.1](https://github.com/jordanlambrecht/tracker-tracker/compare/v2.3.0...v2.4.1) (2026-03-23)
diff --git a/package.json b/package.json
index 28f11a82..a24c5f15 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "private-tracker-tracker",
- "version": "2.4.1",
+ "version": "2.4.2",
"description": "Self-hosted dashboard for monitoring private tracker stats over time",
"license": "GPL-3.0",
"repository": {
From baeacd726872b76142b1a9b9a3dd51cc8256a457 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 25 Mar 2026 16:09:04 +0000
Subject: [PATCH 16/59] chore(deps-dev): bump vitest from 4.1.0 to 4.1.1
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.0 to 4.1.1.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.1/packages/vitest)
---
updated-dependencies:
- dependency-name: vitest
dependency-version: 4.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
---
package.json | 2 +-
pnpm-lock.yaml | 120 ++++++++++++++++++++++++++-----------------------
2 files changed, 64 insertions(+), 58 deletions(-)
diff --git a/package.json b/package.json
index a24c5f15..96b05d44 100644
--- a/package.json
+++ b/package.json
@@ -96,6 +96,6 @@
"tailwindcss": "^4.2.2",
"tsx": "^4.21.0",
"typescript": "^6.0.2",
- "vitest": "^4.1.0"
+ "vitest": "^4.1.1"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2256ecc5..7a098a71 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -166,8 +166,8 @@ importers:
specifier: ^6.0.2
version: 6.0.2
vitest:
- specifier: ^4.1.0
- version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
+ specifier: ^4.1.1
+ version: 4.1.1(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
packages:
@@ -1429,34 +1429,34 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
- '@vitest/expect@4.1.0':
- resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==}
+ '@vitest/expect@4.1.1':
+ resolution: {integrity: sha512-xAV0fqBTk44Rn6SjJReEQkHP3RrqbJo6JQ4zZ7/uVOiJZRarBtblzrOfFIZeYUrukp2YD6snZG6IBqhOoHTm+A==}
- '@vitest/mocker@4.1.0':
- resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==}
+ '@vitest/mocker@4.1.1':
+ resolution: {integrity: sha512-h3BOylsfsCLPeceuCPAAJ+BvNwSENgJa4hXoXu4im0bs9Lyp4URc4JYK4pWLZ4pG/UQn7AT92K6IByi6rE6g3A==}
peerDependencies:
msw: ^2.4.9
- vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
- '@vitest/pretty-format@4.1.0':
- resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==}
+ '@vitest/pretty-format@4.1.1':
+ resolution: {integrity: sha512-GM+TEQN5WhOygr1lp7skeVjdLPqqWMHsfzXrcHAqZJi/lIVh63H0kaRCY8MDhNWikx19zBUK8ceaLB7X5AH9NQ==}
- '@vitest/runner@4.1.0':
- resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==}
+ '@vitest/runner@4.1.1':
+ resolution: {integrity: sha512-f7+FPy75vN91QGWsITueq0gedwUZy1fLtHOCMeQpjs8jTekAHeKP80zfDEnhrleviLHzVSDXIWuCIOFn3D3f8A==}
- '@vitest/snapshot@4.1.0':
- resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==}
+ '@vitest/snapshot@4.1.1':
+ resolution: {integrity: sha512-kMVSgcegWV2FibXEx9p9WIKgje58lcTbXgnJixfcg15iK8nzCXhmalL0ZLtTWLW9PH1+1NEDShiFFedB3tEgWg==}
- '@vitest/spy@4.1.0':
- resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==}
+ '@vitest/spy@4.1.1':
+ resolution: {integrity: sha512-6Ti/KT5OVaiupdIZEuZN7l3CZcR0cxnxt70Z0//3CtwgObwA6jZhmVBA3yrXSVN3gmwjgd7oDNLlsXz526gpRA==}
- '@vitest/utils@4.1.0':
- resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==}
+ '@vitest/utils@4.1.1':
+ resolution: {integrity: sha512-cNxAlaB3sHoCdL6pj6yyUXv9Gry1NHNg0kFTXdvSIZXLHsqKH7chiWOkwJ5s5+d/oMwcoG9T0bKU38JZWKusrQ==}
JSONStream@1.3.5:
resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
@@ -2791,14 +2791,18 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
- picomatch@2.3.1:
- resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
+ picomatch@2.3.2:
+ resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
engines: {node: '>=8.6'}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
@@ -3332,21 +3336,21 @@ packages:
yaml:
optional: true
- vitest@4.1.0:
- resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==}
+ vitest@4.1.1:
+ resolution: {integrity: sha512-yF+o4POL41rpAzj5KVILUxm1GCjKnELvaqmU9TLLUbMfDzuN0UpUR9uaDs+mCtjPe+uYPksXDRLQGGPvj1cTmA==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
- '@vitest/browser-playwright': 4.1.0
- '@vitest/browser-preview': 4.1.0
- '@vitest/browser-webdriverio': 4.1.0
- '@vitest/ui': 4.1.0
+ '@vitest/browser-playwright': 4.1.1
+ '@vitest/browser-preview': 4.1.1
+ '@vitest/browser-webdriverio': 4.1.1
+ '@vitest/ui': 4.1.1
happy-dom: '*'
jsdom: '*'
- vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
@@ -4380,44 +4384,44 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
- '@vitest/expect@4.1.0':
+ '@vitest/expect@4.1.1':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
- '@vitest/spy': 4.1.0
- '@vitest/utils': 4.1.0
+ '@vitest/spy': 4.1.1
+ '@vitest/utils': 4.1.1
chai: 6.2.2
tinyrainbow: 3.1.0
- '@vitest/mocker@4.1.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))':
+ '@vitest/mocker@4.1.1(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))':
dependencies:
- '@vitest/spy': 4.1.0
+ '@vitest/spy': 4.1.1
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)
- '@vitest/pretty-format@4.1.0':
+ '@vitest/pretty-format@4.1.1':
dependencies:
tinyrainbow: 3.1.0
- '@vitest/runner@4.1.0':
+ '@vitest/runner@4.1.1':
dependencies:
- '@vitest/utils': 4.1.0
+ '@vitest/utils': 4.1.1
pathe: 2.0.3
- '@vitest/snapshot@4.1.0':
+ '@vitest/snapshot@4.1.1':
dependencies:
- '@vitest/pretty-format': 4.1.0
- '@vitest/utils': 4.1.0
+ '@vitest/pretty-format': 4.1.1
+ '@vitest/utils': 4.1.1
magic-string: 0.30.21
pathe: 2.0.3
- '@vitest/spy@4.1.0': {}
+ '@vitest/spy@4.1.1': {}
- '@vitest/utils@4.1.0':
+ '@vitest/utils@4.1.1':
dependencies:
- '@vitest/pretty-format': 4.1.0
+ '@vitest/pretty-format': 4.1.1
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
@@ -4927,9 +4931,9 @@ snapshots:
dependencies:
walk-up-path: 4.0.0
- fdir@6.5.0(picomatch@4.0.3):
+ fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
- picomatch: 4.0.3
+ picomatch: 4.0.4
figures@3.2.0:
dependencies:
@@ -5696,7 +5700,7 @@ snapshots:
micromatch@4.0.8:
dependencies:
braces: 3.0.3
- picomatch: 2.3.1
+ picomatch: 2.3.2
min-indent@1.0.1: {}
@@ -5904,10 +5908,12 @@ snapshots:
picocolors@1.1.1: {}
- picomatch@2.3.1: {}
+ picomatch@2.3.2: {}
picomatch@4.0.3: {}
+ picomatch@4.0.4: {}
+
pify@2.3.0: {}
pify@3.0.0: {}
@@ -6361,8 +6367,8 @@ snapshots:
tinyglobby@0.2.15:
dependencies:
- fdir: 6.5.0(picomatch@4.0.3)
- picomatch: 4.0.3
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
tinyrainbow@3.1.0: {}
@@ -6473,8 +6479,8 @@ snapshots:
vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
esbuild: 0.27.4
- fdir: 6.5.0(picomatch@4.0.3)
- picomatch: 4.0.3
+ fdir: 6.5.0(picomatch@4.0.4)
+ picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.60.0
tinyglobby: 0.2.15
@@ -6486,21 +6492,21 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.3
- vitest@4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)):
+ vitest@4.1.1(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@2.0.1))(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)):
dependencies:
- '@vitest/expect': 4.1.0
- '@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
- '@vitest/pretty-format': 4.1.0
- '@vitest/runner': 4.1.0
- '@vitest/snapshot': 4.1.0
- '@vitest/spy': 4.1.0
- '@vitest/utils': 4.1.0
+ '@vitest/expect': 4.1.1
+ '@vitest/mocker': 4.1.1(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
+ '@vitest/pretty-format': 4.1.1
+ '@vitest/runner': 4.1.1
+ '@vitest/snapshot': 4.1.1
+ '@vitest/spy': 4.1.1
+ '@vitest/utils': 4.1.1
es-module-lexer: 2.0.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
- picomatch: 4.0.3
+ picomatch: 4.0.4
std-env: 4.0.0
tinybench: 2.9.0
tinyexec: 1.0.4
From d2cd450af0b6cf55bc5fc1c94ea01452c55252fb Mon Sep 17 00:00:00 2001
From: Jordy
Date: Thu, 26 Mar 2026 00:19:35 -0500
Subject: [PATCH 17/59] fix(Icons): update DownloadArrowIcon stroke width
---
src/components/ui/Icons.tsx | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/src/components/ui/Icons.tsx b/src/components/ui/Icons.tsx
index 2f694597..dff99bf3 100644
--- a/src/components/ui/Icons.tsx
+++ b/src/components/ui/Icons.tsx
@@ -259,15 +259,14 @@ function DownloadArrowIcon(props: IconProps) {
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
- strokeWidth={2}
+ strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
-
-
-
+
+
)
}
From 636d22701cd43d8827f9afe49a5105820578f892 Mon Sep 17 00:00:00 2001
From: Jordy
Date: Thu, 26 Mar 2026 01:21:30 -0500
Subject: [PATCH 18/59] feat(schema): add daily checkpoint tables and
TodayAtAGlance types
---
src/lib/db/schema.ts | 62 +++++++++++++++++++++++++++++++++++++++++++-
src/lib/qbt/types.ts | 2 +-
src/types/api.ts | 49 +++++++++++++++++++++++++++++++++-
3 files changed, 110 insertions(+), 3 deletions(-)
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index 0d325ef4..d1b7e707 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -2,7 +2,8 @@
//
// Tables: appSettings, trackers, trackerSnapshots, trackerRoles, downloadClients,
// tagGroups, tagGroupMembers, clientSnapshots, backupHistory, dismissedAlerts,
-// draftQuicklinks (column on appSettings), notificationTargets, notificationDeliveryState
+// draftQuicklinks (column on appSettings), notificationTargets, notificationDeliveryState,
+// trackerDailyCheckpoints, torrentDailyCheckpoints
import {
bigint,
@@ -364,3 +365,62 @@ export const notificationDeliveryState = pgTable(
index("idx_delivery_state_tracker_id").on(table.trackerId),
]
)
+
+// ─── Today At A Glance — daily checkpoint tables ──────────────────────────────
+//
+// These tables store end-of-day snapshots used by the "Today At A Glance"
+// feature to compute daily deltas (e.g. how much was uploaded/downloaded today).
+//
+// trackerDailyCheckpoints: one row per tracker per calendar day, capturing the
+// final metric values recorded that day. snapshotCount reflects how many raw
+// snapshots were taken during the day, giving confidence in the reading quality.
+//
+// torrentDailyCheckpoints: one row per (client, torrent hash, calendar day),
+// recording the starting uploaded/downloaded values for the day. Delta is
+// computed at query time as (current − start).
+
+export const trackerDailyCheckpoints = pgTable(
+ "tracker_daily_checkpoints",
+ {
+ id: serial("id").primaryKey(),
+ trackerId: integer("tracker_id")
+ .references(() => trackers.id, { onDelete: "cascade" })
+ .notNull(),
+ checkpointDate: date("checkpoint_date").notNull(),
+ uploadedBytesEnd: bigint("uploaded_bytes_end", { mode: "bigint" }).notNull(),
+ downloadedBytesEnd: bigint("downloaded_bytes_end", { mode: "bigint" }).notNull(),
+ bufferBytesEnd: bigint("buffer_bytes_end", { mode: "bigint" }),
+ ratioEnd: real("ratio_end"),
+ seedbonusEnd: real("seedbonus_end"),
+ snapshotCount: integer("snapshot_count").notNull().default(0),
+ },
+ (table) => [
+ uniqueIndex("uq_tracker_checkpoint_tracker_date").on(
+ table.trackerId,
+ table.checkpointDate
+ ),
+ ]
+)
+
+export const torrentDailyCheckpoints = pgTable(
+ "torrent_daily_checkpoints",
+ {
+ id: serial("id").primaryKey(),
+ clientId: integer("client_id")
+ .references(() => downloadClients.id, { onDelete: "cascade" })
+ .notNull(),
+ hash: varchar("hash", { length: 64 }).notNull(),
+ name: text("name").notNull(),
+ checkpointDate: date("checkpoint_date").notNull(),
+ uploadedStart: bigint("uploaded_start", { mode: "bigint" }).notNull(),
+ downloadedStart: bigint("downloaded_start", { mode: "bigint" }).notNull(),
+ },
+ (table) => [
+ uniqueIndex("uq_torrent_checkpoint_client_hash_date").on(
+ table.clientId,
+ table.hash,
+ table.checkpointDate
+ ),
+ index("idx_torrent_checkpoint_date").on(table.checkpointDate),
+ ]
+)
diff --git a/src/lib/qbt/types.ts b/src/lib/qbt/types.ts
index e3485b9f..5306d694 100644
--- a/src/lib/qbt/types.ts
+++ b/src/lib/qbt/types.ts
@@ -29,7 +29,7 @@ export interface QbtTorrent {
progress: number // float 0-1, download progress
content_path: string // full path to content
save_path: string // save directory
- isPrivate: boolean // true if from a private tracker
+ isPrivate?: boolean // qBT returns this as is_private (snake_case) — may be undefined in raw API responses
}
// From GET /api/v2/transfer/info
diff --git a/src/types/api.ts b/src/types/api.ts
index 71e77c27..ced0615e 100644
--- a/src/types/api.ts
+++ b/src/types/api.ts
@@ -22,7 +22,7 @@ export interface TrackerLatestStats {
requiredRatio: number | null
warned: boolean | null
freeleechTokens: number | null
- bufferBytes: string | null // bigint serialized as decimal string
+ bufferBytes: string | null
hitAndRuns: number | null
seedbonus: number | null
shareScore: number | null
@@ -114,9 +114,56 @@ export interface QbitmanageTagConfig {
export interface DashboardSettings {
showHealthIndicators: boolean
showLoginTimers: boolean
+ showTodayAtAGlance: boolean
+}
+
+export interface TodayAtAGlance {
+ fleet: {
+ uploadDelta: string
+ downloadDelta: string
+ bufferDelta: string
+ ratioChange: number | null
+ seedbonusChange: number | null
+ uploadDeltaYesterday: string | null
+ downloadDeltaYesterday: string | null
+ bufferDeltaYesterday: string | null
+ }
+ trackers: Array<{
+ id: number
+ name: string
+ color: string | null
+ uploadDelta: string
+ downloadDelta: string
+ bufferDelta: string
+ }>
+ activity: {
+ addedToday: number
+ completedToday: number
+ }
+ movers: {
+ topUploaders: Array<{
+ hash: string
+ name: string
+ qbtTag: string | null
+ trackerColor: string | null
+ clientName: string | null
+ uploadedToday: string
+ }>
+ topDownloaders: Array<{
+ hash: string
+ name: string
+ qbtTag: string | null
+ trackerColor: string | null
+ clientName: string | null
+ downloadedToday: string
+ }>
+ }
+ trackerLastUpdated: string | null
+ clientLastUpdated: string | null
}
export const DASHBOARD_SETTINGS_DEFAULTS: DashboardSettings = {
showHealthIndicators: true,
showLoginTimers: true,
+ showTodayAtAGlance: true,
}
From cf54c7fbd6c2d9171b5d11b621e9a1f68abc381a Mon Sep 17 00:00:00 2001
From: Jordy
Date: Thu, 26 Mar 2026 01:26:54 -0500
Subject: [PATCH 19/59] fix(auth): return 401 on stale session instead of
misleading credential errors
---
src/app/api/clients/[id]/test/route.ts | 10 +-
src/app/api/clients/[id]/torrents/route.ts | 10 +-
src/app/api/clients/client-routes.test.ts | 5 +-
src/app/api/fleet/torrents/route.ts | 7 +
src/app/api/trackers/[id]/poll/route.ts | 8 +
src/lib/__tests__/client-decrypt.test.ts | 2 +-
src/lib/__tests__/error-utils.test.ts | 182 +++++++++++++++++++++
src/lib/client-decrypt.ts | 5 +-
src/lib/error-utils.ts | 13 +-
src/lib/qbt/fetch-merged.ts | 15 +-
10 files changed, 246 insertions(+), 11 deletions(-)
create mode 100644 src/lib/__tests__/error-utils.test.ts
diff --git a/src/app/api/clients/[id]/test/route.ts b/src/app/api/clients/[id]/test/route.ts
index 45d05939..46de3ee4 100644
--- a/src/app/api/clients/[id]/test/route.ts
+++ b/src/app/api/clients/[id]/test/route.ts
@@ -8,6 +8,7 @@ import { authenticate, decodeKey, parseRouteId } from "@/lib/api-helpers"
import { decryptClientCredentials } from "@/lib/client-decrypt"
import { db } from "@/lib/db"
import { downloadClients } from "@/lib/db/schema"
+import { isDecryptionError } from "@/lib/error-utils"
import { log } from "@/lib/logger"
import { buildBaseUrl, getTransferInfo, invalidateSession, login } from "@/lib/qbt"
@@ -34,7 +35,14 @@ export async function POST(_request: Request, props: { params: Promise<{ id: str
let password: string
try {
;({ username, password } = decryptClientCredentials(client, key))
- } catch {
+ } catch (err) {
+ if (isDecryptionError(err)) {
+ log.warn(
+ { route: "POST /api/clients/[id]/test", clientId },
+ "client test failed — stale session key"
+ )
+ return NextResponse.json({ error: "Session expired — please log in again" }, { status: 401 })
+ }
log.error(
{ route: "POST /api/clients/[id]/test", clientId },
"client test failed — credential decrypt error"
diff --git a/src/app/api/clients/[id]/torrents/route.ts b/src/app/api/clients/[id]/torrents/route.ts
index 647245f7..c1c26b7b 100644
--- a/src/app/api/clients/[id]/torrents/route.ts
+++ b/src/app/api/clients/[id]/torrents/route.ts
@@ -8,6 +8,7 @@ import { authenticate, decodeKey, parseRouteId } from "@/lib/api-helpers"
import { decryptClientCredentials } from "@/lib/client-decrypt"
import { db } from "@/lib/db"
import { downloadClients } from "@/lib/db/schema"
+import { isDecryptionError } from "@/lib/error-utils"
import { log } from "@/lib/logger"
import { getTorrents, withSessionRetry } from "@/lib/qbt"
@@ -40,7 +41,14 @@ export async function GET(request: Request, props: { params: Promise<{ id: strin
let password: string
try {
;({ username, password } = decryptClientCredentials(client, key))
- } catch {
+ } catch (err) {
+ if (isDecryptionError(err)) {
+ log.warn(
+ { route: "GET /api/clients/[id]/torrents", clientId },
+ "torrent fetch failed — stale session key"
+ )
+ return NextResponse.json({ error: "Session expired — please log in again" }, { status: 401 })
+ }
log.error(
{ route: "GET /api/clients/[id]/torrents", clientId },
"torrent fetch failed — credential decrypt error"
diff --git a/src/app/api/clients/client-routes.test.ts b/src/app/api/clients/client-routes.test.ts
index 431963e1..c4f84fe9 100644
--- a/src/app/api/clients/client-routes.test.ts
+++ b/src/app/api/clients/client-routes.test.ts
@@ -238,7 +238,7 @@ describe("GET /api/clients/[id]/torrents", () => {
// Credential handling
// -------------------------------------------------------------------------
- it("returns 422 when credential decryption fails", async () => {
+ it("returns 401 when credential decryption fails", async () => {
mockDbSelectClient(MOCK_CLIENT)
;(decrypt as ReturnType).mockImplementation(() => {
throw new Error("decryption error")
@@ -249,7 +249,8 @@ describe("GET /api/clients/[id]/torrents", () => {
const response = await GET(request, { params })
const data = await response.json()
- expect(response.status).toBe(422)
+ expect(response.status).toBe(401)
+ expect(data.error).toMatch(/session expired/i)
// Response body must not contain the encryption key or the encrypted credential strings
const body = JSON.stringify(data)
expect(body).not.toContain(VALID_KEY)
diff --git a/src/app/api/fleet/torrents/route.ts b/src/app/api/fleet/torrents/route.ts
index 167d0611..68d0eb8b 100644
--- a/src/app/api/fleet/torrents/route.ts
+++ b/src/app/api/fleet/torrents/route.ts
@@ -10,6 +10,7 @@ import { NextResponse } from "next/server"
import { authenticate, decodeKey } from "@/lib/api-helpers"
import { db } from "@/lib/db"
import { downloadClients, trackers } from "@/lib/db/schema"
+import { log } from "@/lib/logger"
import { fetchAndMergeTorrents } from "@/lib/qbt/fetch-merged"
export async function GET() {
@@ -43,5 +44,11 @@ export async function GET() {
]
const result = await fetchAndMergeTorrents(clients, tags, key)
+
+ if (result.sessionExpired) {
+ log.warn({ route: "GET /api/fleet/torrents" }, "fleet fetch failed — stale session key")
+ return NextResponse.json({ error: "Session expired — please log in again" }, { status: 401 })
+ }
+
return NextResponse.json(result)
}
diff --git a/src/app/api/trackers/[id]/poll/route.ts b/src/app/api/trackers/[id]/poll/route.ts
index 4097a81c..fb1af665 100644
--- a/src/app/api/trackers/[id]/poll/route.ts
+++ b/src/app/api/trackers/[id]/poll/route.ts
@@ -4,6 +4,7 @@ import { NextResponse } from "next/server"
import { authenticate, decodeKey, parseTrackerId } from "@/lib/api-helpers"
import { db } from "@/lib/db"
import { appSettings, trackers } from "@/lib/db/schema"
+import { isDecryptionError } from "@/lib/error-utils"
import { log } from "@/lib/logger"
import { buildProxyAgentFromSettings } from "@/lib/proxy"
import { pollTracker } from "@/lib/scheduler"
@@ -57,6 +58,13 @@ export async function POST(_request: Request, props: { params: Promise<{ id: str
await pollTracker(trackerId, key, privacyMode, proxyAgent)
return NextResponse.json({ success: true })
} catch (error) {
+ if (isDecryptionError(error)) {
+ log.warn(
+ { route: "POST /api/trackers/[id]/poll", trackerId },
+ "manual poll failed — stale session key"
+ )
+ return NextResponse.json({ error: "Session expired — please log in again" }, { status: 401 })
+ }
const message = error instanceof Error ? error.message : "Poll failed"
log.error(
{ route: "POST /api/trackers/[id]/poll", trackerId, error: message },
diff --git a/src/lib/__tests__/client-decrypt.test.ts b/src/lib/__tests__/client-decrypt.test.ts
index 594f6f8d..270f649f 100644
--- a/src/lib/__tests__/client-decrypt.test.ts
+++ b/src/lib/__tests__/client-decrypt.test.ts
@@ -24,7 +24,7 @@ describe("decryptClientCredentials", () => {
})
const client = { name: "MyClient", encryptedUsername: "x", encryptedPassword: "y" }
expect(() => decryptClientCredentials(client, Buffer.alloc(32))).toThrow(
- 'Credentials are missing or invalid for client "MyClient"'
+ 'Failed to decrypt credentials for client "MyClient"'
)
})
})
diff --git a/src/lib/__tests__/error-utils.test.ts b/src/lib/__tests__/error-utils.test.ts
new file mode 100644
index 00000000..41299eed
--- /dev/null
+++ b/src/lib/__tests__/error-utils.test.ts
@@ -0,0 +1,182 @@
+// src/lib/__tests__/error-utils.test.ts
+
+import { describe, expect, it } from "vitest"
+import { isDecryptionError, sanitizeNetworkError } from "@/lib/error-utils"
+
+// ---------------------------------------------------------------------------
+// isDecryptionError
+// ---------------------------------------------------------------------------
+
+describe("isDecryptionError", () => {
+ // Positive cases — messages that indicate AES-GCM authentication failure
+
+ it("returns true for 'Unsupported state or unable to authenticate data'", () => {
+ expect(isDecryptionError(new Error("Unsupported state or unable to authenticate data"))).toBe(
+ true
+ )
+ })
+
+ it("returns true for 'bad decrypt'", () => {
+ expect(isDecryptionError(new Error("bad decrypt"))).toBe(true)
+ })
+
+ it("returns true for 'bad decrypt' with mixed casing", () => {
+ expect(isDecryptionError(new Error("Bad Decrypt"))).toBe(true)
+ })
+
+ it("returns true for 'Invalid key length'", () => {
+ expect(isDecryptionError(new Error("Invalid key length"))).toBe(true)
+ })
+
+ it("returns true for messages containing 'EVP_' (OpenSSL error codes)", () => {
+ expect(
+ isDecryptionError(
+ new Error("error:1e000065:Cipher functions:OPENSSL_internal:EVP_DecryptFinal_ex")
+ )
+ ).toBe(true)
+ })
+
+ it("returns true for messages containing 'decrypt' anywhere", () => {
+ expect(isDecryptionError(new Error("Failed to decrypt cipher text"))).toBe(true)
+ })
+
+ it("returns true for 'authenticate data' partial match", () => {
+ expect(isDecryptionError(new Error("unable to authenticate data"))).toBe(true)
+ })
+
+ it("is case-insensitive for 'DECRYPT'", () => {
+ expect(isDecryptionError(new Error("DECRYPT failed"))).toBe(true)
+ })
+
+ it("is case-insensitive for 'INVALID KEY'", () => {
+ expect(isDecryptionError(new Error("INVALID KEY supplied"))).toBe(true)
+ })
+
+ // Negative cases — errors unrelated to decryption
+
+ it("returns false for 'Connection refused'", () => {
+ expect(isDecryptionError(new Error("Connection refused"))).toBe(false)
+ })
+
+ it("returns false for 'Timeout'", () => {
+ expect(isDecryptionError(new Error("Timeout"))).toBe(false)
+ })
+
+ it("returns false for 'ECONNREFUSED'", () => {
+ expect(isDecryptionError(new Error("ECONNREFUSED"))).toBe(false)
+ })
+
+ it("returns false for 'Not found'", () => {
+ expect(isDecryptionError(new Error("Not found"))).toBe(false)
+ })
+
+ it("returns false for a generic empty error message", () => {
+ expect(isDecryptionError(new Error(""))).toBe(false)
+ })
+
+ // Non-Error inputs
+
+ it("returns false for a plain string (not an Error instance)", () => {
+ expect(isDecryptionError("bad decrypt")).toBe(false)
+ })
+
+ it("returns false for null", () => {
+ expect(isDecryptionError(null)).toBe(false)
+ })
+
+ it("returns false for undefined", () => {
+ expect(isDecryptionError(undefined)).toBe(false)
+ })
+
+ it("returns false for a number", () => {
+ expect(isDecryptionError(42)).toBe(false)
+ })
+
+ it("returns false for a plain object", () => {
+ expect(isDecryptionError({ message: "bad decrypt" })).toBe(false)
+ })
+
+ it("returns false for an array", () => {
+ expect(isDecryptionError(["bad decrypt"])).toBe(false)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// sanitizeNetworkError
+// ---------------------------------------------------------------------------
+
+describe("sanitizeNetworkError", () => {
+ it("maps 'timed out' to 'Request timed out'", () => {
+ expect(sanitizeNetworkError("Request timed out after 15s")).toBe("Request timed out")
+ })
+
+ it("maps 'timeout' variant to 'Request timed out'", () => {
+ expect(sanitizeNetworkError("Connection timeout")).toBe("Request timed out")
+ })
+
+ it("maps 'ECONNREFUSED' to 'Connection refused'", () => {
+ expect(sanitizeNetworkError("ECONNREFUSED 127.0.0.1:8080")).toBe("Connection refused")
+ })
+
+ it("maps 'ENOTFOUND' to 'Host not found'", () => {
+ expect(sanitizeNetworkError("getaddrinfo ENOTFOUND example.invalid")).toBe("Host not found")
+ })
+
+ it("maps 'EHOSTUNREACH' to 'Host unreachable'", () => {
+ expect(sanitizeNetworkError("EHOSTUNREACH")).toBe("Host unreachable")
+ })
+
+ it("maps 'ECONNRESET' to 'Connection reset'", () => {
+ expect(sanitizeNetworkError("ECONNRESET")).toBe("Connection reset")
+ })
+
+ it("maps 'ip ban' to IP ban message", () => {
+ expect(sanitizeNetworkError("ip ban detected")).toBe("IP temporarily banned by tracker")
+ })
+
+ it("maps 'rate-limit' to IP ban message", () => {
+ expect(sanitizeNetworkError("rate-limit exceeded")).toBe("IP temporarily banned by tracker")
+ })
+
+ it("maps '401' to 'Authentication failed'", () => {
+ expect(sanitizeNetworkError("HTTP 401 Unauthorized")).toBe("Authentication failed")
+ })
+
+ it("maps 'Unauthorized' to 'Authentication failed'", () => {
+ expect(sanitizeNetworkError("Unauthorized")).toBe("Authentication failed")
+ })
+
+ it("maps 'Forbidden' to 'Authentication failed'", () => {
+ expect(sanitizeNetworkError("403 Forbidden")).toBe("Authentication failed")
+ })
+
+ it("maps 'Session expired' to 'Session expired'", () => {
+ expect(sanitizeNetworkError("Session expired")).toBe("Session expired")
+ })
+
+ it("maps 'proxy' to 'Proxy connection failed'", () => {
+ expect(sanitizeNetworkError("Could not connect via proxy")).toBe("Proxy connection failed")
+ })
+
+ it("extracts status code from 'Tracker API error: 404'", () => {
+ expect(sanitizeNetworkError("Tracker API error: 404")).toBe("API returned 404")
+ })
+
+ it("extracts status code from 'Tracker API error: 500'", () => {
+ expect(sanitizeNetworkError("Tracker API error: 500")).toBe("API returned 500")
+ })
+
+ it("returns the default fallback for an unrecognized message", () => {
+ expect(sanitizeNetworkError("Something completely unexpected happened")).toBe(
+ "Connection failed"
+ )
+ })
+
+ it("uses a custom fallback when provided", () => {
+ expect(sanitizeNetworkError("Unknown error", "Polling failed")).toBe("Polling failed")
+ })
+
+ it("returns default fallback for empty string", () => {
+ expect(sanitizeNetworkError("")).toBe("Connection failed")
+ })
+})
diff --git a/src/lib/client-decrypt.ts b/src/lib/client-decrypt.ts
index 30d94af6..440fe1dc 100644
--- a/src/lib/client-decrypt.ts
+++ b/src/lib/client-decrypt.ts
@@ -15,7 +15,8 @@ export function decryptClientCredentials(
username: decrypt(client.encryptedUsername, key),
password: decrypt(client.encryptedPassword, key),
}
- } catch {
- throw new Error(`Credentials are missing or invalid for client "${client.name}"`)
+ } catch (err) {
+ const cause = err instanceof Error ? err.message : String(err)
+ throw new Error(`Failed to decrypt credentials for client "${client.name}": ${cause}`)
}
}
diff --git a/src/lib/error-utils.ts b/src/lib/error-utils.ts
index 2bcd9476..86665a24 100644
--- a/src/lib/error-utils.ts
+++ b/src/lib/error-utils.ts
@@ -1,6 +1,17 @@
// src/lib/error-utils.ts
//
-// Functions: sanitizeNetworkError
+// Functions: sanitizeNetworkError, isDecryptionError
+
+/**
+ * Returns true when an error originates from AES-256-GCM authentication failure
+ * or key/ciphertext mismatch — i.e. the session encryption key is stale.
+ * Used by route handlers to distinguish key-mismatch (→ 401) from genuinely
+ * missing or corrupt stored credentials (→ 422).
+ */
+export function isDecryptionError(error: unknown): boolean {
+ if (!(error instanceof Error)) return false
+ return /decrypt|authenticate\s*data|bad\s*decrypt|invalid\s*key|EVP_/i.test(error.message)
+}
/**
* Maps raw network/auth error messages to safe user-facing messages.
diff --git a/src/lib/qbt/fetch-merged.ts b/src/lib/qbt/fetch-merged.ts
index 0745d4e0..754eb328 100644
--- a/src/lib/qbt/fetch-merged.ts
+++ b/src/lib/qbt/fetch-merged.ts
@@ -32,6 +32,8 @@ export interface MergedResult {
crossSeedTags: string[]
clientErrors: string[]
clientCount: number
+ /** True when every client failure was a decryption error, indicating a stale session key. */
+ sessionExpired: boolean
}
async function fetchClientTorrents(
@@ -90,6 +92,7 @@ export async function fetchAndMergeTorrents(
crossSeedTags: [],
clientErrors: [],
clientCount: 0,
+ sessionExpired: false,
}
if (clients.length === 0 || tags.length === 0) {
@@ -113,6 +116,7 @@ export async function fetchAndMergeTorrents(
const torrentLists: QbtTorrent[][] = []
const crossSeedClients: { crossSeedTags: string[] }[] = []
const clientErrors: string[] = []
+ let decryptionFailureCount = 0
for (let i = 0; i < results.length; i++) {
const result = results[i]
@@ -122,13 +126,17 @@ export async function fetchAndMergeTorrents(
} else {
const clientName = clients[i].name
const raw = result.reason instanceof Error ? result.reason.message : "Unknown error"
- const message = /decrypt|crypt|EVP_/i.test(raw)
- ? "Credential decryption failed"
- : sanitizeNetworkError(raw)
+ const isDecrypt = /decrypt|crypt|EVP_/i.test(raw)
+ if (isDecrypt) decryptionFailureCount++
+ const message = isDecrypt ? "Credential decryption failed" : sanitizeNetworkError(raw)
clientErrors.push(`${clientName}: ${message}`)
}
}
+ // All clients failed with decryption errors → the session key is stale.
+ const sessionExpired =
+ clients.length > 0 && torrentLists.length === 0 && decryptionFailureCount === clients.length
+
// Build hash → client name(s) lookup before merge flattens provenance
const hashClients = new Map()
for (let i = 0; i < results.length; i++) {
@@ -156,5 +164,6 @@ export async function fetchAndMergeTorrents(
crossSeedTags,
clientErrors,
clientCount: clients.length,
+ sessionExpired,
}
}
From d3826a181c017c6b2ac8bbc6fa9d77f6dfd0edc2 Mon Sep 17 00:00:00 2001
From: Jordy
Date: Thu, 26 Mar 2026 09:15:50 -0500
Subject: [PATCH 20/59] feat(dashboard): add Today At A Glance server logic,
checkpoints, and deep poll fixes
---
src/app/api/dashboard/today/route.ts | 32 +
src/app/api/trackers/tracker-routes.test.ts | 25 +-
src/lib/__tests__/client-scheduler.test.ts | 222 +++++-
src/lib/__tests__/formatters-today.test.ts | 163 ++++
src/lib/__tests__/security.test.ts | 8 +-
src/lib/__tests__/today.test.ts | 799 ++++++++++++++++++++
src/lib/client-scheduler.ts | 51 +-
src/lib/formatters.ts | 48 +-
src/lib/nuke.ts | 6 +-
src/lib/qbt/aggregator.ts | 7 +-
src/lib/qbt/client.ts | 42 +-
src/lib/qbt/index.ts | 1 +
src/lib/qbt/qbt.test.ts | 166 +++-
src/lib/scheduler.ts | 105 ++-
src/lib/server-data.ts | 42 +-
src/lib/today.ts | 447 +++++++++++
16 files changed, 2046 insertions(+), 118 deletions(-)
create mode 100644 src/app/api/dashboard/today/route.ts
create mode 100644 src/lib/__tests__/formatters-today.test.ts
create mode 100644 src/lib/__tests__/today.test.ts
create mode 100644 src/lib/today.ts
diff --git a/src/app/api/dashboard/today/route.ts b/src/app/api/dashboard/today/route.ts
new file mode 100644
index 00000000..1ad6b186
--- /dev/null
+++ b/src/app/api/dashboard/today/route.ts
@@ -0,0 +1,32 @@
+// src/app/api/dashboard/today/route.ts
+
+import { NextResponse } from "next/server"
+import { authenticate } from "@/lib/api-helpers"
+import { log } from "@/lib/logger"
+import { backfillTrackerCheckpoints, computeTodayAtAGlance } from "@/lib/today"
+
+const g = globalThis as typeof globalThis & { __backfillDone?: boolean }
+
+export async function GET() {
+ const auth = await authenticate()
+ if (auth instanceof NextResponse) return auth
+
+ try {
+ // One-time backfill on first request that populates checkpoint table from existing snapshots
+ if (!g.__backfillDone) {
+ g.__backfillDone = true
+ try {
+ const filled = await backfillTrackerCheckpoints()
+ if (filled > 0) log.info(`Backfilled ${filled} tracker daily checkpoints`)
+ } catch (err) {
+ log.error(err, "Checkpoint backfill failed")
+ }
+ }
+
+ const data = await computeTodayAtAGlance()
+ return NextResponse.json(data)
+ } catch (error) {
+ log.error(error, "Failed to compute today at a glance")
+ return NextResponse.json({ error: "Failed to compute daily stats" }, { status: 500 })
+ }
+}
diff --git a/src/app/api/trackers/tracker-routes.test.ts b/src/app/api/trackers/tracker-routes.test.ts
index 5859c7bd..f346736a 100644
--- a/src/app/api/trackers/tracker-routes.test.ts
+++ b/src/app/api/trackers/tracker-routes.test.ts
@@ -24,6 +24,7 @@ vi.mock("@/lib/api-helpers", async (importOriginal) => {
vi.mock("@/lib/db", () => ({
db: {
select: vi.fn(),
+ selectDistinctOn: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
@@ -114,8 +115,12 @@ describe("GET /api/trackers", () => {
;(db.select as ReturnType)
.mockReturnValueOnce({ from: mockFromTrackers })
.mockReturnValueOnce({ from: mockSettingsFrom })
- // DISTINCT ON query via db.execute
- ;(db.execute as ReturnType).mockResolvedValueOnce([snapshot])
+ // DISTINCT ON query via db.selectDistinctOn
+ ;(db.selectDistinctOn as ReturnType).mockReturnValueOnce({
+ from: vi.fn().mockReturnValue({
+ orderBy: vi.fn().mockResolvedValue([snapshot]),
+ }),
+ })
const response = await GET()
const data = await response.json()
@@ -139,8 +144,12 @@ describe("GET /api/trackers", () => {
;(db.select as ReturnType)
.mockReturnValueOnce({ from: mockFrom })
.mockReturnValueOnce({ from: mockSettingsFrom })
- // DISTINCT ON query via db.execute
- ;(db.execute as ReturnType).mockResolvedValueOnce([])
+ // DISTINCT ON query via db.selectDistinctOn
+ ;(db.selectDistinctOn as ReturnType).mockReturnValueOnce({
+ from: vi.fn().mockReturnValue({
+ orderBy: vi.fn().mockResolvedValue([]),
+ }),
+ })
const response = await GET()
const data = await response.json()
@@ -181,8 +190,12 @@ describe("GET /api/trackers", () => {
;(db.select as ReturnType)
.mockReturnValueOnce({ from: mockFromTrackers })
.mockReturnValueOnce({ from: mockSettingsFrom })
- // DISTINCT ON query via db.execute
- ;(db.execute as ReturnType).mockResolvedValueOnce([])
+ // DISTINCT ON query via db.selectDistinctOn
+ ;(db.selectDistinctOn as ReturnType).mockReturnValueOnce({
+ from: vi.fn().mockReturnValue({
+ orderBy: vi.fn().mockResolvedValue([]),
+ }),
+ })
const response = await GET()
const data = await response.json()
diff --git a/src/lib/__tests__/client-scheduler.test.ts b/src/lib/__tests__/client-scheduler.test.ts
index 4eef762b..991a7fd7 100644
--- a/src/lib/__tests__/client-scheduler.test.ts
+++ b/src/lib/__tests__/client-scheduler.test.ts
@@ -259,35 +259,15 @@ describe("deepPollClient per-tag optimization", () => {
// -------------------------------------------------------------------------
it("passes deduped per-tag results to aggregateByTag", async () => {
+ // isPrivate omitted — real qBT API returns is_private (snake_case), not isPrivate
const aitherTorrents = [
- { hash: "a1", state: "uploading", tags: "aither", upspeed: 100, dlspeed: 0, isPrivate: true },
- { hash: "a2", state: "uploading", tags: "aither", upspeed: 100, dlspeed: 0, isPrivate: true },
+ { hash: "a1", state: "uploading", tags: "aither", upspeed: 100, dlspeed: 0 },
+ { hash: "a2", state: "uploading", tags: "aither", upspeed: 100, dlspeed: 0 },
]
const crossTorrents = [
- {
- hash: "c1",
- state: "uploading",
- tags: "cross-seed",
- upspeed: 100,
- dlspeed: 0,
- isPrivate: true,
- },
- {
- hash: "c2",
- state: "uploading",
- tags: "cross-seed",
- upspeed: 100,
- dlspeed: 0,
- isPrivate: true,
- },
- {
- hash: "c3",
- state: "uploading",
- tags: "cross-seed",
- upspeed: 100,
- dlspeed: 0,
- isPrivate: true,
- },
+ { hash: "c1", state: "uploading", tags: "cross-seed", upspeed: 100, dlspeed: 0 },
+ { hash: "c2", state: "uploading", tags: "cross-seed", upspeed: 100, dlspeed: 0 },
+ { hash: "c3", state: "uploading", tags: "cross-seed", upspeed: 100, dlspeed: 0 },
]
mockDbSelectSequence(MOCK_CLIENT, ["aither"])
@@ -423,6 +403,7 @@ describe("deepPollClient per-tag optimization", () => {
// -------------------------------------------------------------------------
it("caches filtered torrents to downloadClients on successful poll", async () => {
+ // isPrivate omitted — real qBT API returns is_private (snake_case), not isPrivate
const filteredTorrents = [
{
hash: "a1",
@@ -431,7 +412,6 @@ describe("deepPollClient per-tag optimization", () => {
tags: "aither",
upspeed: 100,
dlspeed: 0,
- isPrivate: true,
},
{
hash: "a2",
@@ -440,7 +420,6 @@ describe("deepPollClient per-tag optimization", () => {
tags: "aither",
upspeed: 200,
dlspeed: 0,
- isPrivate: true,
},
]
@@ -473,6 +452,7 @@ describe("deepPollClient per-tag optimization", () => {
})
it("strips tracker, content_path, and save_path from cached torrents", async () => {
+ // isPrivate omitted — real qBT API returns is_private (snake_case), not isPrivate
const torrentsWithSensitiveFields = [
{
hash: "a1",
@@ -481,7 +461,6 @@ describe("deepPollClient per-tag optimization", () => {
tags: "aither",
upspeed: 100,
dlspeed: 0,
- isPrivate: true,
tracker: "https://aither.cc/announce?passkey=SECRET123",
content_path: "/data/torrents/Movie.mkv",
save_path: "/data/torrents",
@@ -550,3 +529,188 @@ describe("deepPollClient per-tag optimization", () => {
expect(retryCalls[0][4]).toBe("decrypted-pass")
})
})
+
+// ---------------------------------------------------------------------------
+// Regression: isPrivate field mismatch — dedup must not rely on t.isPrivate
+// ---------------------------------------------------------------------------
+
+describe("deepPollClient dedup without isPrivate", () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ })
+
+ // Regression: prevents dedup from silently dropping all torrents when t.isPrivate
+ // is undefined. The old guard `if (!t.isPrivate || seen.has(t.hash)) continue` would
+ // skip every torrent because `t.isPrivate` is always undefined (the real qBT API
+ // returns `is_private` in snake_case, not camelCase). The fix removed the isPrivate
+ // guard entirely, keeping only the hash-based dedup.
+ it("dedup includes torrents that do not have an isPrivate field", async () => {
+ // Torrents constructed WITHOUT isPrivate — matching real qBT API response shape
+ const torrentsWithoutIsPrivate = [
+ { hash: "h1", state: "uploading", tags: "aither", upspeed: 100, dlspeed: 0 },
+ { hash: "h2", state: "uploading", tags: "aither", upspeed: 200, dlspeed: 0 },
+ { hash: "h3", state: "uploading", tags: "aither", upspeed: 300, dlspeed: 0 },
+ ]
+
+ // Sanity-check the test data itself: none of these objects have isPrivate defined
+ for (const t of torrentsWithoutIsPrivate) {
+ expect(Object.hasOwn(t, "isPrivate")).toBe(false)
+ }
+
+ mockDbSelectSequence(MOCK_CLIENT, ["aither"])
+ ;(decrypt as ReturnType)
+ .mockReturnValueOnce("admin")
+ .mockReturnValueOnce("secret")
+ ;(getTorrents as ReturnType).mockResolvedValue(torrentsWithoutIsPrivate)
+ ;(getTransferInfo as ReturnType).mockResolvedValue(MOCK_TRANSFER_INFO)
+ ;(aggregateByTag as ReturnType).mockReturnValue(MOCK_STATS)
+ mockDbInsertSnapshot()
+ mockDbUpdateClient()
+
+ await deepPollClient(1, makeEncryptionKey())
+
+ // aggregateByTag must have been called with all 3 torrents — none were dropped
+ const aggregateCalls = (aggregateByTag as ReturnType).mock.calls
+ expect(aggregateCalls).toHaveLength(1)
+ const passedTorrents = aggregateCalls[0][0]
+ expect(passedTorrents).toHaveLength(3)
+ })
+
+ // Regression: verify that dedup still correctly handles duplicate hashes across tag
+ // fetches even without isPrivate present. The only dedup criterion should be the hash.
+ it("dedup removes duplicate hashes across tag results when isPrivate is absent", async () => {
+ // Same hash "shared" appears in both aither and cross-seed tag responses
+ const aitherResult = [
+ { hash: "shared", state: "uploading", tags: "aither, cross-seed", upspeed: 100, dlspeed: 0 },
+ { hash: "aither-only", state: "uploading", tags: "aither", upspeed: 50, dlspeed: 0 },
+ ]
+ const crossResult = [
+ // "shared" seen again from the cross-seed tag query — should be deduped
+ { hash: "shared", state: "uploading", tags: "aither, cross-seed", upspeed: 100, dlspeed: 0 },
+ { hash: "cross-only", state: "uploading", tags: "cross-seed", upspeed: 75, dlspeed: 0 },
+ ]
+
+ mockDbSelectSequence(MOCK_CLIENT, ["aither"])
+ ;(decrypt as ReturnType)
+ .mockReturnValueOnce("admin")
+ .mockReturnValueOnce("secret")
+ // First call (aither tag) returns aitherResult; second (cross-seed tag) returns crossResult
+ ;(getTorrents as ReturnType)
+ .mockResolvedValueOnce(aitherResult)
+ .mockResolvedValueOnce(crossResult)
+ ;(getTransferInfo as ReturnType).mockResolvedValue(MOCK_TRANSFER_INFO)
+ ;(aggregateByTag as ReturnType).mockReturnValue(MOCK_STATS)
+ mockDbInsertSnapshot()
+ mockDbUpdateClient()
+
+ await deepPollClient(1, makeEncryptionKey())
+
+ const aggregateCalls = (aggregateByTag as ReturnType).mock.calls
+ expect(aggregateCalls).toHaveLength(1)
+ const passedTorrents = aggregateCalls[0][0] as Array<{ hash: string }>
+
+ // 3 unique hashes: "shared", "aither-only", "cross-only"
+ expect(passedTorrents).toHaveLength(3)
+ const hashes = passedTorrents.map((t) => t.hash)
+ expect(hashes).toContain("shared")
+ expect(hashes).toContain("aither-only")
+ expect(hashes).toContain("cross-only")
+ // "shared" must appear exactly once
+ expect(hashes.filter((h) => h === "shared")).toHaveLength(1)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Regression: heartbeat must not overwrite lastPolledAt
+// ---------------------------------------------------------------------------
+
+describe("deepPollClient lastPolledAt written; heartbeat update does not include it", () => {
+ beforeEach(() => {
+ vi.resetAllMocks()
+ })
+
+ // Regression: prevents heartbeat from starving the deep poll scheduler.
+ // deepPollAllClients decides whether to run by comparing now - lastPolledAt >= intervalMs.
+ // If heartbeat (running every 5s) also wrote lastPolledAt, the overdue threshold would
+ // never be reached after the first heartbeat, and deep polls would stop firing.
+ // The fix: heartbeat updates only lastError/errorSince/updatedAt, never lastPolledAt.
+ it("deep poll success writes lastPolledAt to the DB", async () => {
+ setupFullHappyPathMocks(["aither"])
+ const { mockSet } = mockDbUpdateClient()
+
+ await deepPollClient(1, makeEncryptionKey())
+
+ // At least one update call must include lastPolledAt — this is the status update
+ const allSetCalls = mockSet.mock.calls.map((c: unknown[]) => c[0] as Record)
+ const statusUpdate = allSetCalls.find((c) => "lastPolledAt" in c)
+ expect(statusUpdate).toBeDefined()
+ expect(statusUpdate?.lastPolledAt).toBeInstanceOf(Date)
+ })
+
+ // Regression: verifies the scheduling invariant — a client with a recent lastPolledAt
+ // (set during a prior deep poll) should be considered NOT overdue, while a client
+ // with lastPolledAt = null (never polled) should always be considered overdue.
+ // This mirrors the logic in deepPollAllClients without needing to call it directly.
+ it("overdue check: client with null lastPolledAt is always overdue", () => {
+ const pollIntervalSeconds = 30
+ const now = Date.now()
+
+ // Mirrors: const lastPoll = client.lastPolledAt?.getTime() ?? 0
+ // Mirrors: return now - lastPoll >= intervalMs
+ const lastPolledAt = null
+ const intervalMs = pollIntervalSeconds * 1000
+ const lastPoll = lastPolledAt ?? 0
+ const isOverdue = now - lastPoll >= intervalMs
+
+ expect(isOverdue).toBe(true)
+ })
+
+ it("overdue check: client deep-polled within interval is not overdue", () => {
+ const pollIntervalSeconds = 30
+ const now = Date.now()
+
+ // Simulate: heartbeat ran 5s ago but deep poll ran 10s ago (within 30s interval)
+ const lastPolledAt = new Date(now - 10_000) // 10 seconds ago
+ const intervalMs = pollIntervalSeconds * 1000
+ const lastPoll = lastPolledAt.getTime()
+ const isOverdue = now - lastPoll >= intervalMs
+
+ expect(isOverdue).toBe(false)
+ })
+
+ it("overdue check: client whose interval has elapsed is overdue", () => {
+ const pollIntervalSeconds = 30
+ const now = Date.now()
+
+ // Simulate: last deep poll was 35s ago (past the 30s interval)
+ const lastPolledAt = new Date(now - 35_000)
+ const intervalMs = pollIntervalSeconds * 1000
+ const lastPoll = lastPolledAt.getTime()
+ const isOverdue = now - lastPoll >= intervalMs
+
+ expect(isOverdue).toBe(true)
+ })
+
+ // Regression: if heartbeat wrote lastPolledAt (the bug), a client polled 5s ago
+ // via heartbeat would NOT be considered overdue even after the deep poll interval elapsed.
+ // This test documents that scenario: if lastPolledAt reflects a heartbeat timestamp
+ // (5s ago) and the poll interval is 30s, the client should still be overdue —
+ // but only if deep poll hadn't run. We verify the boundary is the deep poll timestamp,
+ // not the heartbeat timestamp.
+ it("overdue check: client whose only recent update was a heartbeat 5s ago is still overdue after 30s interval", () => {
+ const pollIntervalSeconds = 30
+ const now = Date.now()
+
+ // Scenario demonstrating the bug: if heartbeat wrote lastPolledAt 5s ago,
+ // the client would appear not-overdue even though deep poll hasn't run in 60s.
+ // With the fix, heartbeat does NOT write lastPolledAt, so the last deep poll
+ // timestamp (60s ago) is what drives the overdue check.
+ const lastDeepPollAt = new Date(now - 60_000) // deep poll ran 60s ago
+ const intervalMs = pollIntervalSeconds * 1000
+ const lastPoll = lastDeepPollAt.getTime()
+ const isOverdue = now - lastPoll >= intervalMs
+
+ // 60s ago > 30s interval → overdue
+ expect(isOverdue).toBe(true)
+ })
+})
diff --git a/src/lib/__tests__/formatters-today.test.ts b/src/lib/__tests__/formatters-today.test.ts
new file mode 100644
index 00000000..075a10ba
--- /dev/null
+++ b/src/lib/__tests__/formatters-today.test.ts
@@ -0,0 +1,163 @@
+// src/lib/__tests__/formatters-today.test.ts
+
+import { describe, expect, it } from "vitest"
+import { formatBytesFromString, localDateStr } from "@/lib/formatters"
+
+// ---------------------------------------------------------------------------
+// localDateStr
+// ---------------------------------------------------------------------------
+
+describe("localDateStr", () => {
+ it("returns a YYYY-MM-DD formatted string", () => {
+ const result = localDateStr(new Date("2024-06-15"))
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/)
+ })
+
+ it("returns the correct date for a given Date object", () => {
+ // Construct a date at local midnight to avoid any timezone edge case confusion
+ const d = new Date(2024, 5, 15) // June 15 2024 in local time
+ const result = localDateStr(d)
+ expect(result).toBe("2024-06-15")
+ })
+
+ it("returns the correct date for a unix milliseconds number", () => {
+ // 2024-01-20 at noon UTC — choosing noon avoids UTC±local edge
+ const ms = new Date("2024-01-20T12:00:00").getTime()
+ const result = localDateStr(ms)
+ // The year, month, and day must match a local interpretation of that timestamp
+ expect(result).toMatch(/^2024-01-\d{2}$/)
+ })
+
+ it("with no args, returns today's date in YYYY-MM-DD format", () => {
+ const today = new Date().toLocaleDateString("en-CA")
+ const result = localDateStr()
+ expect(result).toBe(today)
+ })
+
+ it("uses en-CA locale formatting (YYYY-MM-DD, not MM/DD/YYYY)", () => {
+ // en-CA produces ISO-style dates; en-US would produce "6/15/2024"
+ const d = new Date(2024, 5, 15)
+ const result = localDateStr(d)
+ // Must not contain slashes
+ expect(result).not.toContain("/")
+ // Must match the ISO date pattern
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/)
+ })
+
+ it("handles the first day of the month without off-by-one", () => {
+ const d = new Date(2024, 0, 1) // January 1 local
+ expect(localDateStr(d)).toBe("2024-01-01")
+ })
+
+ it("handles the last day of the month correctly", () => {
+ const d = new Date(2024, 0, 31) // January 31 local
+ expect(localDateStr(d)).toBe("2024-01-31")
+ })
+
+ it("handles leap day", () => {
+ const d = new Date(2024, 1, 29) // Feb 29 2024 (leap year) local
+ expect(localDateStr(d)).toBe("2024-02-29")
+ })
+
+ it("is consistent between Date object and equivalent unix ms input", () => {
+ const d = new Date(2025, 2, 26) // March 26 2025 local
+ const ms = d.getTime()
+ expect(localDateStr(d)).toBe(localDateStr(ms))
+ })
+})
+
+// ---------------------------------------------------------------------------
+// formatBytesFromString — MiB threshold
+// ---------------------------------------------------------------------------
+
+describe("formatBytesFromString MiB/GiB threshold", () => {
+ const MiB = 1024 * 1024
+ const GiB = 1024 * 1024 * 1024
+ const TiB = 1024 * 1024 * 1024 * 1024
+
+ it("value of exactly 0 bytes returns MiB scale", () => {
+ // "0" bigint → 0 bytes → 0 MiB
+ const result = formatBytesFromString("0")
+ expect(result).toContain("MiB")
+ })
+
+ it("returns '0 MiB' (zero padded rounds to 0) for 0 bytes", () => {
+ expect(formatBytesFromString("0")).toBe("0 MiB")
+ })
+
+ it("500 MiB (under 1 GiB) displays as MiB, not GiB", () => {
+ const bytes = BigInt(500 * MiB)
+ const result = formatBytesFromString(bytes.toString())
+ expect(result).toContain("MiB")
+ expect(result).not.toContain("GiB")
+ expect(result).not.toContain("TiB")
+ })
+
+ it("value just under 1 GiB displays as MiB", () => {
+ const bytes = BigInt(GiB - 1)
+ const result = formatBytesFromString(bytes.toString())
+ expect(result).toContain("MiB")
+ expect(result).not.toContain("GiB")
+ })
+
+ it("exactly 1 GiB displays as GiB", () => {
+ const bytes = BigInt(GiB)
+ const result = formatBytesFromString(bytes.toString())
+ expect(result).toContain("GiB")
+ expect(result).not.toContain("MiB")
+ expect(result).toBe("1.00 GiB")
+ })
+
+ it("value over 1 GiB displays as GiB", () => {
+ const bytes = BigInt(1.5 * GiB)
+ const result = formatBytesFromString(bytes.toString())
+ expect(result).toContain("GiB")
+ expect(result).not.toContain("MiB")
+ expect(result).toBe("1.50 GiB")
+ })
+
+ it("value over 1 TiB displays as TiB", () => {
+ const bytes = BigInt(2 * TiB)
+ const result = formatBytesFromString(bytes.toString())
+ expect(result).toContain("TiB")
+ expect(result).not.toContain("GiB")
+ expect(result).toBe("2.00 TiB")
+ })
+
+ it("value just over 1 TiB displays as TiB not GiB", () => {
+ const bytes = BigInt(TiB + 1)
+ const result = formatBytesFromString(bytes.toString())
+ expect(result).toContain("TiB")
+ })
+
+ it("null input returns the em dash fallback", () => {
+ expect(formatBytesFromString(null)).toBe("—")
+ })
+
+ it("undefined input returns the em dash fallback", () => {
+ expect(formatBytesFromString(undefined)).toBe("—")
+ })
+
+ it("empty string input returns the em dash fallback", () => {
+ expect(formatBytesFromString("")).toBe("—")
+ })
+
+ it("MiB value rounds to whole number (no decimal)", () => {
+ const bytes = BigInt(512 * MiB)
+ const result = formatBytesFromString(bytes.toString())
+ // Math.round(512) → "512 MiB"
+ expect(result).toBe("512 MiB")
+ })
+
+ it("100 MiB rounds and displays correctly", () => {
+ const bytes = BigInt(100 * MiB)
+ const result = formatBytesFromString(bytes.toString())
+ expect(result).toBe("100 MiB")
+ })
+
+ it("1 MiB displays as 1 MiB", () => {
+ const bytes = BigInt(MiB)
+ const result = formatBytesFromString(bytes.toString())
+ expect(result).toBe("1 MiB")
+ })
+})
diff --git a/src/lib/__tests__/security.test.ts b/src/lib/__tests__/security.test.ts
index f256544c..1d54d0a3 100644
--- a/src/lib/__tests__/security.test.ts
+++ b/src/lib/__tests__/security.test.ts
@@ -24,6 +24,7 @@ vi.mock("@/lib/api-helpers", async (importOriginal) => {
vi.mock("@/lib/db", () => ({
db: {
select: vi.fn(),
+ selectDistinctOn: vi.fn(),
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
@@ -845,7 +846,12 @@ describe("Token leakage prevention", () => {
;(db.select as ReturnType)
.mockReturnValueOnce({ from: mockFrom })
.mockReturnValueOnce({ from: mockSettingsFrom })
- // DISTINCT ON query via db.execute — default mock resolves []
+ // DISTINCT ON query via db.selectDistinctOn
+ ;(db.selectDistinctOn as ReturnType).mockReturnValueOnce({
+ from: vi.fn().mockReturnValue({
+ orderBy: vi.fn().mockResolvedValue([]),
+ }),
+ })
const res = await GET()
const body = await res.json()
diff --git a/src/lib/__tests__/today.test.ts b/src/lib/__tests__/today.test.ts
new file mode 100644
index 00000000..d36cc27d
--- /dev/null
+++ b/src/lib/__tests__/today.test.ts
@@ -0,0 +1,799 @@
+// src/lib/__tests__/today.test.ts
+//
+// Tests for computeTodayAtAGlance() in src/lib/today.ts.
+// The DB layer is mocked via vi.mock. Because vi.mock factories are hoisted to
+// the top of the file before any variable declarations, the mock factories use
+// inline string sentinels that match the schema mock values exactly.
+
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+// ---------------------------------------------------------------------------
+// Per-test data store — populated by seedStore() in each test
+// ---------------------------------------------------------------------------
+
+// These string values must match the values returned by the schema mock below.
+// "T_" prefix is just a local naming convention; the actual values are what matter.
+const SENT_TRACKERS = "SENT_trackers"
+const SENT_SNAPSHOTS = "SENT_snapshots"
+const SENT_TRACKER_CP = "SENT_tracker_cp" // single merged query for both checkpoint dates
+const SENT_TORRENT_CP = "SENT_torrent_cp"
+const SENT_CLIENTS = "SENT_clients"
+
+// The store is read by the mock's .from() handler
+const store: Record = {
+ [SENT_TRACKERS]: [],
+ [SENT_SNAPSHOTS]: [],
+ [SENT_TRACKER_CP]: [],
+ [SENT_TORRENT_CP]: [],
+ [SENT_CLIENTS]: [],
+}
+
+function seedStore(
+ trackers: unknown[] = [],
+ snapshots: unknown[] = [],
+ yesterdayCps: unknown[] = [],
+ dayBeforeCps: unknown[] = [],
+ torrentCps: unknown[] = [],
+ clients: unknown[] = []
+) {
+ store[SENT_TRACKERS] = trackers
+ store[SENT_SNAPSHOTS] = snapshots
+ // Both date ranges are now fetched in one inArray query; combine them here
+ store[SENT_TRACKER_CP] = [...yesterdayCps, ...dayBeforeCps]
+ store[SENT_TORRENT_CP] = torrentCps
+ store[SENT_CLIENTS] = clients
+}
+
+// ---------------------------------------------------------------------------
+// DB mock — vi.mock factory is hoisted; use ONLY inline literals here
+// ---------------------------------------------------------------------------
+
+vi.mock("@/lib/db", () => {
+ // NOTE: Cannot reference outer variables here — this factory is hoisted.
+ // The store/storeDayBefore variables are module-level so they ARE accessible
+ // after hoisting as long as we reference them by closure (not by value at
+ // declaration time). Vitest hoists vi.mock but the factory closure still
+ // has access to module-level mutable state.
+ return {
+ db: {
+ select: vi.fn(() => ({
+ from: vi.fn((table: string) => ({
+ where: vi.fn((_cond: unknown) => {
+ // todaySnapshots has .orderBy() after .where()
+ if (table === "SENT_snapshots") {
+ return {
+ orderBy: vi.fn(() => Promise.resolve(store.SENT_snapshots ?? [])),
+ }
+ }
+ // trackerDailyCheckpoints is fetched in a single inArray query
+ if (table === "SENT_tracker_cp") {
+ return Promise.resolve(store.SENT_tracker_cp ?? [])
+ }
+ if (table === "SENT_torrent_cp") {
+ return Promise.resolve(store.SENT_torrent_cp ?? [])
+ }
+ if (table === "SENT_clients") {
+ return Promise.resolve(store.SENT_clients ?? [])
+ }
+ if (table === "SENT_trackers") {
+ return Promise.resolve(store.SENT_trackers ?? [])
+ }
+ return Promise.resolve([])
+ }),
+ })),
+ })),
+ },
+ }
+})
+
+// Schema mock — each table is a string sentinel matching the from() dispatch above
+vi.mock("@/lib/db/schema", () => ({
+ trackers: "SENT_trackers",
+ trackerSnapshots: "SENT_snapshots",
+ trackerDailyCheckpoints: "SENT_tracker_cp",
+ torrentDailyCheckpoints: "SENT_torrent_cp",
+ downloadClients: "SENT_clients",
+}))
+
+vi.mock("drizzle-orm", () => ({
+ eq: vi.fn((_col: unknown, _val: unknown) => ({ type: "eq" })),
+ gte: vi.fn((_col: unknown, _val: unknown) => ({ type: "gte" })),
+ inArray: vi.fn((_col: unknown, _vals: unknown) => ({ type: "inArray" })),
+ sql: vi.fn(() => ({ type: "sql" })),
+}))
+
+// ---------------------------------------------------------------------------
+// Import under test (after mocks)
+// ---------------------------------------------------------------------------
+
+import { localDateStr } from "@/lib/formatters"
+import { computeTodayAtAGlance } from "@/lib/today"
+
+// ---------------------------------------------------------------------------
+// Factories
+// ---------------------------------------------------------------------------
+
+function makeTracker(
+ id: number,
+ overrides: { qbtTag?: string | null; color?: string | null } = {}
+) {
+ return {
+ id,
+ name: `Tracker ${id}`,
+ color: overrides.color ?? null,
+ qbtTag: overrides.qbtTag ?? null,
+ }
+}
+
+function makeSnapshot(
+ trackerId: number,
+ polledAt: Date,
+ uploadedBytes: string,
+ downloadedBytes: string,
+ bufferBytes: string | null = null,
+ ratio: number | null = null,
+ seedbonus: number | null = null
+) {
+ return {
+ id: Math.random(),
+ trackerId,
+ polledAt,
+ uploadedBytes,
+ downloadedBytes,
+ bufferBytes,
+ ratio,
+ seedbonus,
+ seedingCount: null,
+ leechingCount: null,
+ hitAndRuns: null,
+ warned: null,
+ freeleechTokens: null,
+ shareScore: null,
+ username: null,
+ group: null,
+ }
+}
+
+function makeTrackerCheckpoint(
+ trackerId: number,
+ checkpointDate: string,
+ uploadedBytesEnd: string,
+ downloadedBytesEnd: string,
+ bufferBytesEnd: string | null = null
+) {
+ return {
+ id: Math.random(),
+ trackerId,
+ checkpointDate,
+ uploadedBytesEnd,
+ downloadedBytesEnd,
+ bufferBytesEnd,
+ ratioEnd: null,
+ seedbonusEnd: null,
+ snapshotCount: 1,
+ }
+}
+
+function makeTorrent(
+ hash: string,
+ name: string,
+ tags: string,
+ uploaded: number,
+ downloaded: number,
+ addedOn = 0,
+ completionOn = -1
+) {
+ return {
+ hash,
+ name,
+ state: "uploading",
+ tags,
+ category: "",
+ upspeed: 0,
+ dlspeed: 0,
+ uploaded,
+ downloaded,
+ ratio: 1.0,
+ size: downloaded || 1,
+ num_seeds: 5,
+ num_leechs: 0,
+ num_complete: 5,
+ num_incomplete: 0,
+ tracker: "",
+ added_on: addedOn,
+ completion_on: completionOn,
+ last_activity: 0,
+ seeding_time: 0,
+ time_active: 0,
+ seen_complete: 0,
+ availability: 1,
+ amount_left: 0,
+ progress: 1,
+ content_path: "",
+ save_path: "",
+ }
+}
+
+function makeTorrentCheckpoint(
+ clientId: number,
+ hash: string,
+ uploadedStart: string,
+ downloadedStart: string
+) {
+ return {
+ id: Math.random(),
+ clientId,
+ hash,
+ checkpointDate: localDateStr(),
+ uploadedStart,
+ downloadedStart,
+ }
+}
+
+// ---------------------------------------------------------------------------
+// beforeEach resets store to empty state
+// ---------------------------------------------------------------------------
+
+beforeEach(() => {
+ seedStore()
+})
+
+// ---------------------------------------------------------------------------
+// Empty state
+// ---------------------------------------------------------------------------
+
+describe("computeTodayAtAGlance — empty state", () => {
+ it("returns zero fleet upload delta when there are no trackers", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(result.fleet.uploadDelta).toBe("0")
+ })
+
+ it("returns zero fleet download delta when there are no trackers", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(result.fleet.downloadDelta).toBe("0")
+ })
+
+ it("returns zero fleet buffer delta when there are no trackers", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(result.fleet.bufferDelta).toBe("0")
+ })
+
+ it("returns null ratioChange when there is no upload activity", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(result.fleet.ratioChange).toBeNull()
+ })
+
+ it("returns null yesterday delta values when no checkpoints exist", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(result.fleet.uploadDeltaYesterday).toBeNull()
+ expect(result.fleet.downloadDeltaYesterday).toBeNull()
+ expect(result.fleet.bufferDeltaYesterday).toBeNull()
+ })
+
+ it("returns empty trackers array", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(result.trackers).toEqual([])
+ })
+
+ it("returns zero activity counts", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(result.activity.addedToday).toBe(0)
+ expect(result.activity.completedToday).toBe(0)
+ })
+
+ it("returns empty movers arrays", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(result.movers.topUploaders).toHaveLength(0)
+ expect(result.movers.topDownloaders).toHaveLength(0)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Single tracker, two snapshots today
+// ---------------------------------------------------------------------------
+
+describe("computeTodayAtAGlance — single tracker two snapshots today", () => {
+ const GiB = 1024 * 1024 * 1024
+
+ beforeEach(() => {
+ const now = new Date()
+ const tracker = makeTracker(1)
+ const earliest = makeSnapshot(
+ 1,
+ new Date(now.getTime() - 3600000),
+ String(10n * BigInt(GiB)),
+ String(5n * BigInt(GiB))
+ )
+ const latest = makeSnapshot(
+ 1,
+ now,
+ String(11n * BigInt(GiB)),
+ String(5n * BigInt(GiB) + 512n * 1024n * 1024n)
+ )
+ seedStore([tracker], [earliest, latest])
+ })
+
+ it("computes upload delta as last minus first snapshot value", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(BigInt(result.fleet.uploadDelta)).toBe(BigInt(GiB))
+ })
+
+ it("computes download delta correctly", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(BigInt(result.fleet.downloadDelta)).toBe(512n * 1024n * 1024n)
+ })
+
+ it("exposes the tracker entry with the correct upload delta", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(result.trackers).toHaveLength(1)
+ expect(result.trackers[0].id).toBe(1)
+ expect(BigInt(result.trackers[0].uploadDelta)).toBe(BigInt(GiB))
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Fewer than 2 snapshots
+// ---------------------------------------------------------------------------
+
+describe("computeTodayAtAGlance — tracker with only one snapshot today", () => {
+ beforeEach(() => {
+ const tracker = makeTracker(1)
+ const snap = makeSnapshot(1, new Date(), "1000000000", "500000000")
+ seedStore([tracker], [snap])
+ })
+
+ it("returns zero deltas when tracker has fewer than 2 snapshots today", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(result.trackers[0].uploadDelta).toBe("0")
+ expect(result.trackers[0].downloadDelta).toBe("0")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Null bufferBytes handling
+// ---------------------------------------------------------------------------
+
+describe("computeTodayAtAGlance — null bufferBytes on both snapshots", () => {
+ beforeEach(() => {
+ const tracker = makeTracker(1)
+ const earliest = makeSnapshot(
+ 1,
+ new Date(Date.now() - 3600000),
+ "1000000000",
+ "500000000",
+ null
+ )
+ const latest = makeSnapshot(1, new Date(), "2000000000", "600000000", null)
+ seedStore([tracker], [earliest, latest])
+ })
+
+ it("does not crash when bufferBytes is null and returns 0 buffer delta", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(result.fleet.bufferDelta).toBe("0")
+ expect(result.trackers[0].bufferDelta).toBe("0")
+ })
+})
+
+describe("computeTodayAtAGlance — null bufferBytes on earliest, value on latest", () => {
+ beforeEach(() => {
+ const tracker = makeTracker(1)
+ const earliest = makeSnapshot(
+ 1,
+ new Date(Date.now() - 3600000),
+ "1000000000",
+ "500000000",
+ null
+ )
+ const latest = makeSnapshot(1, new Date(), "2000000000", "600000000", "1073741824")
+ seedStore([tracker], [earliest, latest])
+ })
+
+ it("treats null bufferBytes as 0n so delta equals the latest buffer value", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(BigInt(result.trackers[0].bufferDelta)).toBe(1073741824n)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Yesterday comparison via checkpoint pairs
+// ---------------------------------------------------------------------------
+
+describe("computeTodayAtAGlance — yesterday comparison", () => {
+ const yesterdayStr = localDateStr(Date.now() - 86400000)
+ const dayBeforeStr = localDateStr(Date.now() - 172800000)
+
+ it("computes fleet upload and download deltas for yesterday", async () => {
+ const tracker = makeTracker(1)
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "2000000000", "1000000000")
+ const snap2 = makeSnapshot(1, new Date(), "3000000000", "1500000000")
+ // Yesterday ended at 2G upload, day-before ended at 1G → delta = 1G
+ const yestCp = makeTrackerCheckpoint(1, yesterdayStr, "2000000000", "1000000000")
+ const dayBeforeCp = makeTrackerCheckpoint(1, dayBeforeStr, "1000000000", "500000000")
+ seedStore([tracker], [snap1, snap2], [yestCp], [dayBeforeCp])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.fleet.uploadDeltaYesterday).toBe("1000000000")
+ expect(result.fleet.downloadDeltaYesterday).toBe("500000000")
+ })
+
+ it("returns null when yesterday checkpoint exists but no day-before checkpoint", async () => {
+ const tracker = makeTracker(1)
+ const snap = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+ const yestCp = makeTrackerCheckpoint(1, yesterdayStr, "2000000000", "1000000000")
+ seedStore([tracker], [snap], [yestCp], [])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.fleet.uploadDeltaYesterday).toBeNull()
+ expect(result.fleet.downloadDeltaYesterday).toBeNull()
+ })
+
+ it("returns null when checkpoints exist for a different tracker id", async () => {
+ const tracker = makeTracker(1)
+ const snap = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+ // trackerId 99 does not match tracker 1
+ const yestCp = makeTrackerCheckpoint(99, yesterdayStr, "2000000000", "1000000000")
+ const dayBeforeCp = makeTrackerCheckpoint(99, dayBeforeStr, "1000000000", "500000000")
+ seedStore([tracker], [snap], [yestCp], [dayBeforeCp])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.fleet.uploadDeltaYesterday).toBeNull()
+ })
+
+ it("treats null bufferBytesEnd as 0n in yesterday buffer delta", async () => {
+ const tracker = makeTracker(1)
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000")
+ const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+ const yestCp = makeTrackerCheckpoint(1, yesterdayStr, "2000000000", "1000000000", null)
+ const dayBeforeCp = makeTrackerCheckpoint(1, dayBeforeStr, "1000000000", "500000000", null)
+ seedStore([tracker], [snap1, snap2], [yestCp], [dayBeforeCp])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.fleet.bufferDeltaYesterday).toBe("0")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Torrent movers tag filtering
+// ---------------------------------------------------------------------------
+
+describe("computeTodayAtAGlance — torrent movers tag filtering", () => {
+ it("excludes torrents whose tags do not match any tracker qbtTag", async () => {
+ const tracker = makeTracker(1, { qbtTag: "aither" })
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000")
+ const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+ const torrent = makeTorrent("hash1", "Some Movie", "other-tracker", 5000000000, 3000000000)
+ const cp = makeTorrentCheckpoint(1, "hash1", "0", "0")
+ const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) }
+ seedStore([tracker], [snap1, snap2], [], [], [cp], [client])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.movers.topUploaders).toHaveLength(0)
+ expect(result.movers.topDownloaders).toHaveLength(0)
+ })
+
+ it("includes torrents whose tags match a tracker's qbtTag", async () => {
+ const tracker = makeTracker(1, { qbtTag: "aither" })
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000")
+ const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+ const torrent = makeTorrent("hash1", "Some Movie", "aither", 5000000000, 3000000000)
+ const cp = makeTorrentCheckpoint(1, "hash1", "0", "0")
+ const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) }
+ seedStore([tracker], [snap1, snap2], [], [], [cp], [client])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.movers.topUploaders).toHaveLength(1)
+ expect(result.movers.topUploaders[0].hash).toBe("hash1")
+ expect(result.movers.topUploaders[0].qbtTag).toBe("aither")
+ })
+
+ it("excludes torrents with zero upload delta from topUploaders", async () => {
+ const tracker = makeTracker(1, { qbtTag: "aither" })
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000")
+ const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+ // uploaded matches checkpoint start → upload delta = 0
+ const torrent = makeTorrent("hash1", "Stalled Movie", "aither", 1000000000, 3000000000)
+ const cp = makeTorrentCheckpoint(1, "hash1", "1000000000", "0")
+ const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) }
+ seedStore([tracker], [snap1, snap2], [], [], [cp], [client])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.movers.topUploaders).toHaveLength(0)
+ })
+
+ it("excludes torrents with zero download delta from topDownloaders", async () => {
+ const tracker = makeTracker(1, { qbtTag: "aither" })
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000")
+ const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+ // downloaded matches checkpoint start → download delta = 0
+ const torrent = makeTorrent("hash1", "Completed Movie", "aither", 5000000000, 3000000000)
+ const cp = makeTorrentCheckpoint(1, "hash1", "0", "3000000000")
+ const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) }
+ seedStore([tracker], [snap1, snap2], [], [], [cp], [client])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.movers.topDownloaders).toHaveLength(0)
+ })
+
+ it("caps topUploaders and topDownloaders at 5 entries each", async () => {
+ const tracker = makeTracker(1, { qbtTag: "aither" })
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000")
+ const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+ const torrents = Array.from({ length: 8 }, (_, i) =>
+ makeTorrent(`hash${i}`, `Movie ${i}`, "aither", (i + 1) * 1000000000, (i + 1) * 500000000)
+ )
+ const cps = torrents.map((t) => makeTorrentCheckpoint(1, t.hash, "0", "0"))
+ const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify(torrents) }
+ seedStore([tracker], [snap1, snap2], [], [], cps, [client])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.movers.topUploaders.length).toBeLessThanOrEqual(5)
+ expect(result.movers.topDownloaders.length).toBeLessThanOrEqual(5)
+ })
+
+ it("excludes torrents that have no matching torrent checkpoint entry", async () => {
+ const tracker = makeTracker(1, { qbtTag: "aither" })
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000")
+ const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+ const torrent = makeTorrent(
+ "hash-no-cp",
+ "No Checkpoint Movie",
+ "aither",
+ 5000000000,
+ 3000000000
+ )
+ const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) }
+ // No checkpoint in torrent CP list
+ seedStore([tracker], [snap1, snap2], [], [], [], [client])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.movers.topUploaders).toHaveLength(0)
+ })
+
+ it("attaches tracker color to matched mover entries", async () => {
+ const tracker = makeTracker(1, { qbtTag: "aither", color: "#00d4ff" })
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000")
+ const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+ const torrent = makeTorrent("hash1", "Colored Movie", "aither", 5000000000, 3000000000)
+ const cp = makeTorrentCheckpoint(1, "hash1", "0", "0")
+ const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) }
+ seedStore([tracker], [snap1, snap2], [], [], [cp], [client])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.movers.topUploaders[0].trackerColor).toBe("#00d4ff")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Fleet aggregation — multiple trackers
+// ---------------------------------------------------------------------------
+
+describe("computeTodayAtAGlance — fleet aggregation with multiple trackers", () => {
+ it("sums upload deltas across all trackers", async () => {
+ const tracker1 = makeTracker(1)
+ const tracker2 = makeTracker(2)
+ const now = Date.now()
+ // tracker1: 3G - 1G = 2G; tracker2: 4G - 2G = 2G; total = 4G
+ const snaps = [
+ makeSnapshot(1, new Date(now - 3600000), "1000000000", "500000000"),
+ makeSnapshot(2, new Date(now - 3600000), "2000000000", "1000000000"),
+ makeSnapshot(1, new Date(now), "3000000000", "600000000"),
+ makeSnapshot(2, new Date(now), "4000000000", "1200000000"),
+ ]
+ seedStore([tracker1, tracker2], snaps)
+
+ const result = await computeTodayAtAGlance()
+ expect(BigInt(result.fleet.uploadDelta)).toBe(4000000000n)
+ })
+
+ it("sums download deltas across all trackers", async () => {
+ const tracker1 = makeTracker(1)
+ const tracker2 = makeTracker(2)
+ const now = Date.now()
+ // tracker1 dl: 700M - 500M = 200M; tracker2 dl: 500M - 200M = 300M; total = 500M
+ const snaps = [
+ makeSnapshot(1, new Date(now - 3600000), "1000000000", "500000000"),
+ makeSnapshot(2, new Date(now - 3600000), "1000000000", "200000000"),
+ makeSnapshot(1, new Date(now), "2000000000", "700000000"),
+ makeSnapshot(2, new Date(now), "2000000000", "500000000"),
+ ]
+ seedStore([tracker1, tracker2], snaps)
+
+ const result = await computeTodayAtAGlance()
+ expect(BigInt(result.fleet.downloadDelta)).toBe(500000000n)
+ })
+
+ it("tracker with no snapshots contributes zero and still appears in trackers array", async () => {
+ const tracker1 = makeTracker(1)
+ const tracker2 = makeTracker(2) // no snapshots
+ const now = Date.now()
+ const snaps = [
+ makeSnapshot(1, new Date(now - 3600000), "1000000000", "500000000"),
+ makeSnapshot(1, new Date(now), "2000000000", "600000000"),
+ ]
+ seedStore([tracker1, tracker2], snaps)
+
+ const result = await computeTodayAtAGlance()
+ expect(BigInt(result.fleet.uploadDelta)).toBe(1000000000n)
+ expect(result.trackers).toHaveLength(2)
+ expect(result.trackers.find((t) => t.id === 2)?.uploadDelta).toBe("0")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Ratio weighted average
+// ---------------------------------------------------------------------------
+
+describe("computeTodayAtAGlance — ratio weighted average", () => {
+ it("weights ratio change by upload volume — more upload means more weight", async () => {
+ const tracker1 = makeTracker(1)
+ const tracker2 = makeTracker(2)
+ const now = Date.now()
+ // tracker1: +2G upload, ratio +0.1; tracker2: +0.5G upload, ratio +1.0
+ // weighted avg = (0.1 * 2G + 1.0 * 0.5G) / (2G + 0.5G)
+ // = (0.2G + 0.5G) / 2.5G = 0.7 / 2.5 = 0.28
+ const snaps = [
+ makeSnapshot(1, new Date(now - 3600000), "2000000000", "2000000000", null, 1.0),
+ makeSnapshot(2, new Date(now - 3600000), "500000000", "500000000", null, 1.0),
+ makeSnapshot(1, new Date(now), "4000000000", "2000000000", null, 1.1),
+ makeSnapshot(2, new Date(now), "1000000000", "500000000", null, 2.0),
+ ]
+ seedStore([tracker1, tracker2], snaps)
+
+ const result = await computeTodayAtAGlance()
+ expect(result.fleet.ratioChange).not.toBeNull()
+ expect(result.fleet.ratioChange as number).toBeCloseTo(0.28, 5)
+ })
+
+ it("returns null ratioChange when all upload deltas are zero (no progress this session)", async () => {
+ const tracker = makeTracker(1)
+ // Both snapshots have same uploaded bytes — delta = 0, weight = 0
+ const snap1 = makeSnapshot(
+ 1,
+ new Date(Date.now() - 3600000),
+ "1000000000",
+ "500000000",
+ null,
+ 1.0
+ )
+ const snap2 = makeSnapshot(1, new Date(), "1000000000", "500000000", null, 1.5)
+ seedStore([tracker], [snap1, snap2])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.fleet.ratioChange).toBeNull()
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Activity counts
+// ---------------------------------------------------------------------------
+
+describe("computeTodayAtAGlance — activity counts", () => {
+ it("counts torrents whose added_on unix timestamp falls on today", async () => {
+ const tracker = makeTracker(1, { qbtTag: "aither" })
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000")
+ const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+
+ const todayNoon = new Date()
+ todayNoon.setHours(12, 0, 0, 0)
+ const torrent = makeTorrent(
+ "h1",
+ "New Movie",
+ "aither",
+ 0,
+ 0,
+ Math.floor(todayNoon.getTime() / 1000),
+ -1
+ )
+ const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) }
+ seedStore([tracker], [snap1, snap2], [], [], [], [client])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.activity.addedToday).toBe(1)
+ })
+
+ it("does not count torrents with added_on of 0 (unix epoch, not today)", async () => {
+ const tracker = makeTracker(1, { qbtTag: "aither" })
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000")
+ const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+ const torrent = makeTorrent("h1", "Old Movie", "aither", 0, 0, 0, -1)
+ const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) }
+ seedStore([tracker], [snap1, snap2], [], [], [], [client])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.activity.addedToday).toBe(0)
+ })
+
+ it("counts torrents whose completion_on unix timestamp falls on today", async () => {
+ const tracker = makeTracker(1, { qbtTag: "aither" })
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000")
+ const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+
+ const todayNoon = new Date()
+ todayNoon.setHours(12, 0, 0, 0)
+ const torrent = makeTorrent(
+ "h1",
+ "Done Movie",
+ "aither",
+ 0,
+ 5000000000,
+ 0,
+ Math.floor(todayNoon.getTime() / 1000)
+ )
+ const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) }
+ seedStore([tracker], [snap1, snap2], [], [], [], [client])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.activity.completedToday).toBe(1)
+ })
+
+ it("ignores completion_on of -1 (torrent is still downloading)", async () => {
+ const tracker = makeTracker(1, { qbtTag: "aither" })
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000")
+ const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+ const torrent = makeTorrent("h1", "Seeding Movie", "aither", 5000000000, 5000000000, 0, -1)
+ const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) }
+ seedStore([tracker], [snap1, snap2], [], [], [], [client])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.activity.completedToday).toBe(0)
+ })
+
+ it("ignores completion_on of 0 (qBittorrent sentinel for 'not completed')", async () => {
+ const tracker = makeTracker(1, { qbtTag: "aither" })
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000")
+ const snap2 = makeSnapshot(1, new Date(), "2000000000", "1000000000")
+ const torrent = makeTorrent("h1", "Seeding Movie", "aither", 5000000000, 5000000000, 0, 0)
+ const client = { id: 1, name: "qBit", cachedTorrents: JSON.stringify([torrent]) }
+ seedStore([tracker], [snap1, snap2], [], [], [], [client])
+
+ const result = await computeTodayAtAGlance()
+ expect(result.activity.completedToday).toBe(0)
+ })
+})
+
+// ---------------------------------------------------------------------------
+// Output shape invariants
+// ---------------------------------------------------------------------------
+
+describe("computeTodayAtAGlance — output shape", () => {
+ it("fleet object has all required keys", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(result.fleet).toHaveProperty("uploadDelta")
+ expect(result.fleet).toHaveProperty("downloadDelta")
+ expect(result.fleet).toHaveProperty("bufferDelta")
+ expect(result.fleet).toHaveProperty("ratioChange")
+ expect(result.fleet).toHaveProperty("seedbonusChange")
+ expect(result.fleet).toHaveProperty("uploadDeltaYesterday")
+ expect(result.fleet).toHaveProperty("downloadDeltaYesterday")
+ expect(result.fleet).toHaveProperty("bufferDeltaYesterday")
+ })
+
+ it("fleet delta values are strings (bigints serialized for JSON safety)", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(typeof result.fleet.uploadDelta).toBe("string")
+ expect(typeof result.fleet.downloadDelta).toBe("string")
+ expect(typeof result.fleet.bufferDelta).toBe("string")
+ })
+
+ it("movers has topUploaders and topDownloaders as arrays", async () => {
+ const result = await computeTodayAtAGlance()
+ expect(Array.isArray(result.movers.topUploaders)).toBe(true)
+ expect(Array.isArray(result.movers.topDownloaders)).toBe(true)
+ })
+
+ it("each tracker entry has id, name, color, uploadDelta, downloadDelta, bufferDelta", async () => {
+ const tracker = makeTracker(1)
+ const snap1 = makeSnapshot(1, new Date(Date.now() - 3600000), "1000000000", "500000000")
+ const snap2 = makeSnapshot(1, new Date(), "2000000000", "600000000")
+ seedStore([tracker], [snap1, snap2])
+
+ const result = await computeTodayAtAGlance()
+ const t = result.trackers[0]
+ expect(t).toHaveProperty("id")
+ expect(t).toHaveProperty("name")
+ expect(t).toHaveProperty("color")
+ expect(t).toHaveProperty("uploadDelta")
+ expect(t).toHaveProperty("downloadDelta")
+ expect(t).toHaveProperty("bufferDelta")
+ })
+})
diff --git a/src/lib/client-scheduler.ts b/src/lib/client-scheduler.ts
index 43074256..31f2ce0d 100644
--- a/src/lib/client-scheduler.ts
+++ b/src/lib/client-scheduler.ts
@@ -10,6 +10,10 @@
//
// Functions: heartbeatClient, heartbeatAllClients, deepPollClient, deepPollAllClients,
// startClientScheduler, stopClientScheduler, ensureClientSchedulerRunning
+//
+// Side effects of deepPollClient:
+// - Writes torrent daily checkpoints (torrentDailyCheckpoints) for "Movers & Shakers".
+// Uses onConflictDoNothing so the first poll of the day wins; subsequent polls skip.
import { eq, isNotNull, lt, sql } from "drizzle-orm"
import cron, { type ScheduledTask } from "node-cron"
@@ -20,9 +24,11 @@ import {
clientSnapshots,
clientUptimeBuckets,
downloadClients,
+ torrentDailyCheckpoints,
trackers,
} from "@/lib/db/schema"
import { sanitizeNetworkError } from "@/lib/error-utils"
+import { localDateStr } from "@/lib/formatters"
import { log } from "@/lib/logger"
import type { QbtTorrent } from "@/lib/qbt"
import {
@@ -38,7 +44,7 @@ import {
} from "@/lib/qbt"
import { clearUptimeAccumulator, flushCompletedBuckets, recordHeartbeat } from "@/lib/uptime"
-/** Columns needed by heartbeatClient — excludes large blobs like cachedTorrents */
+/** Columns needed by heartbeatClient. Excludes large blobs like cachedTorrents */
export const HEARTBEAT_COLUMNS = {
id: downloadClients.id,
enabled: downloadClients.enabled,
@@ -50,7 +56,7 @@ export const HEARTBEAT_COLUMNS = {
encryptedPassword: downloadClients.encryptedPassword,
} as const
-/** Columns needed by deepPollClient — heartbeat fields + poll config + tags */
+/** Columns needed by deepPollClient. Heartbeat fields + poll config + tags */
export const DEEP_POLL_COLUMNS = {
...HEARTBEAT_COLUMNS,
crossSeedTags: downloadClients.crossSeedTags,
@@ -82,7 +88,7 @@ function setDeepPollTask(task: ScheduledTask | null) {
}
// ---------------------------------------------------------------------------
-// Heartbeat — lightweight speed + connection check
+// Heartbeat
// ---------------------------------------------------------------------------
async function heartbeatClient(clientId: number, encryptionKey: Buffer): Promise {
@@ -112,7 +118,7 @@ async function heartbeatClient(clientId: number, encryptionKey: Buffer): Promise
await db
.update(downloadClients)
- .set({ lastPolledAt: new Date(), lastError: null, errorSince: null, updatedAt: new Date() })
+ .set({ lastError: null, errorSince: null, updatedAt: new Date() })
.where(eq(downloadClients.id, clientId))
} catch (error) {
recordHeartbeat(clientId, false)
@@ -142,7 +148,7 @@ async function heartbeatAllClients(encryptionKey: Buffer): Promise {
}
// ---------------------------------------------------------------------------
-// Deep poll — full torrent list + per-tag aggregation
+// Deep poll
// ---------------------------------------------------------------------------
export async function deepPollClient(clientId: number, encryptionKey: Buffer): Promise {
@@ -187,7 +193,7 @@ export async function deepPollClient(clientId: number, encryptionKey: Buffer): P
for (const result of tagSettled) {
if (result.status !== "fulfilled") continue
for (const t of result.value) {
- if (!t.isPrivate || seen.has(t.hash)) continue
+ if (seen.has(t.hash)) continue
seen.add(t.hash)
deduped.push(t)
}
@@ -200,6 +206,33 @@ export async function deepPollClient(clientId: number, encryptionKey: Buffer): P
`[deep-poll] client=${clientId} → ${torrents.length} relevant torrents (${allTags.length} tags)`
)
+ // Write daily torrent checkpoints for "Movers & Shakers" — first-seen-today wins
+ const checkpointDate = localDateStr()
+ const checkpointable = torrents.filter(
+ (t) => t.uploaded != null && t.downloaded != null && t.hash && t.name
+ )
+ if (checkpointable.length > 0) {
+ try {
+ await db
+ .insert(torrentDailyCheckpoints)
+ .values(
+ checkpointable.map((t) => ({
+ clientId,
+ hash: t.hash,
+ name: t.name,
+ checkpointDate,
+ uploadedStart: BigInt(t.uploaded),
+ downloadedStart: BigInt(t.downloaded),
+ }))
+ )
+ .onConflictDoNothing()
+ } catch (err) {
+ log.warn(
+ `Torrent checkpoint insert failed for client ${clientId}: ${err instanceof Error ? err.message : "Unknown"}`
+ )
+ }
+ }
+
const stats = aggregateByTag(torrents, trackerTags, crossSeedTags)
// Cache the filtered torrent list for fallback when client is offline.
@@ -280,7 +313,7 @@ export function startClientScheduler(encryptionKey: Buffer): void {
log.error(error, "Initial deep poll error")
})
- // Heartbeat: every 5 seconds — lightweight speed + connection check
+ // Heartbeat: every 5 seconds
const hbTask = cron.schedule("*/5 * * * * *", async () => {
if (heartbeatInFlight) return
heartbeatInFlight = true
@@ -295,7 +328,7 @@ export function startClientScheduler(encryptionKey: Buffer): void {
})
setHeartbeatTask(hbTask)
- // Deep poll: every 5 minutes — full torrent list + tag aggregation
+ // Deep poll
const dpTask = cron.schedule("*/5 * * * *", async () => {
if (deepPollInFlight) return
deepPollInFlight = true
@@ -334,7 +367,7 @@ export function stopClientScheduler(): void {
}
clearAllSessions()
clearSpeedCache()
- // Best-effort flush of any completed uptime buckets before clearing
+
flushCompletedBuckets().catch(() => {})
clearUptimeAccumulator()
}
diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts
index 58d0f5c5..bfdc2c9a 100644
--- a/src/lib/formatters.ts
+++ b/src/lib/formatters.ts
@@ -1,6 +1,10 @@
// src/lib/formatters.ts
//
-// Functions: formatBytesFromString, bytesToGiB, formatBytesNum, formatRatio, formatAccountAge, formatJoinedDate, hexToRgba, hexToHsl, hslToHex, generatePalette, getComplementaryColor, formatStatValue, computeDelta, formatDuration, formatTimeAgo, splitValueUnit
+// Functions: formatBytesFromString, bytesToGiB, formatBytesNum,
+// formatRatio, formatAccountAge, formatJoinedDate, hexToRgba, hexToHsl,
+// hslToHex, generatePalette, getComplementaryColor, formatStatValue,
+// computeDelta, formatDuration, formatTimeAgo, splitValueUnit, compareBigIntDesc,
+// computePctChange, localDateStr, isUnixTimestampOnDate
import type { Snapshot, TrackerLatestStats } from "@/types/api"
@@ -14,7 +18,9 @@ export function formatBytesFromString(bytesStr: string | null | undefined): stri
const tib = bytes / 1024 ** 4
if (tib >= 1) return `${tib.toFixed(2)} TiB`
const gib = bytes / 1024 ** 3
- return `${gib.toFixed(2)} GiB`
+ if (gib >= 1) return `${gib.toFixed(2)} GiB`
+ const mib = bytes / 1024 ** 2
+ return `${Math.round(mib)} MiB`
}
/**
@@ -291,3 +297,41 @@ export function splitValueUnit(formatted: string): { num: string; unit: string }
if (idx === -1) return { num: formatted, unit: "" }
return { num: formatted.slice(0, idx), unit: formatted.slice(idx + 1) }
}
+
+/** Comparator for sorting bigint values in descending order. */
+export function compareBigIntDesc(a: bigint, b: bigint): number {
+ if (b > a) return 1
+ if (b < a) return -1
+ return 0
+}
+
+/** Compute percentage change between two bigint-encoded decimal strings. */
+export function computePctChange(today: string, yesterday: string | null): number | null {
+ if (yesterday === null) return null
+ try {
+ const y = Number(BigInt(yesterday))
+ if (y === 0) return null
+ const t = Number(BigInt(today))
+ return ((t - y) / y) * 100
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Returns a YYYY-MM-DD date string in the server's local timezone (respects TZ env).
+ * Use this instead of `.toISOString().slice(0, 10)` which always returns UTC.
+ */
+export function localDateStr(date?: Date | number): string {
+ const d = date instanceof Date ? date : date !== undefined ? new Date(date) : new Date()
+ return d.toLocaleDateString("en-CA")
+}
+
+/**
+ * Returns true if a unix timestamp (seconds) falls on the given date string (YYYY-MM-DD)
+ * in the server's local timezone. Returns false for timestamps <= 0.
+ */
+export function isUnixTimestampOnDate(unixSeconds: number, dateStr: string): boolean {
+ if (unixSeconds <= 0) return false
+ return localDateStr(new Date(unixSeconds * 1000)) === dateStr
+}
diff --git a/src/lib/nuke.ts b/src/lib/nuke.ts
index e55b1bc5..c4ed8866 100644
--- a/src/lib/nuke.ts
+++ b/src/lib/nuke.ts
@@ -1,6 +1,4 @@
// src/lib/nuke.ts
-//
-// Functions: scrubAndDeleteAll
import { randomBytes } from "node:crypto"
import { db } from "@/lib/db"
@@ -14,6 +12,8 @@ import {
notificationTargets,
tagGroupMembers,
tagGroups,
+ torrentDailyCheckpoints,
+ trackerDailyCheckpoints,
trackerRoles,
trackerSnapshots,
trackers,
@@ -73,6 +73,8 @@ export async function scrubAndDeleteAll(): Promise {
await tx.delete(dismissedAlerts)
await tx.delete(clientUptimeBuckets)
+ await tx.delete(torrentDailyCheckpoints)
+ await tx.delete(trackerDailyCheckpoints)
await tx.delete(clientSnapshots)
await tx.delete(trackerSnapshots)
await tx.delete(trackerRoles)
diff --git a/src/lib/qbt/aggregator.ts b/src/lib/qbt/aggregator.ts
index 4f47738d..9033c32a 100644
--- a/src/lib/qbt/aggregator.ts
+++ b/src/lib/qbt/aggregator.ts
@@ -1,6 +1,4 @@
// src/lib/qbt/aggregator.ts
-//
-// Functions: aggregateByTag
import { LEECHING_STATES, parseTorrentTags, SEEDING_STATES } from "@/lib/fleet"
import type { ClientStats, QbtTorrent, TagStats } from "./types"
@@ -12,11 +10,10 @@ export function aggregateByTag(
): ClientStats {
const knownTags = [...trackerTags, ...crossSeedTags]
- // Build a map of tag → accumulator
const tagMap = new Map()
for (const tag of knownTags) {
- tagMap.set(tag, {
- tag,
+ tagMap.set(tag.toLowerCase(), {
+ tag: tag.toLowerCase(),
seedingCount: 0,
leechingCount: 0,
uploadSpeed: 0,
diff --git a/src/lib/qbt/client.ts b/src/lib/qbt/client.ts
index 02f2c3c0..aa3df638 100644
--- a/src/lib/qbt/client.ts
+++ b/src/lib/qbt/client.ts
@@ -1,15 +1,16 @@
// src/lib/qbt/client.ts
//
// Available functions:
-// buildBaseUrl - Construct base URL from host/port/ssl
-// login - Authenticate with qBittorrent Web API, returns SID cookie
-// getSession - Return cached SID or perform a fresh login
-// invalidateSession - Remove a cached SID (i.e after 403)
-// clearAllSessions - Remove all cached SIDs (called on logout)
-// withSessionRetry - Run an operation with automatic session retry on expiry
-// qbtFetch - Shared fetch + error handler for authenticated qBT requests
-// getTorrents - Fetch torrent info from qBittorrent (optionally filtered by tag)
-// getTransferInfo - Fetch global transfer stats from qBittorrent
+// buildBaseUrl - Construct base URL from host/port/ssl
+// login - Authenticate with qBittorrent Web API, returns SID cookie
+// getSession - Return cached SID or perform a fresh login
+// invalidateSession - Remove a cached SID (i.e after 403)
+// clearAllSessions - Remove all cached SIDs (called on logout)
+// withSessionRetry - Run an operation with automatic session retry on expiry
+// qbtFetch - Shared fetch + error handler for authenticated qBT requests
+// parseCachedTorrents - Safely parse JSONB cachedTorrents column (string or object)
+// getTorrents - Fetch torrent info from qBittorrent (optionally filtered by tag)
+// getTransferInfo - Fetch global transfer stats from qBittorrent
import type { QbtTorrent, QbtTransferInfo } from "./types"
@@ -18,14 +19,13 @@ import type { QbtTorrent, QbtTransferInfo } from "./types"
* TypeError("fetch failed") { cause: Error("ECONNREFUSED ...") }
*/
function describeFetchError(err: unknown): string {
- // Unwrap Node fetch's cause chain for the real error
const cause =
err !== null && typeof err === "object" && "cause" in (err as object)
? (err as { cause: unknown }).cause
: undefined
if (cause instanceof Error) {
const code = "code" in cause ? (cause as NodeJS.ErrnoException).code : undefined
- if (code) return code // i.e ECONNREFUSED, ENOTFOUND, ETIMEDOUT
+ if (code) return code
if (cause.message) return cause.message
}
@@ -45,8 +45,7 @@ export function buildBaseUrl(host: string, port: number, ssl: boolean): string {
}
// ---------------------------------------------------------------------------
-// SID session cache — avoids re-authenticating on every poll cycle.
-// SIDs are cached per baseUrl and reused until a 403 invalidates them.
+// SID session cache to avoid re-authenticating on every poll cycle.
// ---------------------------------------------------------------------------
const gSid = globalThis as typeof globalThis & {
@@ -195,6 +194,23 @@ async function qbtFetch(
return response
}
+/**
+ * Parses the cachedTorrents JSONB column
+ */
+export function parseCachedTorrents(raw: unknown): QbtTorrent[] {
+ if (!raw) return []
+ if (typeof raw === "string") {
+ try {
+ const parsed = JSON.parse(raw)
+ return Array.isArray(parsed) ? (parsed as QbtTorrent[]) : []
+ } catch {
+ return []
+ }
+ }
+ if (Array.isArray(raw)) return raw as QbtTorrent[]
+ return []
+}
+
export async function getTorrents(
baseUrl: string,
sid: string,
diff --git a/src/lib/qbt/index.ts b/src/lib/qbt/index.ts
index b61018c0..5efcf6f8 100644
--- a/src/lib/qbt/index.ts
+++ b/src/lib/qbt/index.ts
@@ -9,6 +9,7 @@ export {
getTransferInfo,
invalidateSession,
login,
+ parseCachedTorrents,
withSessionRetry,
} from "./client"
export type { SpeedSnapshot } from "./speed-cache"
diff --git a/src/lib/qbt/qbt.test.ts b/src/lib/qbt/qbt.test.ts
index fa353ec6..53850c82 100644
--- a/src/lib/qbt/qbt.test.ts
+++ b/src/lib/qbt/qbt.test.ts
@@ -334,6 +334,10 @@ describe("getTransferInfo", () => {
// ---------------------------------------------------------------------------
describe("aggregateByTag", () => {
+ // Regression: prevents isPrivate camelCase/snake_case mismatch from masking real API shape.
+ // The real qBT API returns `is_private` (snake_case), not `isPrivate`. Tests that set
+ // `isPrivate: true` would hide bugs that relied on that field being defined. This factory
+ // deliberately omits it to match the real API response shape.
function makeTorrent(overrides: Partial): QbtTorrent {
return {
hash: "deadbeef",
@@ -363,7 +367,8 @@ describe("aggregateByTag", () => {
progress: 1,
content_path: "/downloads/Test Torrent",
save_path: "/downloads",
- isPrivate: true,
+ // isPrivate intentionally omitted — real qBT API returns `is_private` (snake_case).
+ // Only override explicitly in tests that specifically need to test isPrivate handling.
...overrides,
}
}
@@ -517,3 +522,162 @@ describe("parseCrossSeedTags", () => {
expect(parseCrossSeedTags(null)).toEqual([])
})
})
+
+// ---------------------------------------------------------------------------
+// Mock factory realism
+// ---------------------------------------------------------------------------
+
+describe("makeTorrent factory shape", () => {
+ // Regression: prevents isPrivate camelCase mismatch from masking deep-poll dedup bug.
+ // The factory previously included `isPrivate: true`, which caused any code that
+ // checked `t.isPrivate` to behave differently from the real API (which returns
+ // `is_private` in snake_case). Tests built on the old factory would never catch
+ // bugs that depended on `t.isPrivate` being undefined.
+ it("produces API-realistic shape without isPrivate by default", () => {
+ // Re-declare the factory inline to confirm the standalone shape — this is the
+ // canonical check that should fail immediately if someone adds isPrivate back.
+ function makeTorrentForShapeCheck(overrides: Partial = {}): QbtTorrent {
+ return {
+ hash: "deadbeef",
+ name: "Test Torrent",
+ state: "uploading",
+ tags: "",
+ category: "",
+ upspeed: 0,
+ dlspeed: 0,
+ uploaded: 0,
+ downloaded: 0,
+ ratio: 0,
+ size: 0,
+ num_seeds: 0,
+ num_leechs: 0,
+ num_complete: 0,
+ num_incomplete: 0,
+ tracker: "",
+ added_on: 0,
+ completion_on: -1,
+ last_activity: 0,
+ seeding_time: 0,
+ time_active: 0,
+ seen_complete: 0,
+ availability: -1,
+ amount_left: 0,
+ progress: 1,
+ content_path: "/downloads/Test Torrent",
+ save_path: "/downloads",
+ ...overrides,
+ }
+ }
+
+ const torrent = makeTorrentForShapeCheck()
+
+ // The real qBT API does NOT return `isPrivate` (camelCase). Ensure the factory
+ // does not include it so tests built on this shape match real API responses.
+ expect(Object.hasOwn(torrent, "isPrivate")).toBe(false)
+
+ // Core fields that the API does return must be present
+ expect(torrent).toHaveProperty("hash")
+ expect(torrent).toHaveProperty("state")
+ expect(torrent).toHaveProperty("tags")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// aggregateByTag — case-insensitive tag matching
+// ---------------------------------------------------------------------------
+
+describe("aggregateByTag case-insensitive tag matching", () => {
+ // Local helper that does NOT include isPrivate, matching real API shape
+ function makeRealTorrent(overrides: Partial): QbtTorrent {
+ return {
+ hash: "deadbeef",
+ name: "Test Torrent",
+ state: "uploading",
+ tags: "",
+ category: "",
+ upspeed: 0,
+ dlspeed: 0,
+ uploaded: 0,
+ downloaded: 0,
+ ratio: 0,
+ size: 0,
+ num_seeds: 0,
+ num_leechs: 0,
+ num_complete: 0,
+ num_incomplete: 0,
+ tracker: "",
+ added_on: 0,
+ completion_on: -1,
+ last_activity: 0,
+ seeding_time: 0,
+ time_active: 0,
+ seen_complete: 0,
+ availability: -1,
+ amount_left: 0,
+ progress: 1,
+ content_path: "/downloads/Test Torrent",
+ save_path: "/downloads",
+ ...overrides,
+ }
+ }
+
+ // Regression: prevents tag case mismatch between DB-stored tags and parseTorrentTags output.
+ // aggregateByTag builds its internal map with lowercase keys. parseTorrentTags lowercases
+ // torrent tags by default. If the map were built with original-case keys (e.g. "Blutopia"),
+ // a torrent tagged "blutopia" (lowercased by parseTorrentTags) would never match and would
+ // fall into the untagged bucket instead.
+ it("matches torrent tags case-insensitively when DB tag has title case", () => {
+ // Torrent tags as parseTorrentTags returns them: lowercase
+ const torrents = [makeRealTorrent({ state: "uploading", tags: "blutopia", upspeed: 512 })]
+ // DB stores the tag with title case
+ const result = aggregateByTag(torrents, ["Blutopia"], [])
+
+ const blutopiaStats = result.tagStats.find((t) => t.tag === "blutopia")
+ expect(blutopiaStats).toBeDefined()
+ expect(blutopiaStats?.seedingCount).toBe(1)
+ expect(blutopiaStats?.uploadSpeed).toBe(512)
+
+ // Must NOT fall into the untagged bucket
+ const untagged = result.tagStats.find((t) => t.tag === "untagged")
+ expect(untagged).toBeUndefined()
+ })
+
+ // Regression: verifies the fix handles all common tracker tag casing patterns from DB.
+ it("handles mixed case tags from DB — ALL_CAPS, lowercase, TitleCase", () => {
+ const torrents = [
+ makeRealTorrent({ state: "uploading", tags: "red", upspeed: 100 }),
+ makeRealTorrent({ state: "uploading", tags: "ops", upspeed: 200 }),
+ makeRealTorrent({ state: "uploading", tags: "nebulance", upspeed: 300 }),
+ ]
+ // DB may store these as "RED", "ops", "Nebulance"
+ const result = aggregateByTag(torrents, ["RED", "ops", "Nebulance"], [])
+
+ const redStats = result.tagStats.find((t) => t.tag === "red")
+ const opsStats = result.tagStats.find((t) => t.tag === "ops")
+ const nebStats = result.tagStats.find((t) => t.tag === "nebulance")
+
+ expect(redStats?.seedingCount).toBe(1)
+ expect(opsStats?.seedingCount).toBe(1)
+ expect(nebStats?.seedingCount).toBe(1)
+
+ // No torrent should end up untagged
+ const untagged = result.tagStats.find((t) => t.tag === "untagged")
+ expect(untagged).toBeUndefined()
+
+ expect(result.totalSeedingCount).toBe(3)
+ expect(result.uploadSpeedBytes).toBe(600)
+ })
+
+ it("cross-seed tags from DB are also lowercased for matching", () => {
+ const torrents = [makeRealTorrent({ state: "uploading", tags: "cross-seed", upspeed: 50 })]
+ // DB cross-seed tag stored with mixed case
+ const result = aggregateByTag(torrents, [], ["Cross-Seed"])
+
+ const csStats = result.tagStats.find((t) => t.tag === "cross-seed")
+ expect(csStats).toBeDefined()
+ expect(csStats?.seedingCount).toBe(1)
+
+ const untagged = result.tagStats.find((t) => t.tag === "untagged")
+ expect(untagged).toBeUndefined()
+ })
+})
diff --git a/src/lib/scheduler.ts b/src/lib/scheduler.ts
index d1684b54..bb302f35 100644
--- a/src/lib/scheduler.ts
+++ b/src/lib/scheduler.ts
@@ -1,6 +1,6 @@
// src/lib/scheduler.ts
//
-// Functions: pollTracker, pollAllTrackers, pruneOldSnapshots, startScheduler, stopScheduler, ensureSchedulerRunning, POLL_FAILURE_THRESHOLD
+// Functions: pollTracker, pollAllTrackers, pruneOldSnapshots, pruneOldCheckpoints, startScheduler, stopScheduler, ensureSchedulerRunning, fetchTrackerStats, POLL_FAILURE_THRESHOLD
import type { Agent as HttpAgent } from "node:http"
import { and, desc, eq, isNotNull, lt, notInArray, sql } from "drizzle-orm"
@@ -17,8 +17,16 @@ import {
} from "@/lib/client-scheduler"
import { decrypt } from "@/lib/crypto"
import { db } from "@/lib/db"
-import { appSettings, notificationTargets, trackerSnapshots, trackers } from "@/lib/db/schema"
+import {
+ appSettings,
+ notificationTargets,
+ torrentDailyCheckpoints,
+ trackerDailyCheckpoints,
+ trackerSnapshots,
+ trackers,
+} from "@/lib/db/schema"
import { sanitizeNetworkError } from "@/lib/error-utils"
+import { localDateStr } from "@/lib/formatters"
import { log } from "@/lib/logger"
import { dispatchNotifications } from "@/lib/notifications/dispatch"
import { maskUsername } from "@/lib/privacy"
@@ -27,7 +35,7 @@ import { getPauseState } from "@/lib/tracker-status"
// Store on globalThis to survive HMR in development.
// Without this, each hot-reload orphans the old cron job (it keeps firing)
-// while creating a new one — causing duplicate polls that hammer tracker APIs.
+// while creating a new one which causes duplicate polls that hammer tracker APIs.
const g = globalThis as typeof globalThis & {
__schedulerTask?: ScheduledTask | null
__schedulerKey?: Buffer | null
@@ -73,13 +81,14 @@ export async function fetchTrackerStats(
let apiToken: string
try {
apiToken = decrypt(tracker.encryptedApiToken, encryptionKey)
- } catch {
- throw new Error(`API key is missing or invalid for tracker "${tracker.name}"`)
+ } catch (err) {
+ const cause = err instanceof Error ? err.message : String(err)
+ throw new Error(`API key is missing or invalid for tracker "${tracker.name}": ${cause}`)
}
const adapter = getAdapter(tracker.platformType)
if (tracker.useProxy && !proxyAgent) {
- throw new Error("Proxy required but not available — refusing to leak IP via direct connection")
+ throw new Error("Proxy required but not available, refusing to leak IP via direct connection")
}
const fetchOptions = buildFetchOptions(tracker.baseUrl, {
@@ -131,13 +140,14 @@ export async function pollTracker(
let apiToken: string
try {
apiToken = decrypt(tracker.encryptedApiToken, encryptionKey)
- } catch (_err) {
- throw new Error(`API key is missing or invalid for tracker "${tracker.name}"`)
+ } catch (err) {
+ const cause = err instanceof Error ? err.message : String(err)
+ throw new Error(`API key is missing or invalid for tracker "${tracker.name}": ${cause}`)
}
const adapter = getAdapter(tracker.platformType)
if (tracker.useProxy && !proxyAgent) {
throw new Error(
- "Proxy required but not available — refusing to leak IP via direct connection"
+ "Proxy required but not available, refusing to leak IP via direct connection"
)
}
const fetchOptions = buildFetchOptions(tracker.baseUrl, {
@@ -173,7 +183,7 @@ export async function pollTracker(
await db.update(trackers).set(metaUpdates).where(eq(trackers.id, tracker.id))
}
- // Fetch previous snapshot BEFORE inserting the new one — used for change detection in notifications
+ // Fetch previous snapshot before inserting the new one (used for change detection in notifications)
const [previousSnapshot] = await db
.select({
ratio: trackerSnapshots.ratio,
@@ -207,6 +217,38 @@ export async function pollTracker(
group: privacyMode ? maskUsername(stats.group) : stats.group,
})
+ try {
+ // Upsert daily checkpoint for "Today At A Glance" comparisons
+ const checkpointDate = localDateStr(timestamp)
+ await db
+ .insert(trackerDailyCheckpoints)
+ .values({
+ trackerId: tracker.id,
+ checkpointDate,
+ uploadedBytesEnd: stats.uploadedBytes !== null ? BigInt(stats.uploadedBytes) : 0n,
+ downloadedBytesEnd: stats.downloadedBytes !== null ? BigInt(stats.downloadedBytes) : 0n,
+ bufferBytesEnd: stats.bufferBytes !== null ? BigInt(stats.bufferBytes) : null,
+ ratioEnd: stats.ratio,
+ seedbonusEnd: stats.seedbonus,
+ snapshotCount: 1,
+ })
+ .onConflictDoUpdate({
+ target: [trackerDailyCheckpoints.trackerId, trackerDailyCheckpoints.checkpointDate],
+ set: {
+ uploadedBytesEnd: stats.uploadedBytes !== null ? BigInt(stats.uploadedBytes) : 0n,
+ downloadedBytesEnd: stats.downloadedBytes !== null ? BigInt(stats.downloadedBytes) : 0n,
+ bufferBytesEnd: stats.bufferBytes !== null ? BigInt(stats.bufferBytes) : null,
+ ratioEnd: stats.ratio,
+ seedbonusEnd: stats.seedbonus,
+ snapshotCount: sql`${trackerDailyCheckpoints.snapshotCount} + 1`,
+ },
+ })
+ } catch (checkpointErr) {
+ log.warn(
+ `Daily checkpoint upsert failed for tracker ${tracker.id}: ${checkpointErr instanceof Error ? checkpointErr.message : "Unknown"}`
+ )
+ }
+
try {
await dispatchNotifications(
{
@@ -308,7 +350,7 @@ export async function pollTracker(
enabledTargets
)
} catch {
- // security-audit-ignore: notification dispatch is non-critical — errors logged inside dispatchNotifications
+ // security-audit-ignore: notification dispatch is non-critical, errors logged inside dispatchNotifications
}
}
}
@@ -330,8 +372,26 @@ export async function pruneOldSnapshots(retentionDays: number): Promise
return deleted.length
}
+export async function pruneOldCheckpoints(retentionDays: number): Promise {
+ const cutoffDate = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)
+ .toISOString()
+ .slice(0, 10)
+
+ const deletedTracker = await db
+ .delete(trackerDailyCheckpoints)
+ .where(lt(trackerDailyCheckpoints.checkpointDate, cutoffDate))
+ .returning({ id: trackerDailyCheckpoints.id })
+
+ const deletedTorrent = await db
+ .delete(torrentDailyCheckpoints)
+ .where(lt(torrentDailyCheckpoints.checkpointDate, cutoffDate))
+ .returning({ id: torrentDailyCheckpoints.id })
+
+ return deletedTracker.length + deletedTorrent.length
+}
+
export async function pollAllTrackers(encryptionKey: Buffer): Promise {
- // Query settings first — global interval is needed for overdue filtering
+ // Query settings first; global interval is needed for overdue filtering
const [settings] = await db
.select({
storeUsernames: appSettings.storeUsernames,
@@ -370,7 +430,7 @@ export async function pollAllTrackers(encryptionKey: Buffer): Promise {
// Build proxy agent once for all trackers that need it
const proxyAgent = settings ? buildProxyAgentFromSettings(settings, encryptionKey) : undefined
- // Fetch notification targets once for the entire poll cycle — avoids N identical queries
+ // Fetch notification targets once for the entire poll cycle (avoids N identical queries)
let enabledNotificationTargets: (typeof notificationTargets.$inferSelect)[] = []
try {
enabledNotificationTargets = await db
@@ -384,10 +444,10 @@ export async function pollAllTrackers(encryptionKey: Buffer): Promise {
}
// Capture a single timestamp for the whole batch so all snapshots in one
- // cycle share the same polledAt value — simplifies time-series queries
+ // cycle share the same polledAt value, simplifying time-series queries
const batchTimestamp = new Date()
- // Poll all overdue trackers in parallel — one slow tracker won't block the rest
+ // Poll all overdue trackers in parallel so one slow tracker won't block the rest
await Promise.allSettled(
overdue.map((tracker) =>
pollTracker(
@@ -411,6 +471,17 @@ export async function pollAllTrackers(encryptionKey: Buffer): Promise {
} catch (error) {
log.error(error, "Snapshot pruning failed")
}
+
+ try {
+ const prunedCheckpoints = await pruneOldCheckpoints(settings.snapshotRetentionDays)
+ if (prunedCheckpoints > 0) {
+ log.info(
+ `Pruned ${prunedCheckpoints} checkpoint rows older than ${settings.snapshotRetentionDays} days`
+ )
+ }
+ } catch (error) {
+ log.error(error, "Checkpoint pruning failed")
+ }
}
// Prune expired dismissed alerts (stale-data and zero-seeding types expire after 24h)
@@ -426,7 +497,7 @@ export function startScheduler(encryptionKey: Buffer): void {
setSchedulerKey(encryptionKey)
- // Poll immediately on start — don't wait for first 5-minute tick
+ // Poll immediately on start, don't wait for first 5-minute tick
pollAllTrackers(encryptionKey).catch((error) => {
log.error(error, "Initial poll error")
})
@@ -468,7 +539,7 @@ export function stopScheduler(): void {
stopBackupScheduler()
}
-/** Exposed for testing — returns the raw schedulerKey buffer reference. */
+/** Exposed for testing. Returns the raw schedulerKey buffer reference. */
export function _getSchedulerKeyForTest(): Buffer | null {
return getSchedulerKey()
}
diff --git a/src/lib/server-data.ts b/src/lib/server-data.ts
index f3da1f28..049dce53 100644
--- a/src/lib/server-data.ts
+++ b/src/lib/server-data.ts
@@ -44,7 +44,7 @@ import type { Snapshot, TagGroup, TagGroupChartType } from "@/types/api"
*
* Note: `hasProxyPassword` and `hasBackupPassword` select the encrypted column
* references so the serializer can coerce them to booleans. The raw ciphertext
- * is never returned to clients — serializeSettingsResponse() converts these to
+ * is never returned to clients and serializeSettingsResponse() converts these to
* `!!value` before the data leaves the server.
*/
export const settingsColumns = {
@@ -116,7 +116,7 @@ export function serializeSettingsResponse(row: SettingsRow) {
}
/**
- * Convenience wrapper: fetches settings and serializes for the client.
+ * fetches settings and serializes for the client.
* Returns null if no settings row exists (app not yet configured).
*/
export async function getSettingsForClient() {
@@ -175,40 +175,16 @@ export async function getTrackerListForDashboard(): Promise {
db.select({ storeUsernames: appSettings.storeUsernames }).from(appSettings).limit(1),
])
- // Enforce masking at response time -- even if DB has plaintext from before
- // privacy was enabled, the API never leaks it when privacy mode is on.
+ // Enforce masking at response time
// Fallback true = "store usernames" = no masking. Matches createPrivacyMask()
// behavior when no settings row exists. Do NOT change to false.
const mask = createPrivacyMaskSync(privacySettings?.storeUsernames ?? true)
- // Batch-fetch the latest snapshot per tracker using DISTINCT ON.
- // PG18's enable_distinct_reordering planner flag optimises exactly this pattern.
- // Drizzle has no native DISTINCT ON support, so we use db.execute with a raw sql tag. I know, I know.
- // security-audit-ignore: static SQL string with zero user input -- no injection risk
- const latestSnapshots = (await db.execute(sql`
- SELECT DISTINCT ON (tracker_id)
- id,
- tracker_id AS "trackerId",
- polled_at AS "polledAt",
- uploaded_bytes AS "uploadedBytes",
- downloaded_bytes AS "downloadedBytes",
- ratio,
- buffer_bytes AS "bufferBytes",
- seeding_count AS "seedingCount",
- leeching_count AS "leechingCount",
- seedbonus,
- hit_and_runs AS "hitAndRuns",
- required_ratio AS "requiredRatio",
- warned,
- freeleech_tokens AS "freeleechTokens",
- share_score AS "shareScore",
- username,
- group_name AS "group"
- FROM tracker_snapshots
- ORDER BY tracker_id, polled_at DESC
- `)) as unknown as (typeof trackerSnapshots.$inferSelect)[]
-
- // Build a lookup map for O(1) access
+ const latestSnapshots = await db
+ .selectDistinctOn([trackerSnapshots.trackerId])
+ .from(trackerSnapshots)
+ .orderBy(trackerSnapshots.trackerId, desc(trackerSnapshots.polledAt))
+
const snapshotByTracker = new Map(latestSnapshots.map((s) => [s.trackerId, s]))
// SECURITY: Never include encryptedApiToken in response
@@ -341,7 +317,7 @@ export async function getTagGroupsWithMembers(): Promise {
/**
* Fetches only id, name, and color for trackers that have useProxy enabled.
- * Used by the settings page — avoids the heavy dashboard query for this narrow need.
+ * Used by the settings page
*/
export async function getProxyTrackers(): Promise<{ id: number; name: string; color: string }[]> {
const rows = await db
diff --git a/src/lib/today.ts b/src/lib/today.ts
new file mode 100644
index 00000000..284dde01
--- /dev/null
+++ b/src/lib/today.ts
@@ -0,0 +1,447 @@
+// src/lib/today.ts
+//
+// Functions: computeTodayAtAGlance, backfillTrackerCheckpoints
+
+import "server-only"
+
+import { eq, gte, inArray, sql } from "drizzle-orm"
+import { db } from "@/lib/db"
+import {
+ downloadClients,
+ torrentDailyCheckpoints,
+ trackerDailyCheckpoints,
+ trackerSnapshots,
+ trackers,
+} from "@/lib/db/schema"
+import { parseTorrentTags } from "@/lib/fleet"
+import { compareBigIntDesc, isUnixTimestampOnDate, localDateStr } from "@/lib/formatters"
+import { log } from "@/lib/logger"
+import { parseCachedTorrents } from "@/lib/qbt/client"
+import type { TodayAtAGlance } from "@/types/api"
+
+interface TrackerDelta {
+ id: number
+ name: string
+ color: string | null
+ uploadDelta: bigint
+ downloadDelta: bigint
+ bufferDelta: bigint
+ ratioChange: number
+ seedbonusChange: number
+}
+
+function zeroDelta(tracker: { id: number; name: string; color: string | null }): TrackerDelta {
+ return {
+ id: tracker.id,
+ name: tracker.name,
+ color: tracker.color ?? null,
+ uploadDelta: 0n,
+ downloadDelta: 0n,
+ bufferDelta: 0n,
+ ratioChange: 0,
+ seedbonusChange: 0,
+ }
+}
+
+interface TorrentMover {
+ hash: string
+ name: string
+ qbtTag: string | null
+ trackerColor: string | null
+ clientName: string | null
+ uploadedToday: bigint
+ downloadedToday: bigint
+}
+
+/**
+ * Computes the entire TodayAtAGlance payload in a single call.
+ * Fetches all required data in parallel where possible, then aggregates
+ * fleet-level deltas, yesterday comparisons, torrent movers, and activity counts.
+ */
+export async function computeTodayAtAGlance(): Promise {
+ // ── Date boundaries (local timezone) ──────────────────────────────────────
+ const now = Date.now()
+ const todayStr = localDateStr()
+ // Start of today in local timezone for snapshot filtering
+ const todayStart = new Date(`${todayStr}T00:00:00`)
+ const yesterdayStr = localDateStr(now - 86400000)
+ const dayBeforeStr = localDateStr(now - 172800000)
+
+ // ── Parallel data fetches ─────────────────────────────────────────────────
+ const [allTrackers, todaySnapshots, checkpointRows, torrentCps, clients] = await Promise.all([
+ db
+ .select({
+ id: trackers.id,
+ name: trackers.name,
+ color: trackers.color,
+ qbtTag: trackers.qbtTag,
+ })
+ .from(trackers)
+ .where(eq(trackers.isActive, true)),
+
+ db
+ .select({
+ trackerId: trackerSnapshots.trackerId,
+ polledAt: trackerSnapshots.polledAt,
+ uploadedBytes: trackerSnapshots.uploadedBytes,
+ downloadedBytes: trackerSnapshots.downloadedBytes,
+ bufferBytes: trackerSnapshots.bufferBytes,
+ ratio: trackerSnapshots.ratio,
+ seedbonus: trackerSnapshots.seedbonus,
+ })
+ .from(trackerSnapshots)
+ .where(gte(trackerSnapshots.polledAt, todayStart))
+ .orderBy(trackerSnapshots.polledAt),
+
+ db
+ .select({
+ trackerId: trackerDailyCheckpoints.trackerId,
+ checkpointDate: trackerDailyCheckpoints.checkpointDate,
+ uploadedBytesEnd: trackerDailyCheckpoints.uploadedBytesEnd,
+ downloadedBytesEnd: trackerDailyCheckpoints.downloadedBytesEnd,
+ bufferBytesEnd: trackerDailyCheckpoints.bufferBytesEnd,
+ })
+ .from(trackerDailyCheckpoints)
+ .where(inArray(trackerDailyCheckpoints.checkpointDate, [yesterdayStr, dayBeforeStr])),
+
+ db
+ .select({
+ clientId: torrentDailyCheckpoints.clientId,
+ hash: torrentDailyCheckpoints.hash,
+ uploadedStart: torrentDailyCheckpoints.uploadedStart,
+ downloadedStart: torrentDailyCheckpoints.downloadedStart,
+ })
+ .from(torrentDailyCheckpoints)
+ .where(eq(torrentDailyCheckpoints.checkpointDate, todayStr)),
+
+ db
+ .select({
+ id: downloadClients.id,
+ name: downloadClients.name,
+ cachedTorrents: downloadClients.cachedTorrents,
+ cachedTorrentsAt: downloadClients.cachedTorrentsAt,
+ })
+ .from(downloadClients)
+ .where(eq(downloadClients.enabled, true)),
+ ])
+
+ const yesterdayCheckpoints = checkpointRows.filter((r) => r.checkpointDate === yesterdayStr)
+ const dayBeforeCheckpoints = checkpointRows.filter((r) => r.checkpointDate === dayBeforeStr)
+
+ // ── Per-tracker deltas from today's snapshots ─────────────────────────────
+
+ // Group snapshots by trackerId
+ const snapshotsByTracker = new Map()
+ for (const snap of todaySnapshots) {
+ const bucket = snapshotsByTracker.get(snap.trackerId) ?? []
+ bucket.push(snap)
+ snapshotsByTracker.set(snap.trackerId, bucket)
+ }
+
+ const trackerDeltas: TrackerDelta[] = []
+
+ for (const tracker of allTrackers) {
+ const snaps = snapshotsByTracker.get(tracker.id)
+
+ if (!snaps || snaps.length < 2) {
+ trackerDeltas.push(zeroDelta(tracker))
+ continue
+ }
+
+ const earliest = snaps[0]
+ const latest = snaps[snaps.length - 1]
+
+ try {
+ const uploadDelta = BigInt(latest.uploadedBytes) - BigInt(earliest.uploadedBytes)
+ const downloadDelta = BigInt(latest.downloadedBytes) - BigInt(earliest.downloadedBytes)
+
+ const latestBuffer = latest.bufferBytes ?? 0n
+ const earliestBuffer = earliest.bufferBytes ?? 0n
+ const bufferDelta = BigInt(latestBuffer) - BigInt(earliestBuffer)
+
+ const ratioChange = (latest.ratio ?? 0) - (earliest.ratio ?? 0)
+ const seedbonusChange = (latest.seedbonus ?? 0) - (earliest.seedbonus ?? 0)
+
+ trackerDeltas.push({
+ id: tracker.id,
+ name: tracker.name,
+ color: tracker.color ?? null,
+ uploadDelta,
+ downloadDelta,
+ bufferDelta,
+ ratioChange,
+ seedbonusChange,
+ })
+ } catch {
+ log.debug("BigInt conversion failed for tracker %d", tracker.id)
+ trackerDeltas.push(zeroDelta(tracker))
+ }
+ }
+
+ // ── Fleet aggregation ──────────────────────────────────────────────────────
+
+ let fleetUploadDelta = 0n
+ let fleetDownloadDelta = 0n
+ let fleetBufferDelta = 0n
+ let fleetSeedbonusChange = 0
+
+ // Weighted average of ratio change: sum(ratioChange * uploadDelta) / sum(uploadDelta)
+ let weightedRatioSum = 0
+ let totalUploadWeight = 0n
+
+ for (const delta of trackerDeltas) {
+ fleetUploadDelta += delta.uploadDelta
+ fleetDownloadDelta += delta.downloadDelta
+ fleetBufferDelta += delta.bufferDelta
+ fleetSeedbonusChange += delta.seedbonusChange
+
+ if (delta.uploadDelta > 0n) {
+ // Convert upload delta to a Number for the weight
+ const weight = Number(delta.uploadDelta)
+ weightedRatioSum += delta.ratioChange * weight
+ totalUploadWeight += delta.uploadDelta
+ }
+ }
+
+ const fleetRatioChange =
+ totalUploadWeight > 0n ? weightedRatioSum / Number(totalUploadWeight) : null
+
+ // ── Yesterday comparison from checkpoint tables ────────────────────────────
+
+ const yesterdayByTracker = new Map(yesterdayCheckpoints.map((cp) => [cp.trackerId, cp]))
+ const dayBeforeByTracker = new Map(dayBeforeCheckpoints.map((cp) => [cp.trackerId, cp]))
+
+ let yesterdayFleetUpload: bigint | null = null
+ let yesterdayFleetDownload: bigint | null = null
+ let yesterdayFleetBuffer: bigint | null = null
+
+ for (const tracker of allTrackers) {
+ const yesterday = yesterdayByTracker.get(tracker.id)
+ const dayBefore = dayBeforeByTracker.get(tracker.id)
+
+ if (!yesterday || !dayBefore) continue
+
+ try {
+ const trackerUploadYesterday =
+ BigInt(yesterday.uploadedBytesEnd) - BigInt(dayBefore.uploadedBytesEnd)
+ const trackerDownloadYesterday =
+ BigInt(yesterday.downloadedBytesEnd) - BigInt(dayBefore.downloadedBytesEnd)
+
+ const yesterdayBuffer = yesterday.bufferBytesEnd ?? 0n
+ const dayBeforeBuffer = dayBefore.bufferBytesEnd ?? 0n
+ const trackerBufferYesterday = BigInt(yesterdayBuffer) - BigInt(dayBeforeBuffer)
+
+ yesterdayFleetUpload = (yesterdayFleetUpload ?? 0n) + trackerUploadYesterday
+ yesterdayFleetDownload = (yesterdayFleetDownload ?? 0n) + trackerDownloadYesterday
+ yesterdayFleetBuffer = (yesterdayFleetBuffer ?? 0n) + trackerBufferYesterday
+ } catch {
+ log.debug("BigInt conversion failed for yesterday comparison, tracker %d", tracker.id)
+ }
+ }
+
+ // ── Torrent movers ─────────────────────────────────────────────────────────
+
+ // Build checkpoint lookup
+ const cpByKey = new Map(torrentCps.map((cp) => [`${cp.clientId}:${cp.hash}`, cp]))
+
+ // Build tracker tag → color lookup for matching torrent tags
+ const trackerTagToColor = new Map()
+ for (const tracker of allTrackers) {
+ if (tracker.qbtTag) {
+ trackerTagToColor.set(tracker.qbtTag, tracker.color ?? null)
+ }
+ }
+
+ const movers: TorrentMover[] = []
+ let addedToday = 0
+ let completedToday = 0
+
+ for (const client of clients) {
+ const torrents = parseCachedTorrents(client.cachedTorrents)
+
+ for (const torrent of torrents) {
+ // Activity counts. added_on and completion_on are unix timestamps (seconds)
+ if (isUnixTimestampOnDate(torrent.added_on, todayStr)) {
+ addedToday++
+ }
+ if (torrent.completion_on !== -1 && isUnixTimestampOnDate(torrent.completion_on, todayStr)) {
+ completedToday++
+ }
+
+ // Compare current uploaded/downloaded to today's checkpoint
+ const key = `${client.id}:${torrent.hash}`
+ const checkpoint = cpByKey.get(key)
+ if (!checkpoint) continue
+
+ let uploadedToday: bigint
+ let downloadedToday: bigint
+ try {
+ uploadedToday = BigInt(torrent.uploaded) - BigInt(checkpoint.uploadedStart)
+ downloadedToday = BigInt(torrent.downloaded) - BigInt(checkpoint.downloadedStart)
+ } catch {
+ log.debug("BigInt conversion failed for torrent %s", torrent.hash)
+ continue
+ }
+
+ // Match first qbtTag found in the torrent's comma-separated tags field
+ let matchedTag: string | null = null
+ let matchedColor: string | null = null
+ if (torrent.tags) {
+ const torrentTags = parseTorrentTags(torrent.tags, false)
+ for (const tag of torrentTags) {
+ if (trackerTagToColor.has(tag)) {
+ matchedTag = tag
+ matchedColor = trackerTagToColor.get(tag) ?? null
+ break
+ }
+ }
+ }
+
+ // Only include torrents that match a tracked tracker
+ if (!matchedTag) continue
+
+ movers.push({
+ hash: torrent.hash,
+ name: torrent.name,
+ qbtTag: matchedTag,
+ trackerColor: matchedColor,
+ clientName: client.name,
+ uploadedToday,
+ downloadedToday,
+ })
+ }
+ }
+
+ // Sort and take top 5 uploaders and downloaders
+ const topUploaders = movers
+ .filter((m) => m.uploadedToday > 0n)
+ .sort((a, b) => compareBigIntDesc(a.uploadedToday, b.uploadedToday))
+ .slice(0, 5)
+
+ const topDownloaders = movers
+ .filter((m) => m.downloadedToday > 0n)
+ .sort((a, b) => compareBigIntDesc(a.downloadedToday, b.downloadedToday))
+ .slice(0, 5)
+
+ // ── Assemble and return ───────────────────────────────────────────────────
+
+ const latestClientPoll = clients.reduce((latest, c) => {
+ if (c.cachedTorrentsAt && (!latest || c.cachedTorrentsAt > latest)) return c.cachedTorrentsAt
+ return latest
+ }, null)
+
+ return {
+ fleet: {
+ uploadDelta: fleetUploadDelta.toString(),
+ downloadDelta: fleetDownloadDelta.toString(),
+ bufferDelta: fleetBufferDelta.toString(),
+ ratioChange: fleetRatioChange,
+ seedbonusChange: fleetSeedbonusChange,
+ uploadDeltaYesterday: yesterdayFleetUpload !== null ? yesterdayFleetUpload.toString() : null,
+ downloadDeltaYesterday:
+ yesterdayFleetDownload !== null ? yesterdayFleetDownload.toString() : null,
+ bufferDeltaYesterday: yesterdayFleetBuffer !== null ? yesterdayFleetBuffer.toString() : null,
+ },
+ trackers: trackerDeltas.map((d) => ({
+ id: d.id,
+ name: d.name,
+ color: d.color,
+ uploadDelta: d.uploadDelta.toString(),
+ downloadDelta: d.downloadDelta.toString(),
+ bufferDelta: d.bufferDelta.toString(),
+ })),
+ activity: {
+ addedToday,
+ completedToday,
+ },
+ movers: {
+ topUploaders: topUploaders.map((t) => ({
+ hash: t.hash,
+ name: t.name,
+ qbtTag: t.qbtTag,
+ trackerColor: t.trackerColor,
+ clientName: t.clientName,
+ uploadedToday: t.uploadedToday.toString(),
+ })),
+ topDownloaders: topDownloaders.map((t) => ({
+ hash: t.hash,
+ name: t.name,
+ qbtTag: t.qbtTag,
+ trackerColor: t.trackerColor,
+ clientName: t.clientName,
+ downloadedToday: t.downloadedToday.toString(),
+ })),
+ },
+ trackerLastUpdated:
+ todaySnapshots.length > 0
+ ? todaySnapshots[todaySnapshots.length - 1].polledAt.toISOString()
+ : null,
+ clientLastUpdated: latestClientPoll?.toISOString() ?? null,
+ }
+}
+
+// ── Backfill ───────────────────────────────────────────────────
+
+/**
+ * One-time backfill: populates trackerDailyCheckpoints from existing trackerSnapshots.
+ * Should be called once when the checkpoint table is empty but snapshot data exists.
+ */
+export async function backfillTrackerCheckpoints(): Promise {
+ // Check if backfill is needed
+ const [existing] = await db
+ .select({ id: trackerDailyCheckpoints.id })
+ .from(trackerDailyCheckpoints)
+ .limit(1)
+
+ if (existing) return 0 // Already has data, skip
+
+ // Check if there are snapshots to backfill from
+ const [hasSnapshots] = await db
+ .select({ id: trackerSnapshots.id })
+ .from(trackerSnapshots)
+ .limit(1)
+
+ if (!hasSnapshots) return 0 // No snapshots, nothing to backfill
+
+ const polledDate = sql`DATE(${trackerSnapshots.polledAt})`
+
+ const rows = await db
+ .selectDistinctOn([trackerSnapshots.trackerId, polledDate], {
+ trackerId: trackerSnapshots.trackerId,
+ checkpointDate: polledDate.as("checkpoint_date"),
+ uploadedBytesEnd: trackerSnapshots.uploadedBytes,
+ downloadedBytesEnd: trackerSnapshots.downloadedBytes,
+ bufferBytesEnd: trackerSnapshots.bufferBytes,
+ ratioEnd: trackerSnapshots.ratio,
+ seedbonusEnd: trackerSnapshots.seedbonus,
+ })
+ .from(trackerSnapshots)
+ .orderBy(trackerSnapshots.trackerId, polledDate, sql`${trackerSnapshots.polledAt} DESC`)
+
+ if (rows.length === 0) return 0
+
+ const CHUNK_SIZE = 500
+ let inserted = 0
+
+ for (let i = 0; i < rows.length; i += CHUNK_SIZE) {
+ const chunk = rows.slice(i, i + CHUNK_SIZE)
+ await db
+ .insert(trackerDailyCheckpoints)
+ .values(
+ chunk.map((row) => ({
+ trackerId: row.trackerId,
+ checkpointDate: row.checkpointDate,
+ uploadedBytesEnd: row.uploadedBytesEnd,
+ downloadedBytesEnd: row.downloadedBytesEnd,
+ bufferBytesEnd: row.bufferBytesEnd,
+ ratioEnd: row.ratioEnd != null ? Number(row.ratioEnd) : null,
+ seedbonusEnd: row.seedbonusEnd != null ? Number(row.seedbonusEnd) : null,
+ snapshotCount: 1,
+ }))
+ )
+ .onConflictDoNothing()
+ inserted += chunk.length
+ }
+
+ return inserted
+}
From 84c522750f517c95653b73e1298aa72bd204e27f Mon Sep 17 00:00:00 2001
From: Jordy
Date: Thu, 26 Mar 2026 09:43:21 -0500
Subject: [PATCH 21/59] feat(dashboard): add Today At A Glance UI
---
src/app/(auth)/DashboardClient.tsx | 18 +-
.../dashboard/DashboardSettingsSheet.tsx | 6 +
src/components/dashboard/FleetActivity.tsx | 31 +++
src/components/dashboard/FleetHeadline.tsx | 107 ++++++++++
src/components/dashboard/MoversAndShakers.tsx | 96 +++++++++
src/components/dashboard/TodayAtAGlance.tsx | 87 ++++++++
.../dashboard/TodayAtAGlanceSkeleton.tsx | 43 ++++
.../dashboard/TrackerBreakdownBars.tsx | 89 ++++++++
.../dashboard/TrackerBreakdownTicker.tsx | 39 ++++
src/components/ui/ProgressBar.tsx | 77 +++++++
.../ui/__tests__/ProgressBar.test.tsx | 196 ++++++++++++++++++
src/hooks/useDashboardData.ts | 20 +-
12 files changed, 804 insertions(+), 5 deletions(-)
create mode 100644 src/components/dashboard/FleetActivity.tsx
create mode 100644 src/components/dashboard/FleetHeadline.tsx
create mode 100644 src/components/dashboard/MoversAndShakers.tsx
create mode 100644 src/components/dashboard/TodayAtAGlance.tsx
create mode 100644 src/components/dashboard/TodayAtAGlanceSkeleton.tsx
create mode 100644 src/components/dashboard/TrackerBreakdownBars.tsx
create mode 100644 src/components/dashboard/TrackerBreakdownTicker.tsx
create mode 100644 src/components/ui/ProgressBar.tsx
create mode 100644 src/components/ui/__tests__/ProgressBar.test.tsx
diff --git a/src/app/(auth)/DashboardClient.tsx b/src/app/(auth)/DashboardClient.tsx
index a3e70c16..06dafdd6 100644
--- a/src/app/(auth)/DashboardClient.tsx
+++ b/src/app/(auth)/DashboardClient.tsx
@@ -15,6 +15,8 @@ import { EcosystemStatsSection } from "@/components/dashboard/EcosystemStatsSect
import { FleetDashboard } from "@/components/dashboard/FleetDashboard"
import { LoginTimers } from "@/components/dashboard/LoginTimers"
import { PollAllButton } from "@/components/dashboard/PollAllButton"
+import { TodayAtAGlance } from "@/components/dashboard/TodayAtAGlance"
+import { TodayAtAGlanceSkeleton } from "@/components/dashboard/TodayAtAGlanceSkeleton"
import { TrackerLeaderboard } from "@/components/dashboard/TrackerLeaderboard"
import { TrackerOverviewGrid } from "@/components/dashboard/TrackerOverviewGrid"
import { useDashboardSettings } from "@/components/dashboard/useDashboardSettings"
@@ -96,7 +98,19 @@ export function DashboardClient({ initialTrackers }: DashboardClientProps) {
- {/* Section 1: Tracker Overview */}
+ {/* Today At A Glance */}
+ {dashSettings.settings.showTodayAtAGlance && (
+
+
Today At A Glance
+ {data.todayData ? (
+
+ ) : data.todayLoading ? (
+
+ ) : null}
+
+ )}
+
+ {/* Tracker Overview */}
Trackers
- {/* Section 2: Alerts */}
+ {/* Alerts */}
{data.alerts.length > 0 && (
Trackers
+ dashSettings.update("showTodayAtAGlance", checked)}
+ />
No fleet activity today
+ }
+
+ return (
+
+
+ {addedToday}
+ {" added today"}
+
+ ·
+
+ {completedToday}
+ {" completed today"}
+
+
+ )
+}
diff --git a/src/components/dashboard/FleetHeadline.tsx b/src/components/dashboard/FleetHeadline.tsx
new file mode 100644
index 00000000..3152741a
--- /dev/null
+++ b/src/components/dashboard/FleetHeadline.tsx
@@ -0,0 +1,107 @@
+// src/components/dashboard/FleetHeadline.tsx
+
+"use client"
+
+import { CHART_THEME } from "@/components/charts/lib/theme"
+import {
+ DownloadArrowIcon,
+ RatioIcon,
+ ShieldIcon,
+ StarIcon,
+ UploadArrowIcon,
+} from "@/components/ui/Icons"
+import { StatCard } from "@/components/ui/StatCard"
+import { computePctChange, formatBytesFromString, splitValueUnit } from "@/lib/formatters"
+import type { TodayAtAGlance } from "@/types/api"
+
+interface FleetHeadlineProps {
+ fleet: TodayAtAGlance["fleet"]
+}
+
+function pctLabel(pct: number | null): string | undefined {
+ if (pct === null) return undefined
+ const rounded = Math.round(pct)
+ return rounded >= 0 ? `+${rounded}% vs yesterday` : `${rounded}% vs yesterday`
+}
+
+export function FleetHeadline({ fleet }: FleetHeadlineProps) {
+ const uploadParts = splitValueUnit(formatBytesFromString(fleet.uploadDelta))
+ const downloadParts = splitValueUnit(formatBytesFromString(fleet.downloadDelta))
+ const bufferParts = splitValueUnit(formatBytesFromString(fleet.bufferDelta))
+
+ const ratioDisplay =
+ fleet.ratioChange !== null
+ ? fleet.ratioChange >= 0
+ ? `+${fleet.ratioChange.toFixed(4)}`
+ : fleet.ratioChange.toFixed(4)
+ : "+0.0000"
+
+ const seedbonusDisplay = (() => {
+ if (fleet.seedbonusChange === null) return "+0"
+ const floored = Math.floor(Math.abs(fleet.seedbonusChange))
+ const sign = fleet.seedbonusChange >= 0 ? "+" : "-"
+ return sign + floored.toLocaleString()
+ })()
+
+ const uploadPct = computePctChange(fleet.uploadDelta, fleet.uploadDeltaYesterday)
+ const downloadPct = computePctChange(fleet.downloadDelta, fleet.downloadDeltaYesterday)
+ const bufferPct = computePctChange(fleet.bufferDelta, fleet.bufferDeltaYesterday)
+
+ const ratioTrend =
+ fleet.ratioChange === null || fleet.ratioChange === 0
+ ? undefined
+ : fleet.ratioChange > 0
+ ? ("up" as const)
+ : ("down" as const)
+
+ return (
+
+ }
+ />
+
+ }
+ />
+
+ }
+ />
+
+ }
+ />
+
+ }
+ />
+
+ )
+}
diff --git a/src/components/dashboard/MoversAndShakers.tsx b/src/components/dashboard/MoversAndShakers.tsx
new file mode 100644
index 00000000..bba1a7f4
--- /dev/null
+++ b/src/components/dashboard/MoversAndShakers.tsx
@@ -0,0 +1,96 @@
+// src/components/dashboard/MoversAndShakers.tsx
+
+"use client"
+
+import type { ReactNode } from "react"
+import { MarqueeText } from "@/components/ui/MarqueeText"
+import { Tooltip } from "@/components/ui/Tooltip"
+import { formatBytesFromString } from "@/lib/formatters"
+import type { TodayAtAGlance } from "@/types/api"
+
+interface MoversAndShakersProps {
+ movers: TodayAtAGlance["movers"]
+}
+
+interface TorrentRankListEntry {
+ hash: string
+ name: string
+ qbtTag: string | null
+ trackerColor: string | null
+ clientName: string | null
+ bytes: string
+}
+
+function buildTooltip(entry: TorrentRankListEntry): ReactNode {
+ const lines: string[] = []
+ if (entry.qbtTag) lines.push(`Tracker: ${entry.qbtTag}`)
+ if (entry.clientName) lines.push(`Client: ${entry.clientName}`)
+ if (entry.qbtTag?.toLowerCase().includes("cross-seed")) lines.push("Cross-seeded")
+ if (lines.length === 0) return "Unknown tracker"
+ return (
+
+ {lines.map((line) => (
+ {line}
+ ))}
+
+ )
+}
+
+function TorrentRankList({ label, entries }: { label: string; entries: TorrentRankListEntry[] }) {
+ return (
+
+
+ {label}
+
+ {entries.length === 0 ? (
+
No activity
+ ) : (
+
+ {entries.map((entry, index) => (
+
+ -
+
+
+ {entry.name}
+
+
+ {formatBytesFromString(entry.bytes)}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+export function MoversAndShakers({ movers }: MoversAndShakersProps) {
+ const { topUploaders, topDownloaders } = movers
+
+ if (topUploaders.length === 0 && topDownloaders.length === 0) {
+ return (
+
+ Connect a download client to see torrent activity
+
+ )
+ }
+
+ return (
+
+ ({ ...e, bytes: e.uploadedToday }))}
+ />
+ ({ ...e, bytes: e.downloadedToday }))}
+ />
+
+ )
+}
diff --git a/src/components/dashboard/TodayAtAGlance.tsx b/src/components/dashboard/TodayAtAGlance.tsx
new file mode 100644
index 00000000..92e83069
--- /dev/null
+++ b/src/components/dashboard/TodayAtAGlance.tsx
@@ -0,0 +1,87 @@
+// src/components/dashboard/TodayAtAGlance.tsx
+
+"use client"
+
+import { FleetActivity } from "@/components/dashboard/FleetActivity"
+import { FleetHeadline } from "@/components/dashboard/FleetHeadline"
+import { MoversAndShakers } from "@/components/dashboard/MoversAndShakers"
+import { TrackerBreakdownBars } from "@/components/dashboard/TrackerBreakdownBars"
+import { TrackerBreakdownTicker } from "@/components/dashboard/TrackerBreakdownTicker"
+import { Card } from "@/components/ui/Card"
+import { formatTimeAgo } from "@/lib/formatters"
+import type { TodayAtAGlance as TodayAtAGlanceData } from "@/types/api"
+
+interface TodayAtAGlanceProps {
+ data: TodayAtAGlanceData
+ variant?: "bars" | "ticker"
+}
+
+function UpdatedAt({ iso }: { iso: string | null }) {
+ if (!iso) return null
+ const ago = formatTimeAgo(new Date(iso))
+ return (
+
+ Updated {ago}
+
+ )
+}
+
+export function TodayAtAGlance({ data, variant = "bars" }: TodayAtAGlanceProps) {
+ const hasActivity =
+ data.activity.addedToday > 0 ||
+ data.activity.completedToday > 0 ||
+ data.movers.topUploaders.length > 0 ||
+ data.movers.topDownloaders.length > 0
+
+ return (
+
+
+
+
+
+
+ {/* Tracker breakdowns — upload and download side by side on lg, stacked on mobile */}
+
+
+ By Tracker
+
+
+
+
+
+
+ Upload
+
+ {variant === "bars" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ Download
+
+ {variant === "bars" ? (
+
+ ) : (
+
+ )}
+
+
+
+ {hasActivity && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+ )
+}
diff --git a/src/components/dashboard/TodayAtAGlanceSkeleton.tsx b/src/components/dashboard/TodayAtAGlanceSkeleton.tsx
new file mode 100644
index 00000000..dd1ebf3f
--- /dev/null
+++ b/src/components/dashboard/TodayAtAGlanceSkeleton.tsx
@@ -0,0 +1,43 @@
+// src/components/dashboard/TodayAtAGlanceSkeleton.tsx
+
+"use client"
+
+import { Card } from "@/components/ui/Card"
+
+function Shimmer({ className }: { className?: string }) {
+ return
+}
+
+const STAT_KEYS = ["upload", "download", "buffer", "ratio", "bonus"] as const
+const PANEL_KEYS = ["upload-panel", "download-panel"] as const
+
+export function TodayAtAGlanceSkeleton() {
+ return (
+
+
+ {/* Fleet headline — 5 stat card placeholders */}
+
+ {STAT_KEYS.map((key) => (
+
+
+
+
+ ))}
+
+
+
+
+ {/* Tracker breakdowns — two panels */}
+
+ {PANEL_KEYS.map((key) => (
+
+
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/dashboard/TrackerBreakdownBars.tsx b/src/components/dashboard/TrackerBreakdownBars.tsx
new file mode 100644
index 00000000..db662e44
--- /dev/null
+++ b/src/components/dashboard/TrackerBreakdownBars.tsx
@@ -0,0 +1,89 @@
+// src/components/dashboard/TrackerBreakdownBars.tsx
+
+"use client"
+
+import { ProgressBar } from "@/components/ui/ProgressBar"
+import { compareBigIntDesc, formatBytesFromString } from "@/lib/formatters"
+import type { TodayAtAGlance } from "@/types/api"
+
+interface TrackerBreakdownProps {
+ trackers: TodayAtAGlance["trackers"]
+ metric?: "upload" | "download"
+}
+
+const TOP_N = 5
+
+export function TrackerBreakdownBars({ trackers, metric = "upload" }: TrackerBreakdownProps) {
+ if (trackers.length === 0) return null
+
+ const getDelta = (t: TodayAtAGlance["trackers"][number]) =>
+ metric === "upload" ? t.uploadDelta : t.downloadDelta
+
+ const sorted = [...trackers].sort((a, b) =>
+ compareBigIntDesc(BigInt(getDelta(a)), BigInt(getDelta(b)))
+ )
+
+ const top = sorted.slice(0, TOP_N)
+ const rest = sorted.slice(TOP_N)
+
+ const othersDelta = rest.reduce((acc, t) => acc + BigInt(getDelta(t)), 0n)
+ const totalDelta = sorted.reduce((acc, t) => acc + BigInt(getDelta(t)), 0n)
+
+ const allZero = totalDelta === 0n
+
+ function getPercentage(delta: bigint): number {
+ if (totalDelta === 0n) return 0
+ return Number((delta * 100n) / totalDelta)
+ }
+
+ if (allZero) {
+ return (
+
+ No {metric} activity yet today
+
+ )
+ }
+
+ const activeTop = top.filter((t) => BigInt(getDelta(t)) > 0n)
+
+ return (
+
+ {activeTop.map((tracker) => {
+ const delta = BigInt(getDelta(tracker))
+ const pct = getPercentage(delta)
+ const color = tracker.color ?? undefined
+
+ return (
+
+
+ {tracker.name}
+
+
+
+
+
+ {formatBytesFromString(getDelta(tracker))}
+
+
+ )
+ })}
+
+ {othersDelta > 0n && (
+
+
Others
+
+
+
+
+ {formatBytesFromString(othersDelta.toString())}
+
+
+ )}
+
+ )
+}
diff --git a/src/components/dashboard/TrackerBreakdownTicker.tsx b/src/components/dashboard/TrackerBreakdownTicker.tsx
new file mode 100644
index 00000000..94afe3e2
--- /dev/null
+++ b/src/components/dashboard/TrackerBreakdownTicker.tsx
@@ -0,0 +1,39 @@
+// src/components/dashboard/TrackerBreakdownTicker.tsx
+
+"use client"
+
+import { compareBigIntDesc, formatBytesFromString } from "@/lib/formatters"
+import type { TodayAtAGlance } from "@/types/api"
+
+interface TrackerBreakdownProps {
+ trackers: TodayAtAGlance["trackers"]
+}
+
+export function TrackerBreakdownTicker({ trackers }: TrackerBreakdownProps) {
+ const active = [...trackers]
+ .filter((t) => BigInt(t.uploadDelta) > 0n)
+ .sort((a, b) => compareBigIntDesc(BigInt(a.uploadDelta), BigInt(b.uploadDelta)))
+
+ if (active.length === 0) return null
+
+ return (
+
+ {active.map((tracker) => {
+ const dotColor = tracker.color ?? "var(--color-accent)"
+
+ return (
+
+
+ {tracker.name}
+
+ {formatBytesFromString(tracker.uploadDelta)}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/src/components/ui/ProgressBar.tsx b/src/components/ui/ProgressBar.tsx
new file mode 100644
index 00000000..be690396
--- /dev/null
+++ b/src/components/ui/ProgressBar.tsx
@@ -0,0 +1,77 @@
+// src/components/ui/ProgressBar.tsx
+//
+// Functions: ProgressBar
+//
+// Neumorphic progress bar matching the Toggle track/knob depth pattern.
+// Track: nm-inset-sm (recessed channel). Fill: nm-raised-sm (floating bar).
+
+"use client"
+
+import clsx from "clsx"
+
+type ProgressBarSize = "sm" | "md" | "lg"
+
+interface ProgressBarProps {
+ /** Percentage filled (0–100). Clamped internally. */
+ percent: number
+ /** Fill color — accepts any CSS color value or CSS variable. Defaults to accent. */
+ color?: string
+ /** Track height. sm=4px, md=8px (default), lg=12px */
+ size?: ProgressBarSize
+ /** Show the percentage as text inside the bar (only visible on lg size) */
+ showLabel?: boolean
+ /** Animate width transitions */
+ animated?: boolean
+ /** Additional classes on the outer track */
+ className?: string
+}
+
+const sizeMap: Record = {
+ sm: { track: "p-[1px]", fill: "h-1.5" },
+ md: { track: "p-[3px]", fill: "h-3" },
+ lg: { track: "p-[2px]", fill: "h-3" },
+}
+
+function ProgressBar({
+ percent,
+ color,
+ size = "md",
+ showLabel = false,
+ animated = true,
+ className,
+}: ProgressBarProps) {
+ const clamped = Math.max(0, Math.min(100, percent))
+ const { track, fill } = sizeMap[size]
+ const resolvedColor = color ?? "var(--color-accent)"
+
+ return (
+
+
0 ? `0 0 12px ${resolvedColor}60` : undefined,
+ }}
+ role="progressbar"
+ aria-valuenow={clamped}
+ aria-valuemin={0}
+ aria-valuemax={100}
+ >
+ {showLabel && size === "lg" && clamped > 10 && (
+
+ {Math.round(clamped)}%
+
+ )}
+
+
+ )
+}
+
+export type { ProgressBarProps, ProgressBarSize }
+export { ProgressBar }
diff --git a/src/components/ui/__tests__/ProgressBar.test.tsx b/src/components/ui/__tests__/ProgressBar.test.tsx
new file mode 100644
index 00000000..395415af
--- /dev/null
+++ b/src/components/ui/__tests__/ProgressBar.test.tsx
@@ -0,0 +1,196 @@
+// src/components/ui/__tests__/ProgressBar.test.tsx
+
+import { render } from "@testing-library/react"
+import { describe, expect, it } from "vitest"
+import { ProgressBar } from "@/components/ui/ProgressBar"
+
+// ---------------------------------------------------------------------------
+// aria attributes
+// ---------------------------------------------------------------------------
+
+describe("ProgressBar aria attributes", () => {
+ it("renders an element with role='progressbar'", () => {
+ const { getByRole } = render()
+ expect(getByRole("progressbar")).toBeDefined()
+ })
+
+ it("sets aria-valuenow to the clamped percent value", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ expect(bar.getAttribute("aria-valuenow")).toBe("75")
+ })
+
+ it("sets aria-valuemin to 0", () => {
+ const { getByRole } = render()
+ expect(getByRole("progressbar").getAttribute("aria-valuemin")).toBe("0")
+ })
+
+ it("sets aria-valuemax to 100", () => {
+ const { getByRole } = render()
+ expect(getByRole("progressbar").getAttribute("aria-valuemax")).toBe("100")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// width computation
+// ---------------------------------------------------------------------------
+
+describe("ProgressBar fill width", () => {
+ it("at 50% the fill has width 50%", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ expect(bar.style.width).toBe("50%")
+ })
+
+ it("at 100% the fill has width 100%", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ expect(bar.style.width).toBe("100%")
+ })
+
+ it("at 0% the fill has width 0% and aria-valuenow is 0", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ expect(bar.style.width).toBe("0%")
+ expect(bar.getAttribute("aria-valuenow")).toBe("0")
+ })
+
+ it("at 0% the fill element has the 'invisible' class applied", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ expect(bar.className).toContain("invisible")
+ })
+
+ it("at 50% the fill element does NOT have the 'invisible' class", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ expect(bar.className).not.toContain("invisible")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// clamping
+// ---------------------------------------------------------------------------
+
+describe("ProgressBar clamping", () => {
+ it("negative values are clamped to 0", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ expect(bar.getAttribute("aria-valuenow")).toBe("0")
+ expect(bar.style.width).toBe("0%")
+ })
+
+ it("values over 100 are clamped to 100", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ expect(bar.getAttribute("aria-valuenow")).toBe("100")
+ expect(bar.style.width).toBe("100%")
+ })
+
+ it("aria-valuenow reflects the clamped value, not the raw input", () => {
+ const { getByRole } = render()
+ expect(getByRole("progressbar").getAttribute("aria-valuenow")).toBe("0")
+ })
+
+ it("exactly 0 is not clamped further", () => {
+ const { getByRole } = render()
+ expect(getByRole("progressbar").getAttribute("aria-valuenow")).toBe("0")
+ })
+
+ it("exactly 100 is not clamped further", () => {
+ const { getByRole } = render()
+ expect(getByRole("progressbar").getAttribute("aria-valuenow")).toBe("100")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// color prop
+// ---------------------------------------------------------------------------
+
+describe("ProgressBar color prop", () => {
+ it("applies custom color to the fill's backgroundColor", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ expect(bar.style.backgroundColor).toBe("rgb(255, 0, 0)")
+ })
+
+ it("uses var(--color-accent) when no color prop is provided", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ // jsdom renders CSS variable references as-is in the style attribute
+ expect(bar.style.backgroundColor).toBe("var(--color-accent)")
+ })
+
+ it("applies a CSS variable as color", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ expect(bar.style.backgroundColor).toBe("var(--color-warn)")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// glow / boxShadow
+// ---------------------------------------------------------------------------
+
+describe("ProgressBar box shadow", () => {
+ it("applies glow boxShadow when percent > 0", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ expect(bar.style.boxShadow).toContain("12px")
+ })
+
+ it("does not apply boxShadow when percent is 0 (fill invisible)", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ expect(bar.style.boxShadow).toBe("")
+ })
+})
+
+// ---------------------------------------------------------------------------
+// showLabel
+// ---------------------------------------------------------------------------
+
+describe("ProgressBar showLabel prop", () => {
+ it("does not render label by default", () => {
+ const { queryByText } = render()
+ expect(queryByText("50%")).toBeNull()
+ })
+
+ it("does not render label on lg size when percent <= 10", () => {
+ const { queryByText } = render()
+ expect(queryByText("10%")).toBeNull()
+ })
+
+ it("renders label text on lg size when percent > 10 and showLabel is true", () => {
+ const { getByText } = render()
+ expect(getByText("50%")).toBeDefined()
+ })
+
+ it("does not render label on md size even when showLabel is true", () => {
+ const { queryByText } = render()
+ expect(queryByText("50%")).toBeNull()
+ })
+
+ it("rounds the percent label to the nearest integer", () => {
+ const { getByText } = render()
+ expect(getByText("51%")).toBeDefined()
+ })
+})
+
+// ---------------------------------------------------------------------------
+// animated prop
+// ---------------------------------------------------------------------------
+
+describe("ProgressBar animated prop", () => {
+ it("includes transition class by default", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ expect(bar.className).toContain("transition-all")
+ })
+
+ it("omits transition class when animated=false", () => {
+ const { getByRole } = render()
+ const bar = getByRole("progressbar")
+ expect(bar.className).not.toContain("transition-all")
+ })
+})
diff --git a/src/hooks/useDashboardData.ts b/src/hooks/useDashboardData.ts
index 724b5c26..9c3bc4d0 100644
--- a/src/hooks/useDashboardData.ts
+++ b/src/hooks/useDashboardData.ts
@@ -1,6 +1,4 @@
// src/hooks/useDashboardData.ts
-//
-// Functions: useDashboardData
"use client"
@@ -16,7 +14,7 @@ import {
fetchDismissedKeys,
postDismissAlert as persistDismiss,
} from "@/lib/dashboard"
-import type { Snapshot, TrackerSummary } from "@/types/api"
+import type { Snapshot, TodayAtAGlance, TrackerSummary } from "@/types/api"
interface DashboardData {
trackers: TrackerSummary[]
@@ -25,6 +23,8 @@ interface DashboardData {
alerts: DashboardAlert[]
dayRange: DayRange
setDayRange: (range: DayRange) => void
+ todayData: TodayAtAGlance | null
+ todayLoading: boolean
dismissAlert: (key: string, type: string) => void
dismissAllAlerts: () => void
refresh: () => Promise
@@ -88,6 +88,17 @@ function useDashboardData(options?: UseDashboardDataOptions): DashboardData {
})),
})
+ const todayQuery = useQuery({
+ queryKey: ["dashboard-today"],
+ queryFn: async ({ signal }): Promise => {
+ const res = await fetch("/api/dashboard/today", { signal })
+ if (!res.ok) throw new Error("Failed to fetch today data")
+ return res.json()
+ },
+ staleTime: 60_000,
+ refetchInterval: 60_000,
+ })
+
// Secondary queries for alert computation (less frequent)
const clientsQuery = useQuery({
queryKey: ["clients-for-alerts"],
@@ -169,6 +180,8 @@ function useDashboardData(options?: UseDashboardDataOptions): DashboardData {
return {
trackers,
snapshotMap,
+ todayData: todayQuery.data ?? null,
+ todayLoading: todayQuery.isLoading,
loading: trackersQuery.isLoading,
alerts: visibleAlerts,
dayRange,
@@ -178,6 +191,7 @@ function useDashboardData(options?: UseDashboardDataOptions): DashboardData {
refresh: async () => {
await trackersQuery.refetch()
await queryClient.invalidateQueries({ queryKey: ["snapshots"] })
+ queryClient.invalidateQueries({ queryKey: ["dashboard-today"] })
},
}
}
From 2d0b22aa51b1badeb8916ace9505fdf32f526dc9 Mon Sep 17 00:00:00 2001
From: Jordy
Date: Thu, 26 Mar 2026 09:46:54 -0500
Subject: [PATCH 22/59] fix(ui): prevent StatCard DOM prop leak
---
src/components/ui/StatCard.tsx | 41 ++++++++++++++++++++++++++--------
1 file changed, 32 insertions(+), 9 deletions(-)
diff --git a/src/components/ui/StatCard.tsx b/src/components/ui/StatCard.tsx
index e1d7cae8..99ded844 100644
--- a/src/components/ui/StatCard.tsx
+++ b/src/components/ui/StatCard.tsx
@@ -1,8 +1,6 @@
// src/components/ui/StatCard.tsx
-//
-// Functions: StatCard
-//
-// Unified stat card with three variants:
+
+// three variants:
// "basic" — single hero value (default)
// "stacked" — multiple label/value rows with optional total
// "ring" — countdown ring (login deadline)
@@ -14,7 +12,7 @@ import { Tooltip } from "@/components/ui/Tooltip"
import { hexToRgba } from "@/lib/formatters"
// ---------------------------------------------------------------------------
-// Shared types
+// types
// ---------------------------------------------------------------------------
type TrendDirection = "up" | "down" | "flat"
@@ -228,7 +226,7 @@ function BasicContent({
}
// ---------------------------------------------------------------------------
-// Stacked variant — multiple label/value rows
+// Stacked variant
// ---------------------------------------------------------------------------
function StackedContent({
@@ -298,7 +296,7 @@ function StackedContent({
}
// ---------------------------------------------------------------------------
-// Ring variant — countdown progress
+// Ring variant
// ---------------------------------------------------------------------------
function getDeadlineColor(progress: number, accent: string): string {
@@ -404,7 +402,26 @@ function RingContent({
// ---------------------------------------------------------------------------
function StatCard(props: StatCardProps) {
- const { accentColor, icon, alert, alertReason, className, style, ...rest } = props
+ const {
+ accentColor,
+ icon,
+ alert,
+ alertReason,
+ className,
+ style,
+ // Destructure component-only props so they don't leak into Shell's DOM spread
+ ...rest
+ } = props
+ // Strip non-DOM props from rest before Shell spread
+ const {
+ label: _label,
+ value: _value,
+ unit: _unit,
+ subValue: _subValue,
+ subtitle: _subtitle,
+ trend: _trend,
+ ...shellRest
+ } = rest as Record
if (props.type === "ring") {
const la = new Date(props.lastAccessAt).getTime()
@@ -456,7 +473,13 @@ function StatCard(props: StatCardProps) {
// Default: basic
return (
-
+