diff --git a/src/lib/components/CatalogGrid.svelte b/src/lib/components/CatalogGrid.svelte
index d67e4cf..7932a4d 100644
--- a/src/lib/components/CatalogGrid.svelte
+++ b/src/lib/components/CatalogGrid.svelte
@@ -8,6 +8,7 @@
import * as Card from '#lib/components/ui/card/index.js';
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
+ import PrivateLock from '#lib/components/PrivateLock.svelte';
type Item = {
name: string;
@@ -17,6 +18,7 @@
children?: readonly Item[];
icon?: string;
featured?: boolean;
+ locked?: boolean;
};
let {
items,
@@ -57,9 +59,9 @@
{/if}
{project.name}{project.subtitle}{project.name}{#if project.locked}{/if}{project.subtitle}
{#if project.external}
diff --git a/src/lib/components/ErrorView.svelte b/src/lib/components/ErrorView.svelte
index e644268..37ff101 100644
--- a/src/lib/components/ErrorView.svelte
+++ b/src/lib/components/ErrorView.svelte
@@ -16,6 +16,7 @@
import ChartColumn from 'lucide-svelte/icons/chart-column';
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
+ import PrivateLock from '#lib/components/PrivateLock.svelte';
import { errorCopy, suggestPages } from '#lib/errors.js';
let {
@@ -140,7 +141,9 @@
>
{/if}
- {s.name}
+ {s.name}{#if s.locked}{/if}
{s.subtitle}
+ // Small monochrome lock after the name of an owner-only tracker (driven by `locked` in
+ // #lib/projects). A status marker, so it inherits the muted text colour, not an accent.
+ import Lock from 'lucide-svelte/icons/lock';
+ // `tight`: inside a flex row whose gap already spaces it from the name.
+ let {
+ size = 12,
+ tight = false,
+ class: className = ''
+ }: { size?: number; tight?: boolean; class?: string } = $props();
+
+
+
+
+
diff --git a/src/lib/components/SiteChrome.svelte b/src/lib/components/SiteChrome.svelte
index 73e333e..1e8f875 100644
--- a/src/lib/components/SiteChrome.svelte
+++ b/src/lib/components/SiteChrome.svelte
@@ -7,6 +7,7 @@
import CopyButton from '#lib/components/CopyButton.svelte';
import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
+ import PrivateLock from '#lib/components/PrivateLock.svelte';
import { projects, visibleTrackers } from '#lib/projects.js';
import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
import * as Sheet from '#lib/components/ui/sheet/index.js';
@@ -342,8 +343,10 @@
aria-expanded={flyout === project.name}
>{project.name}{project.name}{#if 'locked' in project && project.locked}{/if}{project.subtitle}
{/if}
{project.name}{project.name}{#if 'locked' in project && project.locked}{/if}{project.subtitle}
@@ -529,7 +534,7 @@
onclick={() => (menuOpen = false)}
>{project.name}{#if 'locked' in project && project.locked}{/if}
{/each}
diff --git a/src/lib/errors.ts b/src/lib/errors.ts
index 22361f3..e19b1dc 100644
--- a/src/lib/errors.ts
+++ b/src/lib/errors.ts
@@ -61,6 +61,7 @@ export type Suggestion = {
href: string;
kind: 'tracker' | 'project' | 'page';
subtitle: string;
+ locked?: boolean;
};
const pages: Suggestion[] = [
@@ -77,7 +78,13 @@ export function destinations(isOwner: boolean): Suggestion[] {
if (!p.external)
out.push({ name: p.name, href: p.href, kind: 'project', subtitle: p.subtitle });
for (const t of visibleTrackers(isOwner)) {
- out.push({ name: t.name, href: t.href, kind: 'tracker', subtitle: t.subtitle });
+ out.push({
+ name: t.name,
+ href: t.href,
+ kind: 'tracker',
+ subtitle: t.subtitle,
+ ...(t.locked ? { locked: true } : {})
+ });
for (const c of t.children ?? [])
out.push({ name: c.name, href: c.href, kind: 'tracker', subtitle: c.subtitle });
}
diff --git a/src/lib/projects.test.ts b/src/lib/projects.test.ts
index 1159b57..dfb0f10 100644
--- a/src/lib/projects.test.ts
+++ b/src/lib/projects.test.ts
@@ -18,4 +18,14 @@ describe('trackers', () => {
it('uses short single-word names', () => {
for (const t of visibleTrackers(true)) expect(t.name).toMatch(/^[a-z]+$/);
});
+ it('locks every owner-only tracker, drops the private: prefix', () => {
+ const owner = visibleTrackers(true);
+ const pub = new Set(visibleTrackers(false).map((t) => t.name));
+ for (const t of owner) {
+ if (!pub.has(t.name)) expect(t.locked).toBe(true);
+ expect(t.subtitle).not.toMatch(/^private:/);
+ }
+ expect(owner.find((t) => t.name === 'money')?.locked).toBe(true);
+ expect(owner.find((t) => t.name === 'reading')?.locked).toBeUndefined();
+ });
});
diff --git a/src/lib/projects.ts b/src/lib/projects.ts
index 47ea734..2f28584 100644
--- a/src/lib/projects.ts
+++ b/src/lib/projects.ts
@@ -43,6 +43,8 @@ type Tracker = {
icon?: string;
/** Shown on group tiles before the "more" chip (main and actively played games). */
featured?: boolean;
+ /** Owner-only (needs login): every listing shows a small lock after the name. */
+ locked?: boolean;
};
// The spotting trackers: pokedexes of things I can spot in the wild. Grouped under one
@@ -110,11 +112,12 @@ export const gameTrackers: readonly Tracker[] = [
// Trackers, in display order. Names are short single nouns. Most keep their existing
// /projects/... URLs (no moves, so no redirects needed). `private` trackers are shown in the
// header, home page and /trackers only to the logged-in owner (layout data `isOwner`), never
-// in the sitemap; their routes 404 for everyone else.
+// in the sitemap; their routes 404 for everyone else. Private trackers, and listed ones that
+// still need a login (`locked`, e.g. money), get a lock icon next to their name everywhere.
const allTrackers: readonly (Tracker & { private: boolean })[] = [
{
name: 'tasks',
- subtitle: 'private: to-dos, goals and my TickTick history',
+ subtitle: 'to-dos, goals and my TickTick history',
href: '/trackers/tasks',
external: false,
private: true
@@ -124,6 +127,7 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
subtitle: 'a manual ledger for accounts, bills and utilities',
href: '/trackers/money',
external: false,
+ locked: true,
private: false
},
{
@@ -157,28 +161,28 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
},
{
name: 'health',
- subtitle: 'private: body, workouts, sleep, meds and vaccinations',
+ subtitle: 'body, workouts, sleep, meds and vaccinations',
href: '/trackers/health',
external: false,
private: true
},
{
name: 'groceries',
- subtitle: 'private: supermarket products, favorites and prices per store',
+ subtitle: 'supermarket products, favorites and prices per store',
href: '/trackers/groceries',
external: false,
private: true
},
{
name: 'houses',
- subtitle: 'private: rentals around Gouda, applications and alerts',
+ subtitle: 'rentals around Gouda, applications and alerts',
href: '/trackers/houses',
external: false,
private: true
},
{
name: 'holders',
- subtitle: 'private: who has my address, email, phone and bank details, and a moving checklist',
+ subtitle: 'who has my address, email, phone and bank details, and a moving checklist',
href: '/trackers/data-holders',
external: false,
private: true
@@ -221,8 +225,22 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
}
];
-const strip = ({ name, subtitle, href, external, children }: Tracker): Tracker =>
- children ? { name, subtitle, href, external, children } : { name, subtitle, href, external };
+const strip = ({
+ name,
+ subtitle,
+ href,
+ external,
+ children,
+ locked,
+ private: hidden
+}: Tracker & { private: boolean }): Tracker => ({
+ name,
+ subtitle,
+ href,
+ external,
+ ...(children ? { children } : {}),
+ ...(locked || hidden ? { locked: true } : {})
+});
/** Public trackers (sitemap, logged-out navigation). */
export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private).map(strip);
/** Owner-only trackers. */
diff --git a/src/routes/start/+page.svelte b/src/routes/start/+page.svelte
index 4151de3..dd9181b 100644
--- a/src/routes/start/+page.svelte
+++ b/src/routes/start/+page.svelte
@@ -4,6 +4,7 @@
import { ago, bytes, duration, timerStatus, type Container } from '#lib/home.js';
import { linkIcon } from '#lib/link-icons.js';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
+ import PrivateLock from '#lib/components/PrivateLock.svelte';
import { visibleTrackers } from '#lib/projects.js';
let { data, form } = $props();
@@ -307,7 +308,7 @@