Compare commits
No commits in common. "d8365d0e228bcbdef3bca47e71d6f88e9f00020d" and "bdff5ad932c200c72f0d3edc6223e479198bb9a2" have entirely different histories.
d8365d0e22
...
bdff5ad932
|
|
@ -1,24 +0,0 @@
|
||||||
// Pre-bundle theme apply — avoids a light/dark flash. Mirrors applyTheme in
|
|
||||||
// client/contexts/ThemeContext.tsx (theme 'system' follows the OS).
|
|
||||||
// External file (not inline in index.html) so the CSP stays `script-src 'self'`
|
|
||||||
// with no inline-hash maintenance across build-tool upgrades.
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var s = localStorage.getItem('bt-theme');
|
|
||||||
if (s === 'dark-purple') s = 'dark';
|
|
||||||
var theme = s === 'light' || s === 'dark' || s === 'system' ? s : 'dark';
|
|
||||||
var resolved =
|
|
||||||
theme === 'system'
|
|
||||||
? window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
? 'dark'
|
|
||||||
: 'light'
|
|
||||||
: theme;
|
|
||||||
var root = document.documentElement;
|
|
||||||
if (resolved === 'dark') root.classList.add('dark');
|
|
||||||
else root.classList.remove('dark');
|
|
||||||
var m = document.querySelector('meta[name="theme-color"]');
|
|
||||||
if (m) m.setAttribute('content', resolved === 'dark' ? '#101417' : '#f8faf9');
|
|
||||||
} catch (e) {
|
|
||||||
/* localStorage/matchMedia unavailable — keep default */
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
21
index.html
21
index.html
|
|
@ -23,9 +23,24 @@
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
<meta name="apple-mobile-web-app-title" content="Bill Tracker">
|
<meta name="apple-mobile-web-app-title" content="Bill Tracker">
|
||||||
<title>Bill Tracker</title>
|
<title>Bill Tracker</title>
|
||||||
<!-- Pre-bundle theme apply (external so CSP stays script-src 'self'): a
|
<script>
|
||||||
classic script blocks parsing, so the class lands before first paint. -->
|
// Pre-bundle theme apply — avoids a light/dark flash. Mirrors applyTheme in
|
||||||
<script src="/theme-init.js"></script>
|
// client/contexts/ThemeContext.tsx (theme 'system' follows the OS).
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var s = localStorage.getItem('bt-theme');
|
||||||
|
if (s === 'dark-purple') s = 'dark';
|
||||||
|
var theme = (s === 'light' || s === 'dark' || s === 'system') ? s : 'dark';
|
||||||
|
var resolved = theme === 'system'
|
||||||
|
? ((window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light')
|
||||||
|
: theme;
|
||||||
|
var root = document.documentElement;
|
||||||
|
if (resolved === 'dark') root.classList.add('dark'); else root.classList.remove('dark');
|
||||||
|
var m = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (m) m.setAttribute('content', resolved === 'dark' ? '#101417' : '#f8faf9');
|
||||||
|
} catch (e) { /* localStorage/matchMedia unavailable — keep default */ }
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -66,7 +66,7 @@
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^6.26.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^2.5.4",
|
"tailwind-merge": "^2.5.4",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
|
@ -76,12 +76,11 @@
|
||||||
"@axe-core/playwright": "^4.10.1",
|
"@axe-core/playwright": "^4.10.1",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@playwright/test": "^1.50.1",
|
"@playwright/test": "^1.50.1",
|
||||||
"@testing-library/dom": "^10.4.1",
|
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^26.1.0",
|
"@types/node": "^26.1.0",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
|
|
@ -98,7 +97,7 @@
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^3.4.14",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.62.1",
|
"typescript-eslint": "^8.62.1",
|
||||||
"vite": "^8.1.4",
|
"vite": "^5.4.10",
|
||||||
"vite-plugin-pwa": "^1.3.0",
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.8"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -149,16 +149,9 @@ router.get('/backups/:id/download', (req: Req, res: Res) => {
|
||||||
const backup = getBackupFile(req.params.id);
|
const backup = getBackupFile(req.params.id);
|
||||||
res.setHeader('Content-Type', 'application/octet-stream');
|
res.setHeader('Content-Type', 'application/octet-stream');
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
// root-option form: send >= 2026-07 rejects bare absolute paths (4a dep sweep)
|
res.download(backup.path, backup.metadata.id, (err) => {
|
||||||
const pathMod = require('path');
|
if (err && !res.headersSent) sendError(res, err);
|
||||||
res.download(
|
});
|
||||||
pathMod.basename(backup.path),
|
|
||||||
backup.metadata.id,
|
|
||||||
{ root: pathMod.dirname(backup.path) },
|
|
||||||
(err) => {
|
|
||||||
if (err && !res.headersSent) sendError(res, err);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
sendError(res, err);
|
sendError(res, err);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
|
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
|
||||||
import type { Req, Res, Next } from '../types/http';
|
import type { Req, Res, Next } from '../types/http';
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { ValidationError } = require('../utils/apiError.cts');
|
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
@ -22,10 +22,16 @@ router.get('/', (req: Req, res: Res) => {
|
||||||
let where, whereParams, label;
|
let where, whereParams, label;
|
||||||
if (from != null || to != null) {
|
if (from != null || to != null) {
|
||||||
if (!isDate(from) || !isDate(to)) {
|
if (!isDate(from) || !isDate(to)) {
|
||||||
throw ValidationError('from and to must both be YYYY-MM-DD dates', 'from');
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json(
|
||||||
|
standardizeError('from and to must both be YYYY-MM-DD dates', 'VALIDATION_ERROR', 'from'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (from > to) {
|
if (from > to) {
|
||||||
throw ValidationError('from must be on or before to', 'from');
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json(standardizeError('from must be on or before to', 'VALIDATION_ERROR', 'from'));
|
||||||
}
|
}
|
||||||
where = 'p.paid_date BETWEEN ? AND ?';
|
where = 'p.paid_date BETWEEN ? AND ?';
|
||||||
whereParams = [from, to];
|
whereParams = [from, to];
|
||||||
|
|
@ -33,7 +39,15 @@ router.get('/', (req: Req, res: Res) => {
|
||||||
} else {
|
} else {
|
||||||
const year = parseInt(req.query.year || new Date().getFullYear(), 10);
|
const year = parseInt(req.query.year || new Date().getFullYear(), 10);
|
||||||
if (isNaN(year) || year < 2000 || year > 2100) {
|
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||||
throw ValidationError('year must be a 4-digit integer between 2000 and 2100', 'year');
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json(
|
||||||
|
standardizeError(
|
||||||
|
'year must be a 4-digit integer between 2000 and 2100',
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
'year',
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
where = "strftime('%Y', p.paid_date) = ?";
|
where = "strftime('%Y', p.paid_date) = ?";
|
||||||
whereParams = [String(year)];
|
whereParams = [String(year)];
|
||||||
|
|
@ -328,19 +342,11 @@ function buildUserDbExportFile(userId) {
|
||||||
|
|
||||||
router.get('/user-db', (req: Req, res: Res) => {
|
router.get('/user-db', (req: Req, res: Res) => {
|
||||||
const file = buildUserDbExportFile(req.user.id);
|
const file = buildUserDbExportFile(req.user.id);
|
||||||
// root-option form: send >= 2026-07 rejects bare absolute paths (4a dep sweep)
|
res.download(file, 'bill-tracker-user-export.sqlite', () => {
|
||||||
res.download(
|
try {
|
||||||
path.basename(file),
|
fs.unlinkSync(file);
|
||||||
'bill-tracker-user-export.sqlite',
|
} catch {}
|
||||||
{
|
});
|
||||||
root: path.dirname(file),
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
try {
|
|
||||||
fs.unlinkSync(file);
|
|
||||||
} catch {}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Full portable JSON export of the user's data (same assembly as SQLite/Excel).
|
// Full portable JSON export of the user's data (same assembly as SQLite/Excel).
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
|
// @ts-nocheck — route controller converted to .cts; handler req/res typed, full type-check deferred (thin glue over typed services).
|
||||||
import type { Req, Res, Next } from '../types/http';
|
import type { Req, Res, Next } from '../types/http';
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { standardizeError } = require('../middleware/errorFormatter.cts');
|
||||||
const {
|
const {
|
||||||
ApiError,
|
ApiError,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
|
|
@ -268,14 +269,12 @@ router.post('/', (req: Req, res: Res) => {
|
||||||
)
|
)
|
||||||
.get(bill.id, payment.paid_date, payment.amount);
|
.get(bill.id, payment.paid_date, payment.amount);
|
||||||
if (existingDuplicate) {
|
if (existingDuplicate) {
|
||||||
// Deliberate hand-rolled body (documented exception): carries the
|
|
||||||
// `existing` payment the client's "add anyway?" flow renders —
|
|
||||||
// formatError has no extras channel. Shape matches the standard envelope.
|
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: 'DUPLICATE_SUSPECTED',
|
...standardizeError(
|
||||||
message: 'A matching payment already exists for this bill, date, and amount',
|
'A matching payment already exists for this bill, date, and amount',
|
||||||
code: 'DUPLICATE_SUSPECTED',
|
'DUPLICATE_SUSPECTED',
|
||||||
field: 'amount',
|
'amount',
|
||||||
|
),
|
||||||
existing: serializePayment(existingDuplicate),
|
existing: serializePayment(existingDuplicate),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -273,8 +273,7 @@ const { getCsrfToken } = require('./middleware/csrf.cts');
|
||||||
app.get('/{*splat}', (req, res) => {
|
app.get('/{*splat}', (req, res) => {
|
||||||
// Set CSRF cookie if not present (needed for SPA to read token)
|
// Set CSRF cookie if not present (needed for SPA to read token)
|
||||||
getCsrfToken(req, res);
|
getCsrfToken(req, res);
|
||||||
// root-option form: send >= 2026-07 rejects bare absolute paths (4a dep sweep)
|
res.sendFile(path.join(DIST, 'index.html'));
|
||||||
res.sendFile('index.html', { root: DIST });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Global error handler ──────────────────────────────────────────────────────
|
// ── Global error handler ──────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ const dbPath = path.join(os.tmpdir(), `bill-tracker-export-richer-${process.pid}
|
||||||
process.env.DB_PATH = dbPath;
|
process.env.DB_PATH = dbPath;
|
||||||
|
|
||||||
const { getDb, closeDb } = require('../db/database.cts');
|
const { getDb, closeDb } = require('../db/database.cts');
|
||||||
const { formatError } = require('../utils/apiError.cts');
|
|
||||||
const exportRouter = require('../routes/export.cts');
|
const exportRouter = require('../routes/export.cts');
|
||||||
const { getUserExportData } = exportRouter;
|
const { getUserExportData } = exportRouter;
|
||||||
|
|
||||||
|
|
@ -40,13 +39,7 @@ function callExport(query) {
|
||||||
resolve({ status: this.statusCode, headers, body });
|
resolve({ status: this.statusCode, headers, body });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// Routes follow the throw-pattern: mirror the app's terminal error handler
|
handler(req, res);
|
||||||
// so thrown ApiErrors resolve to the same wire shape.
|
|
||||||
try {
|
|
||||||
handler(req, res);
|
|
||||||
} catch (err) {
|
|
||||||
resolve({ status: err.status || 500, headers, json: formatError(err) });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,15 +103,18 @@ export default defineConfig({
|
||||||
output: {
|
output: {
|
||||||
// QA-B0-01: split the shared vendor code out of the ~659 kB index chunk
|
// QA-B0-01: split the shared vendor code out of the ~659 kB index chunk
|
||||||
// so large libs load/cache independently and the main bundle shrinks.
|
// so large libs load/cache independently and the main bundle shrinks.
|
||||||
// Function form: vite 8 (rolldown core) dropped the object form.
|
manualChunks: {
|
||||||
manualChunks(id) {
|
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
|
||||||
if (!id.includes('node_modules')) return undefined;
|
'vendor-radix': [
|
||||||
if (/node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/.test(id))
|
'@radix-ui/react-dialog',
|
||||||
return 'vendor-react';
|
'@radix-ui/react-select',
|
||||||
if (/node_modules[\\/]@radix-ui[\\/]/.test(id)) return 'vendor-radix';
|
'@radix-ui/react-dropdown-menu',
|
||||||
if (/node_modules[\\/]framer-motion[\\/]/.test(id)) return 'vendor-motion';
|
'@radix-ui/react-tabs',
|
||||||
if (/node_modules[\\/]@tanstack[\\/]/.test(id)) return 'vendor-query';
|
'@radix-ui/react-tooltip',
|
||||||
return undefined;
|
'@radix-ui/react-alert-dialog',
|
||||||
|
],
|
||||||
|
'vendor-motion': ['framer-motion'],
|
||||||
|
'vendor-query': ['@tanstack/react-query', '@tanstack/react-virtual'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue