Money: move /projects/money-tracker to /trackers/money, rename to Money, 308 redirects from old paths (bank-callback query kept)
All checks were successful
Deploy production / deploy (push) Successful in 2m49s

This commit is contained in:
2026-10-24 02:37:07 +00:00
parent 2af279e8a9
commit 988c6c31a9
17 changed files with 67 additions and 29 deletions

View File

@@ -260,9 +260,9 @@ bind-mounts the host's `BOOKS_HOST_DIR` (default `/home/stan/books`) read-only a
`BOOKS_ROOT=/books`. Without a `library` directory there the deploy still succeeds, but files can't `BOOKS_ROOT=/books`. Without a `library` directory there the deploy still succeeds, but files can't
be opened. be opened.
## Money tracker ## Money
`/projects/money-tracker` is restricted to administrator accounts. Its tables `/trackers/money` (formerly `/projects/money-tracker`, which now redirects) is restricted to administrator accounts. Its tables
are created by `npm run db:migrate:money-tracker`; apply that migration to each are created by `npm run db:migrate:money-tracker`; apply that migration to each
environment before deploying the new route. The deployment preflight checks for environment before deploying the new route. The deployment preflight checks for
all nine tracker tables and the projection columns. all nine tracker tables and the projection columns.

View File

@@ -69,7 +69,7 @@ const aspsp = {
const defaultAccount = value('--account') ?? preset?.account ?? aspsp.name; const defaultAccount = value('--account') ?? preset?.account ?? aspsp.name;
const redirectUrl = const redirectUrl =
process.env.ENABLEBANKING_REDIRECT_URL ?? process.env.ENABLEBANKING_REDIRECT_URL ??
'https://stanrunge.dev/projects/money-tracker/bank-callback'; 'https://stanrunge.dev/projects/money-tracker/bank-callback'; // registered with Enable Banking; 308s to /trackers/money/bank-callback with the query kept
const app = await client.application(); const app = await client.application();
console.log( console.log(

View File

@@ -33,7 +33,7 @@
'derby lens': ChartNoAxesCombined, 'derby lens': ChartNoAxesCombined,
'world opinions': Globe2, 'world opinions': Globe2,
'language learner': Languages, 'language learner': Languages,
'money tracker': Wallet, money: Wallet,
'cs prices': CandlestickChart, 'cs prices': CandlestickChart,
'house tracker': House, 'house tracker': House,
lists: ListChecks, lists: ListChecks,

View File

@@ -41,9 +41,9 @@ export const trackers = [
external: false external: false
}, },
{ {
name: 'money tracker', name: 'money',
subtitle: 'a manual ledger for accounts, bills and utilities', subtitle: 'a manual ledger for accounts, bills and utilities',
href: '/projects/money-tracker', href: '/trackers/money',
external: false external: false
}, },
{ {

View File

@@ -34,10 +34,10 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/projects/energy': 'Energy', '/projects/energy': 'Energy',
'/projects/game-tracker': 'Game Tracker', '/projects/game-tracker': 'Game Tracker',
'/projects/language-learner': 'Language Learner', '/projects/language-learner': 'Language Learner',
'/projects/money-tracker': 'Money Tracker',
'/projects/spotify-beatmap-finder': 'osu! map finder', '/projects/spotify-beatmap-finder': 'osu! map finder',
'/projects/progress': 'Progress', '/projects/progress': 'Progress',
'/projects/world-opinions': 'World Opinions' '/projects/world-opinions': 'World Opinions',
'/trackers/money': 'Money'
}; };
const cleaned = path.replace(/\/$/, '') || '/'; const cleaned = path.replace(/\/$/, '') || '/';
const title = const title =

View File

@@ -14,9 +14,9 @@
stored. Session cookies keep you signed in; there are no advertising or third-party tracking stored. Session cookies keep you signed in; there are no advertising or third-party tracking
cookies. cookies.
</p> </p>
<h2 class="pt-2 text-lg font-semibold">Bank data (money tracker)</h2> <h2 class="pt-2 text-lg font-semibold">Bank data (Money)</h2>
<p> <p>
The money tracker is a private tool used only by the site owner. With the account holders' Money (/trackers/money) is a private tool used only by the site owner. With the account holders'
explicit consent, it reads account details, balances and booked transactions from Revolut explicit consent, it reads account details, balances and booked transactions from Revolut
through Enable Banking Oy, a licensed account information service provider. This data is used through Enable Banking Oy, a licensed account information service provider. This data is used
only to show the account holders their own finances. It is stored in a database on a server only to show the account holders their own finances. It is stored in a database on a server

View File

@@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
// Money moved from /projects/money-tracker to /trackers/money. Old links, and the Enable Banking
// redirect URL registered as /projects/money-tracker/bank-callback, keep working: 308 keeps the
// method, the subpath and the query string (?range=, ?code=&state=).
export const fallback: RequestHandler = ({ params, url }) => {
redirect(308, `/trackers/money${params.rest ? `/${params.rest}` : ''}${url.search}`);
};
export const GET = fallback;

View File

@@ -0,0 +1,30 @@
import { isRedirect } from '@sveltejs/kit';
import { describe, expect, it } from 'vitest';
import { fallback } from './+server';
function target(path: string, rest: string) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fallback({ params: { rest }, url: new URL(`https://stanrunge.dev${path}`) } as any);
} catch (thrown) {
if (isRedirect(thrown)) return [thrown.status, thrown.location];
throw thrown;
}
return null;
}
describe('old money tracker URLs', () => {
it('redirects to /trackers/money, keeping subpath and query', () => {
expect(target('/projects/money-tracker?range=3y', '')).toEqual([
308,
'/trackers/money?range=3y'
]);
expect(target('/projects/money-tracker/transactions?page=2', 'transactions')).toEqual([
308,
'/trackers/money/transactions?page=2'
]);
expect(
target('/projects/money-tracker/bank-callback?state=s&code=abc-123', 'bank-callback')
).toEqual([308, '/trackers/money/bank-callback?state=s&code=abc-123']);
});
});

View File

@@ -22,10 +22,11 @@
You're responsible for activity on your account. Accounts that abuse the site may be removed. You're responsible for activity on your account. Accounts that abuse the site may be removed.
You can ask for your account to be deleted at any time. You can ask for your account to be deleted at any time.
</p> </p>
<h2 class="pt-2 text-lg font-semibold">Money tracker</h2> <h2 class="pt-2 text-lg font-semibold">Money</h2>
<p> <p>
The money tracker is a private tool for the site owner and household. Its bank connection is Money (/trackers/money) is a private tool for the site owner and household. Its bank connection
read-only (account information via Enable Banking) and nothing shown in it is financial advice. is read-only (account information via Enable Banking) and nothing shown in it is financial
advice.
</p> </p>
<h2 class="pt-2 text-lg font-semibold">Liability</h2> <h2 class="pt-2 text-lg font-semibold">Liability</h2>
<p> <p>

View File

@@ -31,7 +31,7 @@ import { error, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => { export const load: PageServerLoad = async (event) => {
if (!event.locals.user) redirect(303, '/login?returnTo=/projects/money-tracker'); if (!event.locals.user) redirect(303, '/login?returnTo=/trackers/money');
const user = requireAdmin(event); const user = requireAdmin(event);
const userId = user.id; const userId = user.id;
const [ const [

View File

@@ -250,7 +250,7 @@
); );
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
link.download = 'money-tracker-transactions.csv'; link.download = 'money-transactions.csv';
link.click(); link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000); setTimeout(() => URL.revokeObjectURL(url), 1000);
} }
@@ -276,7 +276,7 @@
<header class="app-header"> <header class="app-header">
<div> <div>
<p class="muted">Personal finance</p> <p class="muted">Personal finance</p>
<h1>Money tracker</h1> <h1>Money</h1>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<Button variant="outline" onclick={() => open('import')} <Button variant="outline" onclick={() => open('import')}
@@ -440,7 +440,7 @@
<p>{filtered.length} of {data.transactions.length} entries</p> <p>{filtered.length} of {data.transactions.length} entries</p>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<Button variant="outline" href={resolve('/projects/money-tracker/transactions')} <Button variant="outline" href={resolve('/trackers/money/transactions')}
>All transactions</Button >All transactions</Button
><Button variant="outline" onclick={exportCsv} disabled={!data.transactions.length} ><Button variant="outline" onclick={exportCsv} disabled={!data.transactions.length}
><Download size={16} /> Export CSV</Button ><Download size={16} /> Export CSV</Button
@@ -767,7 +767,7 @@
<Dialog.Root bind:open={dialogOpen}> <Dialog.Root bind:open={dialogOpen}>
<Dialog.Content class="money-dialog block max-h-[90dvh] overflow-y-auto sm:max-w-lg"> <Dialog.Content class="money-dialog block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
<Dialog.Description class="sr-only">Manage your money tracker records.</Dialog.Description> <Dialog.Description class="sr-only">Manage your money records.</Dialog.Description>
{#if showActionError && form?.message}<p class="form-error" role="alert">{form.message}</p>{/if} {#if showActionError && form?.message}<p class="form-error" role="alert">{form.message}</p>{/if}
<Dialog.Header class="mb-6 pr-8" <Dialog.Header class="mb-6 pr-8"
><Dialog.Title> ><Dialog.Title>

View File

@@ -14,10 +14,7 @@
</script> </script>
<svelte:head <svelte:head
><title>Bank connection · Money tracker</title><meta ><title>Bank connection · Money</title><meta name="robots" content="noindex" /></svelte:head
name="robots"
content="noindex"
/></svelte:head
> >
<section class="mx-auto max-w-xl space-y-6 p-6"> <section class="mx-auto max-w-xl space-y-6 p-6">
@@ -48,6 +45,6 @@
</p> </p>
{/if} {/if}
<p> <p>
<a class="underline" href={resolve('/projects/money-tracker')}>Back to the money tracker</a> <a class="underline" href={resolve('/trackers/money')}>Back to Money</a>
</p> </p>
</section> </section>

View File

@@ -36,7 +36,7 @@
else params.set(key, String(value)); else params.set(key, String(value));
} }
const query = params.toString(); const query = params.toString();
return resolve('/projects/money-tracker/transactions') + (query ? '?' + query : ''); return resolve('/trackers/money/transactions') + (query ? '?' + query : '');
} }
function submit(event: SubmitEvent) { function submit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
@@ -45,7 +45,7 @@
for (const [key, value] of form) for (const [key, value] of form)
if (typeof value === 'string' && value) params.append(key, value); if (typeof value === 'string' && value) params.append(key, value);
const query = params.toString(); const query = params.toString();
goto(resolve('/projects/money-tracker/transactions') + (query ? '?' + query : ''), { goto(resolve('/trackers/money/transactions') + (query ? '?' + query : ''), {
keepFocus: true, keepFocus: true,
noScroll: true noScroll: true
}); });
@@ -55,14 +55,14 @@
} }
</script> </script>
<svelte:head><title>Transactions · Money tracker</title></svelte:head> <svelte:head><title>Transactions · Money</title></svelte:head>
<div class="mx-auto max-w-screen-xl space-y-6 px-4 py-8 sm:px-6 sm:py-10"> <div class="mx-auto max-w-screen-xl space-y-6 px-4 py-8 sm:px-6 sm:py-10">
<header class="flex flex-wrap items-end justify-between gap-4"> <header class="flex flex-wrap items-end justify-between gap-4">
<div> <div>
<a <a
class="inline-flex items-center gap-1 text-sm opacity-70 hover:opacity-100" class="inline-flex items-center gap-1 text-sm opacity-70 hover:opacity-100"
href={resolve('/projects/money-tracker')}><ArrowLeft size={14} /> Money tracker</a href={resolve('/trackers/money')}><ArrowLeft size={14} /> Money</a
> >
<h1 class="text-2xl font-bold">Transactions</h1> <h1 class="text-2xl font-bold">Transactions</h1>
<p class="text-sm opacity-70"> <p class="text-sm opacity-70">
@@ -138,7 +138,7 @@
<div class="flex items-end gap-2"> <div class="flex items-end gap-2">
<Button type="submit">Apply</Button><Button <Button type="submit">Apply</Button><Button
variant="ghost" variant="ghost"
href={resolve('/projects/money-tracker/transactions')}>Reset</Button href={resolve('/trackers/money/transactions')}>Reset</Button
> >
</div> </div>
</form> </form>

View File

@@ -365,7 +365,7 @@ test('shared dialogs have focus, Escape dismissal and mobile layouts without ove
await expect(dialog).toHaveCount(0); await expect(dialog).toHaveCount(0);
await expect(page.getByRole('button', { name: label, exact: true })).toBeFocused(); await expect(page.getByRole('button', { name: label, exact: true })).toBeFocused();
} }
await page.goto('/projects/money-tracker'); await page.goto('/trackers/money');
await page.getByRole('heading', { name: 'Accounts', exact: true }).waitFor(); await page.getByRole('heading', { name: 'Accounts', exact: true }).waitFor();
await page.locator('#accounts').getByRole('button', { name: 'Add', exact: true }).click(); await page.locator('#accounts').getByRole('button', { name: 'Add', exact: true }).click();
const moneyDialog = page.getByRole('dialog'); const moneyDialog = page.getByRole('dialog');