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 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 @@
</span>
{/if}
<span class="min-w-0"
><span class="block font-medium">{project.name}</span><span class={subtitleClass}
>{project.subtitle}</span
></span
><span class="block font-medium"
>{project.name}{#if project.locked}<PrivateLock />{/if}</span
><span class={subtitleClass}>{project.subtitle}</span></span
>
</span>
{#if project.external}

View File

@@ -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}
<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>
<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 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}
><TrackerIcon name={project.name} class="tile-icon size-9" /><span
class="min-w-0"
><span class="block font-medium">{project.name}</span><span
class="block truncate text-xs text-muted-foreground"
><span class="block font-medium"
>{project.name}{#if 'locked' in project && project.locked}<PrivateLock
/>{/if}</span
><span class="block truncate text-xs text-muted-foreground"
>{project.subtitle}</span
></span
><ChevronRight
@@ -410,8 +413,10 @@
</span>
{/if}
<span class="min-w-0"
><span class="block font-medium">{project.name}</span><span
class="block truncate text-xs text-muted-foreground"
><span class="block font-medium"
>{project.name}{#if 'locked' in project && project.locked}<PrivateLock
/>{/if}</span
><span class="block truncate text-xs text-muted-foreground"
>{project.subtitle}</span
></span
>
@@ -529,7 +534,7 @@
onclick={() => (menuOpen = false)}
><TrackerIcon name={project.name} class="tile-icon size-7" /><span
class="truncate">{project.name}</span
></a
>{#if 'locked' in project && project.locked}<PrivateLock tight />{/if}</a
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{/each}

View File

@@ -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 });
}

View File

@@ -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();
});
});

View File

@@ -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. */