AI tracker: /trackers/ai with public news, owner-only bots and usage; ai schema (news_items, bot_status, usage_entries); idempotent import scripts; preflight ai grants check
Some checks failed
Deploy production / deploy (push) Has been cancelled

This commit is contained in:
2026-10-25 23:58:39 +00:00
parent 9b21220280
commit cf0fec973c
24 changed files with 38866 additions and 3 deletions

View File

@@ -31,7 +31,8 @@ export default defineConfig({
'wildlife', 'wildlife',
'trains', 'trains',
'dataholders', 'dataholders',
'stats' 'stats',
'ai'
], ],
migrations: { schema: 'drizzle', table: '__drizzle_migrations' }, migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
verbose: true, verbose: true,

View File

@@ -0,0 +1,76 @@
CREATE SCHEMA "ai";
--> statement-breakpoint
CREATE TABLE "ai"."bot_status" (
"id" serial PRIMARY KEY,
"name" text NOT NULL,
"focus" text,
"recent" text,
"last_active" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "bot_status_name_check" CHECK (length(btrim("name")) > 0)
);
--> statement-breakpoint
CREATE TABLE "ai"."news_items" (
"id" serial PRIMARY KEY,
"date" date NOT NULL,
"title" text NOT NULL,
"url" text NOT NULL,
"note" text,
"section" text DEFAULT 'ai' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "news_items_section_check" CHECK ("section" IN ('ai','software')),
CONSTRAINT "news_items_title_check" CHECK (length(btrim("title")) > 0)
);
--> statement-breakpoint
CREATE TABLE "ai"."usage_entries" (
"id" serial PRIMARY KEY,
"provider" text NOT NULL,
"date" date NOT NULL,
"metric" text NOT NULL,
"value" double precision NOT NULL,
"unit" text,
"source" text DEFAULT 'manual' NOT NULL,
"note" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "usage_entries_provider_check" CHECK ("provider" IN ('claude','chatgpt','gemini','api')),
CONSTRAINT "usage_entries_source_check" CHECK ("source" IN ('scheduled','ccusage','export','manual')),
CONSTRAINT "usage_entries_metric_check" CHECK (length(btrim("metric")) > 0)
);
--> statement-breakpoint
CREATE UNIQUE INDEX "bot_status_name_key" ON "ai"."bot_status" ("name");--> statement-breakpoint
CREATE UNIQUE INDEX "news_items_url_key" ON "ai"."news_items" ("url");--> statement-breakpoint
CREATE INDEX "news_items_date_idx" ON "ai"."news_items" ("date");--> statement-breakpoint
CREATE UNIQUE INDEX "usage_entries_day_key" ON "ai"."usage_entries" ("provider","metric","date","source");--> statement-breakpoint
CREATE INDEX "usage_entries_provider_idx" ON "ai"."usage_entries" ("provider","metric","date");--> statement-breakpoint
-- Hand-written below this line (not generated by drizzle-kit).
-- updated_at triggers, same as every other core table.
DO $$
DECLARE t text;
BEGIN
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
FOREACH t IN ARRAY ARRAY['news_items','bot_status','usage_entries'] LOOP
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON ai.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
END LOOP;
END IF;
END $$;
--> statement-breakpoint
-- Privacy: like tasks and health, readable/writable by the app role only (no personal_ro).
-- News is public on the site, but bot_status and usage_entries are personal, and the
-- owner-only checks live in the app. Grants only run on prod, where migrations run as `personal`.
REVOKE ALL ON SCHEMA ai FROM PUBLIC;
--> statement-breakpoint
DO $$
BEGIN
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT USAGE ON SCHEMA ai TO personal_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ai TO personal_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ai TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA ai
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA ai
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
END IF;
END $$;

File diff suppressed because it is too large Load Diff

41
scripts/ai-bots-import.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* Import Grok Bot agent summaries into ai.bot_status (owner-only /trackers/ai/bots tab).
* Idempotent: upserts on name (focus, recent and last_active are overwritten).
* Short neutral summaries only: never raw transcripts, secrets or private details.
*
* bun scripts/ai-bots-import.ts ai-bots.json # upsert
* bun scripts/ai-bots-import.ts --dry-run ai-bots.json # validate only
*
* JSON: an array of
* { "name": "Daisy", "focus": "…", "recent": "…", "last_active": "2026-10-26T00:45:00+01:00" }
* focus (≤300 chars), recent (≤600) and last_active (ISO date or date-time) may be null.
* Uses DB_URL (the app role on prod); see scripts/ai-news-import.ts for how to run it on stan-ovh.
*/
import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { botsFile, upsertBots } from '../src/lib/server/ai-import';
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const files = args.filter((a) => !a.startsWith('--'));
if (files.length !== 1) {
console.error('usage: bun scripts/ai-bots-import.ts [--dry-run] <file.json>');
process.exit(2);
}
const parsed = botsFile.safeParse(JSON.parse(await readFile(files[0], 'utf8')));
if (!parsed.success) {
for (const issue of parsed.error.issues.slice(0, 50))
console.error(`${issue.path.join('.')}: ${issue.message}`);
process.exit(1);
}
console.log(`Valid: ${parsed.data.length} bots`);
if (dryRun) process.exit(0);
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
try {
const n = await upsertBots(drizzle({ client }), parsed.data);
console.log(`Upserted ${n} bots`);
} finally {
await client.end();
}

43
scripts/ai-news-import.ts Normal file
View File

@@ -0,0 +1,43 @@
/**
* Import AI/software digest items into ai.news_items (public /trackers/ai news tab).
* Idempotent: upserts on url (date, title, note and section are overwritten).
*
* bun scripts/ai-news-import.ts ai-news.json # upsert
* bun scripts/ai-news-import.ts --dry-run ai-news.json # validate only
*
* JSON: an array of
* { "date": "2026-10-26", "title": "…", "url": "https://…", "note": "one line", "section": "ai" }
* section is "ai" or "software" (default "ai"); note may be null or omitted.
* Uses DB_URL (the app role on prod). On stan-ovh, run it inside the app image, e.g.
* sudo docker run --rm --network backend --env-file <app env> -v $PWD/ai-news.json:/tmp/in.json:ro \
* <app image> bun scripts/ai-news-import.ts /tmp/in.json
* or from the repo with DB_URL pointing at 127.0.0.1:5440 (never echo it).
*/
import { readFile } from 'node:fs/promises';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { newsFile, upsertNews } from '../src/lib/server/ai-import';
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const files = args.filter((a) => !a.startsWith('--'));
if (files.length !== 1) {
console.error('usage: bun scripts/ai-news-import.ts [--dry-run] <file.json>');
process.exit(2);
}
const parsed = newsFile.safeParse(JSON.parse(await readFile(files[0], 'utf8')));
if (!parsed.success) {
for (const issue of parsed.error.issues.slice(0, 50))
console.error(`${issue.path.join('.')}: ${issue.message}`);
process.exit(1);
}
console.log(`Valid: ${parsed.data.length} news items`);
if (dryRun) process.exit(0);
if (!process.env.DB_URL) throw new Error('DB_URL is required');
const client = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
try {
const n = await upsertNews(drizzle({ client }), parsed.data);
console.log(`Upserted ${n} news items`);
} finally {
await client.end();
}

View File

@@ -187,6 +187,30 @@ try {
`; `;
if (Number(stats.tables) !== STATS_TABLES || Number(stats.app_rw) !== STATS_TABLES) if (Number(stats.tables) !== STATS_TABLES || Number(stats.app_rw) !== STATS_TABLES)
throw new Error(`stats schema tables/grants are wrong: ${JSON.stringify(stats)}`); throw new Error(`stats schema tables/grants are wrong: ${JSON.stringify(stats)}`);
// Private-by-default AI tracker (news public in the app; bots and usage owner-only): app role
// read/write, personal_ro none. 3 = news_items, bot_status, usage_entries.
const AI_TABLES = 3;
const [ai] = await client`
select
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
count(*) filter (
where exists (
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
where r.rolname = 'personal_ro'
)
) as ro_read,
count(*) as tables,
has_schema_privilege('public', 'ai', 'usage') as public_usage
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'ai' and c.relkind = 'r'
`;
if (
Number(ai.tables) !== AI_TABLES ||
Number(ai.app_rw) !== AI_TABLES ||
Number(ai.ro_read) !== 0 ||
ai.public_usage
)
throw new Error(`ai schema tables/grants are wrong: ${JSON.stringify(ai)}`);
// Energy tracker reads the ned-collector tables (SELECT only). // Energy tracker reads the ned-collector tables (SELECT only).
const [energy] = await client` const [energy] = await client`
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
@@ -196,7 +220,7 @@ try {
if (!Number(energy.hours)) if (!Number(energy.hours))
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days'); console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
console.log( console.log(
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}` `health: ${health.tables} tables, tasks: ${tasks.tables} tables, groceries: ${groceries.tables} tables, stats: ${stats.tables} tables, ai: ${ai.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
); );
const [catalog] = await client` const [catalog] = await client`
select select

View File

@@ -346,6 +346,13 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
external: false, external: false,
private: false private: false
}, },
{
name: 'ai',
subtitle: 'AI and software news, my bots and how much I use Claude, ChatGPT and Gemini',
href: '/trackers/ai',
external: false,
private: false
},
{ {
name: 'health', name: 'health',
subtitle: 'body, workouts, sleep, meds and vaccinations', subtitle: 'body, workouts, sleep, meds and vaccinations',

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { botsFile, newsFile, usageInput } from './ai-import';
import { isOwnerOnlyPath, isOwnerWritePath } from './owner';
describe('ai tracker', () => {
it('parses digest items and defaults the section', () => {
const r = newsFile.parse([
{ date: '2026-10-26', title: ' A model ', url: 'https://example.com/a', note: '' }
]);
expect(r[0]).toMatchObject({ title: 'A model', note: null, section: 'ai' });
const bad = (item: object) => newsFile.safeParse([item]).success;
expect(bad({ date: '26-10-2026', title: 'x', url: 'https://e.com' })).toBe(false);
expect(bad({ date: '2026-10-26', title: 'x', url: 'javascript:alert(1)' })).toBe(false);
expect(bad({ date: '2026-10-26', title: 'x', url: 'https://e.com', section: 'misc' })).toBe(
false
);
});
it('parses bot rows with an optional last_active', () => {
const [a, b] = botsFile.parse([
{ name: 'Daisy', focus: 'site', recent: null, last_active: '2026-10-26T00:45:00+01:00' },
{ name: 'Ilya' }
]);
expect(a.last_active?.toISOString()).toBe('2026-10-25T23:45:00.000Z');
expect(b.last_active).toBeNull();
expect(botsFile.safeParse([{ name: 'x', last_active: 'soon' }]).success).toBe(false);
});
it('validates manual usage entries', () => {
const ok = { provider: 'claude', date: '2026-10-26', metric: 'messages', value: '42' };
expect(usageInput.parse(ok).value).toBe(42);
expect(usageInput.safeParse({ ...ok, provider: 'grok' }).success).toBe(false);
});
it('keeps news public and bots/usage owner-only', () => {
expect(isOwnerOnlyPath('/trackers/ai')).toBe(false);
expect(isOwnerOnlyPath('/trackers/ai/bots')).toBe(true);
expect(isOwnerOnlyPath('/trackers/ai/usage/')).toBe(true);
expect(isOwnerWritePath('/trackers/ai/usage')).toBe(true);
});
});

104
src/lib/server/ai-import.ts Normal file
View File

@@ -0,0 +1,104 @@
// AI tracker: validation and the idempotent upserts shared by /trackers/ai and the import
// scripts (scripts/ai-news-import.ts, scripts/ai-bots-import.ts), so it must not import
// anything from SvelteKit.
import type { PgAsyncDatabase } from 'drizzle-orm/pg-core';
import * as z from 'zod';
import {
AI_NEWS_SECTIONS,
AI_PROVIDERS,
AI_USAGE_SOURCES,
aiBotStatus,
aiNewsItems
} from './db/schema/ai';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Db = PgAsyncDatabase<any, any>;
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'date must be YYYY-MM-DD');
const trimmed = (max: number) => z.string().trim().min(1).max(max);
const optional = (max: number) =>
z
.string()
.trim()
.max(max)
.nullish()
.transform((v) => (v ? v : null));
export const newsItemInput = z.object({
date: isoDate,
title: trimmed(300),
url: z
.string()
.trim()
.url()
.refine((u) => /^https?:\/\//i.test(u), 'url must be http(s)'),
note: optional(500),
section: z.enum(AI_NEWS_SECTIONS).default('ai')
});
export type NewsItemInput = z.infer<typeof newsItemInput>;
export const botInput = z.object({
name: trimmed(80),
focus: optional(300),
recent: optional(600),
/** ISO date or date-time; null when unknown. */
last_active: z
.string()
.trim()
.nullish()
.transform((v, ctx) => {
if (!v) return null;
const d = new Date(v);
if (Number.isNaN(d.getTime())) {
ctx.addIssue({ code: 'custom', message: 'last_active is not a valid date' });
return z.NEVER;
}
return d;
})
});
export type BotInput = z.infer<typeof botInput>;
export const usageInput = z.object({
provider: z.enum(AI_PROVIDERS),
date: isoDate,
metric: trimmed(60),
value: z.coerce.number().finite(),
unit: optional(30),
source: z.enum(AI_USAGE_SOURCES).default('manual'),
note: optional(300)
});
/** Upsert news items on url (idempotent). Returns how many rows were written. */
export async function upsertNews(db: Db, items: NewsItemInput[]) {
let n = 0;
for (const item of items) {
await db
.insert(aiNewsItems)
.values(item)
.onConflictDoUpdate({
target: aiNewsItems.url,
set: { date: item.date, title: item.title, note: item.note, section: item.section }
});
n++;
}
return n;
}
/** Upsert bot rows on name (idempotent). */
export async function upsertBots(db: Db, items: BotInput[]) {
let n = 0;
for (const b of items) {
await db
.insert(aiBotStatus)
.values({ name: b.name, focus: b.focus, recent: b.recent, lastActive: b.last_active })
.onConflictDoUpdate({
target: aiBotStatus.name,
set: { focus: b.focus, recent: b.recent, lastActive: b.last_active }
});
n++;
}
return n;
}
export const newsFile = z.array(newsItemInput);
export const botsFile = z.array(botInput);

View File

@@ -0,0 +1,96 @@
// AI tracker (/trackers/ai): reads and owner-only writes on the `ai` schema.
// News is public; bot status and usage are owner-only (callers gate with requireOwner).
// Imports (scripts/ai-news-import.ts, scripts/ai-bots-import.ts) reuse the parsers below.
import { db } from '#lib/server/db/index.js';
import { aiBotStatus, aiNewsItems, aiUsageEntries } from '#lib/server/db/schema/index.js';
import { usageInput } from '#lib/server/ai-import.js';
import { asc, desc, eq, gte } from 'drizzle-orm';
export type NewsItem = {
id: number;
date: string;
title: string;
url: string;
note: string | null;
section: string;
};
export async function loadNews(limitDays = 120): Promise<NewsItem[]> {
const since = new Date(Date.now() - limitDays * 864e5).toISOString().slice(0, 10);
return db
.select({
id: aiNewsItems.id,
date: aiNewsItems.date,
title: aiNewsItems.title,
url: aiNewsItems.url,
note: aiNewsItems.note,
section: aiNewsItems.section
})
.from(aiNewsItems)
.where(gte(aiNewsItems.date, since))
.orderBy(desc(aiNewsItems.date), asc(aiNewsItems.id))
.limit(1000);
}
export async function loadBots() {
const rows = await db
.select({
name: aiBotStatus.name,
focus: aiBotStatus.focus,
recent: aiBotStatus.recent,
lastActive: aiBotStatus.lastActive,
updatedAt: aiBotStatus.updatedAt
})
.from(aiBotStatus)
.orderBy(desc(aiBotStatus.lastActive), asc(aiBotStatus.name));
return rows.map((r) => ({
...r,
lastActive: r.lastActive?.toISOString() ?? null,
updatedAt: r.updatedAt.toISOString()
}));
}
export async function loadUsage() {
return db
.select({
id: aiUsageEntries.id,
provider: aiUsageEntries.provider,
date: aiUsageEntries.date,
metric: aiUsageEntries.metric,
value: aiUsageEntries.value,
unit: aiUsageEntries.unit,
source: aiUsageEntries.source,
note: aiUsageEntries.note
})
.from(aiUsageEntries)
.orderBy(desc(aiUsageEntries.date), asc(aiUsageEntries.provider), asc(aiUsageEntries.metric))
.limit(5000);
}
type Result = { ok: true } | { ok: false; message: string };
export async function addUsage(form: FormData): Promise<Result> {
const parsed = usageInput.safeParse(Object.fromEntries(form));
if (!parsed.success) return { ok: false, message: parsed.error.issues[0]?.message ?? 'Invalid' };
const e = parsed.data;
await db
.insert(aiUsageEntries)
.values(e)
.onConflictDoUpdate({
target: [
aiUsageEntries.provider,
aiUsageEntries.metric,
aiUsageEntries.date,
aiUsageEntries.source
],
set: { value: e.value, unit: e.unit, note: e.note }
});
return { ok: true };
}
export async function deleteUsage(form: FormData): Promise<Result> {
const id = Number(form.get('id'));
if (!Number.isInteger(id) || id <= 0) return { ok: false, message: 'Missing id' };
await db.delete(aiUsageEntries).where(eq(aiUsageEntries.id, id));
return { ok: true };
}

View File

@@ -0,0 +1,84 @@
import { sql } from 'drizzle-orm';
import { check, date, doublePrecision, index, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { aiSchema } from './schemas';
import { id, timestamps, timestamptz } from './helpers';
// The AI tracker (/trackers/ai). No live tracking: rows arrive through the idempotent
// import scripts (scripts/ai-news-import.ts, scripts/ai-bots-import.ts) that Daisy runs on
// the server, plus the owner's manual usage form. News is public; bot_status and
// usage_entries are owner-only (checked in the server loads and actions). Like tasks and
// health, the schema is granted to the app role only (no personal_ro).
export const AI_NEWS_SECTIONS = ['ai', 'software'] as const;
export type AiNewsSection = (typeof AI_NEWS_SECTIONS)[number];
export const AI_PROVIDERS = ['claude', 'chatgpt', 'gemini', 'api'] as const;
export type AiProvider = (typeof AI_PROVIDERS)[number];
/** scheduled = a scheduled task in the provider app; ccusage = ccusage on the server. */
export const AI_USAGE_SOURCES = ['scheduled', 'ccusage', 'export', 'manual'] as const;
export type AiUsageSource = (typeof AI_USAGE_SOURCES)[number];
/** Digest items from Ilya's weekday AI/software digest; `url` is the import key. */
export const aiNewsItems = aiSchema.table(
'news_items',
{
id: id(),
date: date('date').notNull(),
title: text('title').notNull(),
url: text('url').notNull(),
note: text('note'),
section: text('section').notNull().default('ai'),
...timestamps
},
(t) => [
uniqueIndex('news_items_url_key').on(t.url),
index('news_items_date_idx').on(t.date),
check('news_items_section_check', sql`${t.section} IN ('ai','software')`),
check('news_items_title_check', sql`length(btrim(${t.title})) > 0`)
]
);
/** One row per Grok Bot agent: short neutral summaries only, never raw transcripts. */
export const aiBotStatus = aiSchema.table(
'bot_status',
{
id: id(),
name: text('name').notNull(),
focus: text('focus'),
recent: text('recent'),
lastActive: timestamptz('last_active'),
...timestamps
},
(t) => [
uniqueIndex('bot_status_name_key').on(t.name),
check('bot_status_name_check', sql`length(btrim(${t.name})) > 0`)
]
);
/** Dated usage numbers per provider and metric (one value per day, metric and source). */
export const aiUsageEntries = aiSchema.table(
'usage_entries',
{
id: id(),
provider: text('provider').notNull(),
date: date('date').notNull(),
metric: text('metric').notNull(),
value: doublePrecision('value').notNull(),
unit: text('unit'),
source: text('source').notNull().default('manual'),
note: text('note'),
...timestamps
},
(t) => [
uniqueIndex('usage_entries_day_key').on(t.provider, t.metric, t.date, t.source),
index('usage_entries_provider_idx').on(t.provider, t.metric, t.date),
check(
'usage_entries_provider_check',
sql`${t.provider} IN ('claude','chatgpt','gemini','api')`
),
check(
'usage_entries_source_check',
sql`${t.source} IN ('scheduled','ccusage','export','manual')`
),
check('usage_entries_metric_check', sql`length(btrim(${t.metric})) > 0`)
]
);

View File

@@ -28,3 +28,4 @@ export * from './hardware';
export * from './dataholders'; export * from './dataholders';
export * from './stats'; export * from './stats';
export * from './pubg-map'; export * from './pubg-map';
export * from './ai';

View File

@@ -25,3 +25,4 @@ export const wildlifeSchema = pgSchema('wildlife');
export const trainsSchema = pgSchema('trains'); export const trainsSchema = pgSchema('trains');
export const dataholdersSchema = pgSchema('dataholders'); export const dataholdersSchema = pgSchema('dataholders');
export const statsSchema = pgSchema('stats'); export const statsSchema = pgSchema('stats');
export const aiSchema = pgSchema('ai');

View File

@@ -27,6 +27,9 @@ export const ownerOnlyPrefixes = [
'/trackers/houses', '/trackers/houses',
'/trackers/data-holders', '/trackers/data-holders',
'/trackers/books/files', '/trackers/books/files',
// AI tracker: the news tab (/trackers/ai) is public, bots and usage are mine only.
'/trackers/ai/bots',
'/trackers/ai/usage',
'/lists/db', '/lists/db',
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else). // Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
'/api/spotify' '/api/spotify'
@@ -55,7 +58,8 @@ export const ownerWritePrefixes = [
'/trackers/trains', '/trackers/trains',
'/trackers/wildlife', '/trackers/wildlife',
'/trackers/music', '/trackers/music',
'/trackers/pc' '/trackers/pc',
'/trackers/ai'
] as const; ] as const;
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, ''); const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');

View File

@@ -74,6 +74,16 @@ export const trackerIcons: Record<string, TrackerIcon> = {
ink: WHITE, ink: WHITE,
paths: ['M4 4h16v11H4z', 'M9 19h6M12 15v4', 'M8 8h3v3H8z', 'M14 8.5h2M14 11h2'] paths: ['M4 4h16v11H4z', 'M9 19h6M12 15v4', 'M8 8h3v3H8z', 'M14 8.5h2M14 11h2']
}, },
ai: {
from: '#fda4af',
to: '#8b5cf6',
ink: WHITE,
// sparkles
paths: [
'M9.94 15.5A2 2 0 0 0 8.5 14.06l-6.14-1.58a.5.5 0 0 1 0-.96L8.5 9.94A2 2 0 0 0 9.94 8.5l1.58-6.14a.5.5 0 0 1 .96 0L14.06 8.5a2 2 0 0 0 1.44 1.44l6.14 1.58a.5.5 0 0 1 0 .96l-6.14 1.58a2 2 0 0 0-1.44 1.44l-1.58 6.14a.5.5 0 0 1-.96 0z',
'M20 3v4M22 5h-4M4 17v2M5 18H3'
]
},
health: { health: {
from: '#f472b6', from: '#f472b6',
to: '#db2777', to: '#db2777',

View File

@@ -27,6 +27,7 @@ const staticPaths = [
'/stats/countries/USA', '/stats/countries/USA',
'/stats/countries/KOR', '/stats/countries/KOR',
'/trackers/books', '/trackers/books',
'/trackers/ai',
'/trackers/spotting', '/trackers/spotting',
'/trackers/cars', '/trackers/cars',
'/trackers/cars/brands', '/trackers/cars/brands',

View File

@@ -0,0 +1,10 @@
import { isOwner } from '#lib/server/owner.js';
import type { LayoutServerLoad } from './$types';
// /trackers/ai: the news tab is public; bots and usage are owner-only (their own loads call
// requireOwner, and hooks.server.ts 404s /trackers/ai/bots and /trackers/ai/usage as a backstop).
export const load: LayoutServerLoad = (event) => {
const owner = isOwner(event);
if (owner) event.setHeaders({ 'cache-control': 'private, no-store' });
return { owner };
};

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import PrivateLock from '#lib/components/PrivateLock.svelte';
import Newspaper from 'lucide-svelte/icons/newspaper';
import Bot from 'lucide-svelte/icons/bot';
import Gauge from 'lucide-svelte/icons/gauge';
let { data, children } = $props();
const tabs = $derived([
{ name: 'news', href: '/trackers/ai', icon: Newspaper, locked: false },
...(data.owner
? [
{ name: 'bots', href: '/trackers/ai/bots', icon: Bot, locked: true },
{ name: 'usage', href: '/trackers/ai/usage', icon: Gauge, locked: true }
]
: [])
]);
</script>
<div class="ai-page">
<header class="ai-head">
<TrackerIcon name="ai" class="size-10" />
<div>
<h1>ai</h1>
<p>AI and software news, plus what my bots are doing and how much I use each model.</p>
</div>
</header>
<nav class="ai-tabs" aria-label="AI tracker sections">
{#each tabs as tab (tab.href)}
{@const active = page.url.pathname === tab.href}
<a
href={resolve(tab.href as '/trackers/ai')}
class={{ active }}
aria-current={active ? 'page' : undefined}
><span class="tab-icon tab-{tab.name}"><tab.icon size={14} /></span
>{tab.name}{#if tab.locked}<PrivateLock size={11} />{/if}</a
>
{/each}
</nav>
{@render children()}
</div>
<style>
.ai-page {
max-width: 72rem;
margin: 0 auto;
padding: 2rem 1rem 3rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.ai-head {
display: flex;
align-items: center;
gap: 0.85rem;
}
.ai-head h1 {
font-size: 2rem;
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
}
.ai-head p {
color: var(--site-muted);
font-size: 0.9rem;
}
.ai-tabs {
display: flex;
gap: 0.35rem;
overflow-x: auto;
scrollbar-width: none;
border-bottom: 1px solid var(--site-border);
padding-bottom: 0.6rem;
}
.ai-tabs a {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.75rem 0.35rem 0.4rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.85rem;
color: var(--site-muted);
white-space: nowrap;
text-decoration: none;
transition:
color 0.15s,
border-color 0.15s,
background 0.15s;
}
.ai-tabs a:hover {
color: var(--site-text);
border-color: var(--site-muted);
}
.ai-tabs a.active {
background: var(--site-text);
border-color: var(--site-text);
color: var(--site-bg);
}
.tab-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 999px;
color: #fff;
}
.tab-news {
background: linear-gradient(135deg, #38bdf8, #2563eb);
}
.tab-bots {
background: linear-gradient(135deg, #fb923c, #db2777);
}
.tab-usage {
background: linear-gradient(135deg, #34d399, #0d9488);
}
</style>

View File

@@ -0,0 +1,15 @@
import { loadNews } from '#lib/server/ai-tracker.js';
import type { PageServerLoad } from './$types';
// Public: the digest news from Ilya's weekday AI/software digest.
export const load: PageServerLoad = async () => {
const news = await loadNews(365);
return {
news,
seo: {
title: 'AI tracker',
description:
'A daily digest of AI and software news: model releases, research, tools and the software world, one line each.'
}
};
};

View File

@@ -0,0 +1,233 @@
<script lang="ts">
import ArrowUpRight from 'lucide-svelte/icons/arrow-up-right';
import Cpu from 'lucide-svelte/icons/cpu';
import Code from 'lucide-svelte/icons/code';
import Layers from 'lucide-svelte/icons/layers';
let { data } = $props();
type Filter = 'all' | 'ai' | 'software';
let filter = $state<Filter>('all');
const filters = [
{ key: 'all', label: 'all', icon: Layers },
{ key: 'ai', label: 'ai', icon: Cpu },
{ key: 'software', label: 'software', icon: Code }
] as const;
const counts = $derived({
all: data.news.length,
ai: data.news.filter((n) => n.section === 'ai').length,
software: data.news.filter((n) => n.section === 'software').length
});
const days = $derived.by(() => {
const groups: { date: string; items: typeof data.news }[] = [];
for (const item of data.news) {
if (filter !== 'all' && item.section !== filter) continue;
const last = groups.at(-1);
if (last?.date === item.date) last.items.push(item);
else groups.push({ date: item.date, items: [item] });
}
return groups;
});
const dayLabel = (iso: string) =>
new Date(`${iso}T12:00:00Z`).toLocaleDateString('en-GB', {
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric',
timeZone: 'Europe/Amsterdam'
});
const host = (url: string) => {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return '';
}
};
</script>
<div class="filters" role="group" aria-label="Filter by section">
{#each filters as f (f.key)}
<button
type="button"
class={['chip', `chip-${f.key}`, { on: filter === f.key }]}
aria-pressed={filter === f.key}
onclick={() => (filter = f.key)}
><span class="dot"><f.icon size={12} /></span>{f.label}<span class="count"
>{counts[f.key]}</span
></button
>
{/each}
</div>
{#if !days.length}
<p class="empty">No news yet. The digest lands here every weekday.</p>
{:else}
<div class="days">
{#each days as day (day.date)}
<section class="day" aria-label={dayLabel(day.date)}>
<h2><time datetime={day.date}>{dayLabel(day.date)}</time></h2>
<ul>
{#each day.items as item (item.id)}
<li>
<a class="item" href={item.url} target="_blank" rel="noopener noreferrer">
<span class={['badge', `badge-${item.section}`]}
>{#if item.section === 'software'}<Code size={13} />{:else}<Cpu
size={13}
/>{/if}</span
>
<span class="body">
<span class="title">{item.title}</span>
{#if item.note}<span class="note">{item.note}</span>{/if}
<span class="host">{host(item.url)}</span>
</span>
<ArrowUpRight size={16} class="arrow" aria-hidden="true" />
</a>
</li>
{/each}
</ul>
</section>
{/each}
</div>
{/if}
<style>
.filters {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.6rem 0.25rem 0.3rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.8rem;
color: var(--site-muted);
background: transparent;
cursor: pointer;
transition:
color 0.15s,
border-color 0.15s;
}
.chip:hover {
color: var(--site-text);
border-color: var(--site-muted);
}
.chip.on {
color: var(--site-text);
border-color: var(--site-text);
}
.dot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 999px;
color: #fff;
}
.chip-all .dot {
background: linear-gradient(135deg, #a1a1aa, #52525b);
}
.chip-ai .dot,
.badge-ai {
background: linear-gradient(135deg, #fda4af, #8b5cf6);
}
.chip-software .dot,
.badge-software {
background: linear-gradient(135deg, #5eead4, #0284c7);
}
.count {
font-variant-numeric: tabular-nums;
opacity: 0.6;
}
.empty {
color: var(--site-muted);
padding: 2rem 0;
}
.days {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.day h2 {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--site-muted);
margin-bottom: 0.5rem;
}
.day ul {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
}
.item {
display: flex;
align-items: flex-start;
gap: 0.7rem;
height: 100%;
padding: 0.7rem 0.8rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
color: var(--site-text);
text-decoration: none;
transition:
border-color 0.15s,
transform 0.15s,
box-shadow 0.15s;
}
.item:hover {
border-color: var(--site-muted);
transform: translateY(-1px);
box-shadow: 0 6px 20px -12px rgb(0 0 0 / 0.5);
}
.item :global(.arrow) {
flex-shrink: 0;
color: var(--site-muted);
opacity: 0;
transition: opacity 0.15s;
}
.item:hover :global(.arrow) {
opacity: 1;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.5rem;
color: #fff;
}
.body {
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
flex: 1;
}
.title {
font-weight: 550;
font-size: 0.92rem;
line-height: 1.3;
}
.note {
font-size: 0.83rem;
color: var(--site-muted);
line-height: 1.35;
}
.host {
font-size: 0.72rem;
color: var(--site-muted);
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,14 @@
import { requireOwner } from '#lib/server/owner.js';
import { loadBots } from '#lib/server/ai-tracker.js';
import type { PageServerLoad } from './$types';
// Owner only: one row per Grok Bot agent, short summaries written by Daisy (no transcripts).
export const load: PageServerLoad = async (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {
bots: await loadBots(),
now: Date.now(),
seo: { title: 'AI tracker · bots', description: 'My bots.', index: false }
};
};

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { relativeTime } from '#lib/relative-time.js';
import Bot from 'lucide-svelte/icons/bot';
let { data } = $props();
// Stable accent per bot name, so each row gets its own colourful avatar.
const HUES = [
['#60a5fa', '#2563eb'],
['#f472b6', '#db2777'],
['#34d399', '#059669'],
['#fb923c', '#ea580c'],
['#a78bfa', '#7c3aed'],
['#22d3ee', '#0891b2'],
['#facc15', '#ca8a04'],
['#f87171', '#dc2626']
];
const hue = (name: string) => {
let h = 0;
for (const ch of name) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
return HUES[h % HUES.length];
};
const stamp = (iso: string) =>
new Date(iso).toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Amsterdam'
});
</script>
{#if !data.bots.length}
<p class="empty">No bots yet. Daisy fills this from short daily summaries.</p>
{:else}
<p class="meta">{data.bots.length} bots · summaries only, never transcripts</p>
<ul class="bots">
{#each data.bots as bot (bot.name)}
{@const [from, to] = hue(bot.name)}
<li class="bot">
<span class="avatar" style:--from={from} style:--to={to}><Bot size={16} /></span>
<div class="body">
<div class="top">
<h2>{bot.name}</h2>
{#if bot.lastActive}
<time datetime={bot.lastActive} title={`${stamp(bot.lastActive)} (Amsterdam)`}
>{relativeTime(bot.lastActive, data.now)}</time
>
{/if}
</div>
{#if bot.focus}<p class="focus">{bot.focus}</p>{/if}
{#if bot.recent}<p class="recent">{bot.recent}</p>{/if}
</div>
</li>
{/each}
</ul>
{/if}
<style>
.empty,
.meta {
color: var(--site-muted);
font-size: 0.85rem;
}
.empty {
padding: 2rem 0;
}
.bots {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 22rem), 1fr));
}
.bot {
display: flex;
gap: 0.7rem;
padding: 0.75rem 0.85rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
transition: border-color 0.15s;
}
.bot:hover {
border-color: var(--site-muted);
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 0.6rem;
color: #fff;
background: linear-gradient(135deg, var(--from), var(--to));
}
.body {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.top {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
}
h2 {
font-weight: 600;
font-size: 0.95rem;
}
time {
font-size: 0.72rem;
color: var(--site-muted);
white-space: nowrap;
}
.focus {
font-size: 0.85rem;
line-height: 1.35;
}
.recent {
font-size: 0.8rem;
color: var(--site-muted);
line-height: 1.35;
}
</style>

View File

@@ -0,0 +1,28 @@
import { requireOwner } from '#lib/server/owner.js';
import { addUsage, deleteUsage, loadUsage } from '#lib/server/ai-tracker.js';
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
// Owner only: dated usage numbers per provider. Actions don't run the load, so each
// action checks the owner again (hooks.server.ts also 404s this path for everyone else).
export const load: PageServerLoad = async (event) => {
requireOwner(event);
event.setHeaders({ 'cache-control': 'private, no-store' });
return {
entries: await loadUsage(),
seo: { title: 'AI tracker · usage', description: 'My AI usage.', index: false }
};
};
export const actions: Actions = {
add: async (event) => {
requireOwner(event);
const r = await addUsage(await event.request.formData());
return r.ok ? { saved: true } : fail(400, { message: r.message });
},
delete: async (event) => {
requireOwner(event);
const r = await deleteUsage(await event.request.formData());
return r.ok ? { deleted: true } : fail(400, { message: r.message });
}
};

View File

@@ -0,0 +1,334 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Plus from 'lucide-svelte/icons/plus';
import Trash2 from 'lucide-svelte/icons/trash-2';
let { data, form } = $props();
const PROVIDERS = [
{ key: 'claude', label: 'Claude', from: '#fdba74', to: '#c2410c' },
{ key: 'chatgpt', label: 'ChatGPT', from: '#6ee7b7', to: '#047857' },
{ key: 'gemini', label: 'Gemini', from: '#93c5fd', to: '#6d28d9' },
{ key: 'api', label: 'API keys', from: '#d4d4d8', to: '#3f3f46' }
] as const;
const SOURCES = [
{ key: 'manual', label: 'manual' },
{ key: 'scheduled', label: 'scheduled task in the app' },
{ key: 'ccusage', label: 'ccusage on the server' },
{ key: 'export', label: 'data export' }
] as const;
const today = new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Amsterdam' });
let busy = $state(false);
type Entry = (typeof data.entries)[number];
/** provider → metric → entries (newest first, as loaded). */
const byProvider = $derived.by(() => {
const out: Record<string, Record<string, Entry[]>> = {};
for (const e of data.entries) ((out[e.provider] ??= {})[e.metric] ??= []).push(e);
return out;
});
const knownMetrics = $derived([...new Set(data.entries.map((e) => e.metric))].sort());
const W = 240;
const H = 56;
/** Oldest → newest points (summed per day across sources) for a small line chart. */
function chart(list: Entry[]) {
const perDay: Record<string, number> = {};
for (const e of list) perDay[e.date] = (perDay[e.date] ?? 0) + e.value;
const pts = Object.entries(perDay).sort(([a], [b]) => a.localeCompare(b));
const t = pts.map(([d]) => Date.parse(d));
const v = pts.map(([, n]) => n);
const t0 = Math.min(...t);
const t1 = Math.max(...t);
const lo = Math.min(0, ...v);
const hi = Math.max(...v, lo + 1);
const xy = pts.map((_, i) => [
t1 === t0 ? W / 2 : ((t[i] - t0) / (t1 - t0)) * (W - 8) + 4,
H - 4 - ((v[i] - lo) / (hi - lo)) * (H - 8)
]);
return {
line: xy.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' '),
dots: xy,
first: pts[0]?.[0],
last: pts.at(-1)?.[0],
latest: v.at(-1) ?? 0,
max: Math.max(...v)
};
}
const fmt = (n: number) =>
n.toLocaleString('en-GB', { maximumFractionDigits: n < 10 ? 2 : n < 1000 ? 1 : 0 });
const short = (iso?: string) =>
iso
? new Date(`${iso}T12:00:00Z`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
: '';
</script>
<form
class="add"
method="POST"
action="?/add"
use:enhance={() => {
busy = true;
return async ({ update }) => {
await update({ reset: false });
busy = false;
};
}}
>
<label
>provider<select name="provider" required>
{#each PROVIDERS as p (p.key)}<option value={p.key}>{p.label}</option>{/each}
</select></label
>
<label>date<input name="date" type="date" value={today} required /></label>
<label
>metric<input
name="metric"
list="ai-metrics"
placeholder="messages, tokens, cost…"
maxlength="60"
required
/></label
>
<datalist id="ai-metrics">
{#each knownMetrics as m (m)}<option value={m}></option>{/each}
</datalist>
<label>value<input name="value" type="number" step="any" required /></label>
<label>unit<input name="unit" placeholder="msgs, USD, M tokens" maxlength="30" /></label>
<label
>source<select name="source">
{#each SOURCES as s (s.key)}<option value={s.key}>{s.label}</option>{/each}
</select></label
>
<label class="wide">note<input name="note" maxlength="300" placeholder="optional" /></label>
<button type="submit" disabled={busy}><Plus size={15} />add</button>
{#if form?.message}<p class="error" role="alert">{form.message}</p>{/if}
</form>
<p class="hint">Same provider, metric, date and source overwrites the earlier value.</p>
<div class="providers">
{#each PROVIDERS as p (p.key)}
{@const metrics = byProvider[p.key]}
<section class="provider" style:--from={p.from} style:--to={p.to}>
<h2><span class="swatch"></span>{p.label}</h2>
{#if !metrics}
<p class="none">No entries yet.</p>
{:else}
{#each Object.entries(metrics) as [metric, list] (metric)}
{@const c = chart(list)}
<div class="metric">
<div class="mhead">
<span class="mname">{metric}</span>
<span class="mval">{fmt(c.latest)} <small>{list[0].unit ?? ''}</small></span>
</div>
<svg viewBox="0 0 {W} {H}" role="img" aria-label="{p.label} {metric} over time">
<polyline points={c.line} fill="none" stroke="url(#g-{p.key})" stroke-width="2" />
{#each c.dots as [x, y], i (i)}<circle cx={x} cy={y} r="2.2" fill={p.to} />{/each}
<defs>
<linearGradient id="g-{p.key}" x1="0" x2="1">
<stop offset="0" stop-color={p.from} /><stop offset="1" stop-color={p.to} />
</linearGradient>
</defs>
</svg>
<div class="axis">
<span>{short(c.first)}</span><span>max {fmt(c.max)}</span><span>{short(c.last)}</span>
</div>
<details>
<summary>{list.length} entries</summary>
<ul class="rows">
{#each list as e (e.id)}
<li>
<span class="d">{e.date}</span>
<span class="v">{fmt(e.value)} {e.unit ?? ''}</span>
<span class="s">{e.source}</span>
{#if e.note}<span class="n" title={e.note}>{e.note}</span>{/if}
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={e.id} />
<button type="submit" class="del" aria-label="delete entry" title="delete"
><Trash2 size={13} /></button
>
</form>
</li>
{/each}
</ul>
</details>
</div>
{/each}
{/if}
</section>
{/each}
</div>
<style>
.add {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.5rem;
align-items: end;
padding: 0.8rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
}
.add label {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
}
.add .wide {
grid-column: span 2;
}
.add input,
.add select {
font-size: 0.85rem;
text-transform: none;
letter-spacing: normal;
color: var(--site-text);
background: var(--site-bg);
border: 1px solid var(--site-border);
border-radius: 0.5rem;
padding: 0.35rem 0.5rem;
min-width: 0;
}
.add button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.4rem 0.8rem;
border-radius: 0.5rem;
background: var(--site-text);
color: var(--site-bg);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.add button:hover {
opacity: 0.85;
}
.add button:disabled {
opacity: 0.5;
}
.error {
grid-column: 1 / -1;
color: #f87171;
font-size: 0.8rem;
}
.hint {
font-size: 0.75rem;
color: var(--site-muted);
margin-top: -0.6rem;
}
.providers {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
}
.provider {
padding: 0.8rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
display: flex;
flex-direction: column;
gap: 0.75rem;
transition: border-color 0.15s;
}
.provider:hover {
border-color: var(--site-muted);
}
h2 {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
}
.swatch {
width: 1rem;
height: 1rem;
border-radius: 0.3rem;
background: linear-gradient(135deg, var(--from), var(--to));
}
.none {
font-size: 0.8rem;
color: var(--site-muted);
}
.metric svg {
width: 100%;
height: 56px;
overflow: visible;
}
.mhead {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
}
.mname {
color: var(--site-muted);
}
.mval {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.mval small {
font-weight: 400;
color: var(--site-muted);
}
.axis {
display: flex;
justify-content: space-between;
font-size: 0.68rem;
color: var(--site-muted);
}
details {
font-size: 0.78rem;
margin-top: 0.3rem;
}
summary {
cursor: pointer;
color: var(--site-muted);
}
.rows {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-top: 0.3rem;
}
.rows li {
display: flex;
align-items: center;
gap: 0.5rem;
font-variant-numeric: tabular-nums;
}
.rows .s,
.rows .n {
color: var(--site-muted);
}
.rows .n {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.rows form {
margin-left: auto;
}
.del {
display: inline-flex;
padding: 0.2rem;
border-radius: 0.35rem;
color: var(--site-muted);
cursor: pointer;
}
.del:hover {
color: #f87171;
background: rgb(248 113 113 / 0.1);
}
</style>