trackers: lock icon after owner-only tracker names (header dropdown, mobile menu, home, /trackers, /start, error suggestions) from one locked flag in #lib/projects; drop the private: subtitle prefix
All checks were successful
Deploy production / deploy (push) Successful in 1m7s

This commit is contained in:
2026-10-25 16:22:25 +00:00
parent eff5a24985
commit 676ac2ad64
8 changed files with 95 additions and 20 deletions

View File

@@ -8,6 +8,7 @@
import * as Card from '#lib/components/ui/card/index.js'; import * as Card from '#lib/components/ui/card/index.js';
import ProjectIcon from '#lib/components/ProjectIcon.svelte'; import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import TrackerIcon from '#lib/components/TrackerIcon.svelte'; import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
type Item = { type Item = {
name: string; name: string;
@@ -17,6 +18,7 @@
children?: readonly Item[]; children?: readonly Item[];
icon?: string; icon?: string;
featured?: boolean; featured?: boolean;
locked?: boolean;
}; };
let { let {
items, items,
@@ -57,9 +59,9 @@
</span> </span>
{/if} {/if}
<span class="min-w-0" <span class="min-w-0"
><span class="block font-medium">{project.name}</span><span class={subtitleClass} ><span class="block font-medium"
>{project.subtitle}</span >{project.name}{#if project.locked}<PrivateLock />{/if}</span
></span ><span class={subtitleClass}>{project.subtitle}</span></span
> >
</span> </span>
{#if project.external} {#if project.external}

View File

@@ -16,6 +16,7 @@
import ChartColumn from 'lucide-svelte/icons/chart-column'; import ChartColumn from 'lucide-svelte/icons/chart-column';
import ProjectIcon from '#lib/components/ProjectIcon.svelte'; import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import TrackerIcon from '#lib/components/TrackerIcon.svelte'; import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import { errorCopy, suggestPages } from '#lib/errors.js'; import { errorCopy, suggestPages } from '#lib/errors.js';
let { let {
@@ -140,7 +141,9 @@
> >
{/if} {/if}
<span class="min-w-0 flex-1"> <span class="min-w-0 flex-1">
<span class="block font-medium">{s.name}</span> <span class="block font-medium"
>{s.name}{#if s.locked}<PrivateLock />{/if}</span
>
<span class="block truncate text-sm text-muted-foreground">{s.subtitle}</span> <span class="block truncate text-sm text-muted-foreground">{s.subtitle}</span>
</span> </span>
<ArrowRight <ArrowRight

View File

@@ -0,0 +1,29 @@
<script lang="ts">
// 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();
</script>
<span class={['private-lock', { tight }, className]} role="img" aria-label="private" title="private"
><Lock {size} strokeWidth={2.25} aria-hidden="true" /></span
>
<style>
.private-lock {
display: inline-flex;
flex-shrink: 0;
margin-left: 0.3em;
vertical-align: -0.08em;
color: var(--site-muted, currentColor);
opacity: 0.75;
}
.private-lock.tight {
margin-left: -0.2em;
}
</style>

View File

@@ -7,6 +7,7 @@
import CopyButton from '#lib/components/CopyButton.svelte'; import CopyButton from '#lib/components/CopyButton.svelte';
import ProjectIcon from '#lib/components/ProjectIcon.svelte'; import ProjectIcon from '#lib/components/ProjectIcon.svelte';
import TrackerIcon from '#lib/components/TrackerIcon.svelte'; import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import { projects, visibleTrackers } from '#lib/projects.js'; import { projects, visibleTrackers } from '#lib/projects.js';
import { Button, buttonVariants } from '#lib/components/ui/button/index.js'; import { Button, buttonVariants } from '#lib/components/ui/button/index.js';
import * as Sheet from '#lib/components/ui/sheet/index.js'; import * as Sheet from '#lib/components/ui/sheet/index.js';
@@ -342,8 +343,10 @@
aria-expanded={flyout === project.name} aria-expanded={flyout === project.name}
><TrackerIcon name={project.name} class="tile-icon size-9" /><span ><TrackerIcon name={project.name} class="tile-icon size-9" /><span
class="min-w-0" class="min-w-0"
><span class="block font-medium">{project.name}</span><span ><span class="block font-medium"
class="block truncate text-xs text-muted-foreground" >{project.name}{#if 'locked' in project && project.locked}<PrivateLock
/>{/if}</span
><span class="block truncate text-xs text-muted-foreground"
>{project.subtitle}</span >{project.subtitle}</span
></span ></span
><ChevronRight ><ChevronRight
@@ -410,8 +413,10 @@
</span> </span>
{/if} {/if}
<span class="min-w-0" <span class="min-w-0"
><span class="block font-medium">{project.name}</span><span ><span class="block font-medium"
class="block truncate text-xs text-muted-foreground" >{project.name}{#if 'locked' in project && project.locked}<PrivateLock
/>{/if}</span
><span class="block truncate text-xs text-muted-foreground"
>{project.subtitle}</span >{project.subtitle}</span
></span ></span
> >
@@ -529,7 +534,7 @@
onclick={() => (menuOpen = false)} onclick={() => (menuOpen = false)}
><TrackerIcon name={project.name} class="tile-icon size-7" /><span ><TrackerIcon name={project.name} class="tile-icon size-7" /><span
class="truncate">{project.name}</span class="truncate">{project.name}</span
></a >{#if 'locked' in project && project.locked}<PrivateLock tight />{/if}</a
> >
<!-- eslint-enable svelte/no-navigation-without-resolve --> <!-- eslint-enable svelte/no-navigation-without-resolve -->
{/each} {/each}

View File

@@ -61,6 +61,7 @@ export type Suggestion = {
href: string; href: string;
kind: 'tracker' | 'project' | 'page'; kind: 'tracker' | 'project' | 'page';
subtitle: string; subtitle: string;
locked?: boolean;
}; };
const pages: Suggestion[] = [ const pages: Suggestion[] = [
@@ -77,7 +78,13 @@ export function destinations(isOwner: boolean): Suggestion[] {
if (!p.external) if (!p.external)
out.push({ name: p.name, href: p.href, kind: 'project', subtitle: p.subtitle }); out.push({ name: p.name, href: p.href, kind: 'project', subtitle: p.subtitle });
for (const t of visibleTrackers(isOwner)) { 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 ?? []) for (const c of t.children ?? [])
out.push({ name: c.name, href: c.href, kind: 'tracker', subtitle: c.subtitle }); out.push({ name: c.name, href: c.href, kind: 'tracker', subtitle: c.subtitle });
} }

View File

@@ -18,4 +18,14 @@ describe('trackers', () => {
it('uses short single-word names', () => { it('uses short single-word names', () => {
for (const t of visibleTrackers(true)) expect(t.name).toMatch(/^[a-z]+$/); 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();
});
}); });

View File

@@ -43,6 +43,8 @@ type Tracker = {
icon?: string; icon?: string;
/** Shown on group tiles before the "more" chip (main and actively played games). */ /** Shown on group tiles before the "more" chip (main and actively played games). */
featured?: boolean; 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 // 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 // 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 // /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 // 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 })[] = [ const allTrackers: readonly (Tracker & { private: boolean })[] = [
{ {
name: 'tasks', name: 'tasks',
subtitle: 'private: to-dos, goals and my TickTick history', subtitle: 'to-dos, goals and my TickTick history',
href: '/trackers/tasks', href: '/trackers/tasks',
external: false, external: false,
private: true private: true
@@ -124,6 +127,7 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
subtitle: 'a manual ledger for accounts, bills and utilities', subtitle: 'a manual ledger for accounts, bills and utilities',
href: '/trackers/money', href: '/trackers/money',
external: false, external: false,
locked: true,
private: false private: false
}, },
{ {
@@ -157,28 +161,28 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
}, },
{ {
name: 'health', name: 'health',
subtitle: 'private: body, workouts, sleep, meds and vaccinations', subtitle: 'body, workouts, sleep, meds and vaccinations',
href: '/trackers/health', href: '/trackers/health',
external: false, external: false,
private: true private: true
}, },
{ {
name: 'groceries', name: 'groceries',
subtitle: 'private: supermarket products, favorites and prices per store', subtitle: 'supermarket products, favorites and prices per store',
href: '/trackers/groceries', href: '/trackers/groceries',
external: false, external: false,
private: true private: true
}, },
{ {
name: 'houses', name: 'houses',
subtitle: 'private: rentals around Gouda, applications and alerts', subtitle: 'rentals around Gouda, applications and alerts',
href: '/trackers/houses', href: '/trackers/houses',
external: false, external: false,
private: true private: true
}, },
{ {
name: 'holders', 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', href: '/trackers/data-holders',
external: false, external: false,
private: true private: true
@@ -221,8 +225,22 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
} }
]; ];
const strip = ({ name, subtitle, href, external, children }: Tracker): Tracker => const strip = ({
children ? { name, subtitle, href, external, children } : { name, subtitle, href, external }; 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). */ /** Public trackers (sitemap, logged-out navigation). */
export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private).map(strip); export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private).map(strip);
/** Owner-only trackers. */ /** Owner-only trackers. */

View File

@@ -4,6 +4,7 @@
import { ago, bytes, duration, timerStatus, type Container } from '#lib/home.js'; import { ago, bytes, duration, timerStatus, type Container } from '#lib/home.js';
import { linkIcon } from '#lib/link-icons.js'; import { linkIcon } from '#lib/link-icons.js';
import TrackerIcon from '#lib/components/TrackerIcon.svelte'; import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import { visibleTrackers } from '#lib/projects.js'; import { visibleTrackers } from '#lib/projects.js';
let { data, form } = $props(); let { data, form } = $props();
@@ -307,7 +308,7 @@
<div class="ttile"> <div class="ttile">
<a class="stretched" href={t.href} title={t.subtitle}> <a class="stretched" href={t.href} title={t.subtitle}>
<TrackerIcon name={t.name} class="t-icon size-8" /> <TrackerIcon name={t.name} class="t-icon size-8" />
<span class="tname">{t.name}</span> <span class="tname">{t.name}</span>{#if t.locked}<PrivateLock tight />{/if}
</a> </a>
<span class="tchips"> <span class="tchips">
{#each tileChildren(t) as c (c.name)} {#each tileChildren(t) as c (c.name)}
@@ -330,7 +331,7 @@
{:else} {:else}
<a class="ttile" href={t.href} title={t.subtitle}> <a class="ttile" href={t.href} title={t.subtitle}>
<TrackerIcon name={t.name} class="t-icon size-8" /> <TrackerIcon name={t.name} class="t-icon size-8" />
<span class="tname">{t.name}</span> <span class="tname">{t.name}</span>{#if t.locked}<PrivateLock tight />{/if}
</a> </a>
{/if} {/if}
</li> </li>