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
Some checks failed
Deploy production / deploy (push) Has been cancelled
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
76
drizzle/20261025235015_ai_tracker/migration.sql
Normal file
76
drizzle/20261025235015_ai_tracker/migration.sql
Normal 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 $$;
|
||||||
37450
drizzle/20261025235015_ai_tracker/snapshot.json
Normal file
37450
drizzle/20261025235015_ai_tracker/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
41
scripts/ai-bots-import.ts
Normal file
41
scripts/ai-bots-import.ts
Normal 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
43
scripts/ai-news-import.ts
Normal 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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
38
src/lib/server/ai-import.test.ts
Normal file
38
src/lib/server/ai-import.test.ts
Normal 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
104
src/lib/server/ai-import.ts
Normal 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);
|
||||||
96
src/lib/server/ai-tracker.ts
Normal file
96
src/lib/server/ai-tracker.ts
Normal 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 };
|
||||||
|
}
|
||||||
84
src/lib/server/db/schema/ai.ts
Normal file
84
src/lib/server/db/schema/ai.ts
Normal 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`)
|
||||||
|
]
|
||||||
|
);
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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(/\/+$/, '');
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
10
src/routes/trackers/ai/+layout.server.ts
Normal file
10
src/routes/trackers/ai/+layout.server.ts
Normal 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 };
|
||||||
|
};
|
||||||
121
src/routes/trackers/ai/+layout.svelte
Normal file
121
src/routes/trackers/ai/+layout.svelte
Normal 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>
|
||||||
15
src/routes/trackers/ai/+page.server.ts
Normal file
15
src/routes/trackers/ai/+page.server.ts
Normal 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.'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
233
src/routes/trackers/ai/+page.svelte
Normal file
233
src/routes/trackers/ai/+page.svelte
Normal 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>
|
||||||
14
src/routes/trackers/ai/bots/+page.server.ts
Normal file
14
src/routes/trackers/ai/bots/+page.server.ts
Normal 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 }
|
||||||
|
};
|
||||||
|
};
|
||||||
127
src/routes/trackers/ai/bots/+page.svelte
Normal file
127
src/routes/trackers/ai/bots/+page.svelte
Normal 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>
|
||||||
28
src/routes/trackers/ai/usage/+page.server.ts
Normal file
28
src/routes/trackers/ai/usage/+page.server.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
334
src/routes/trackers/ai/usage/+page.svelte
Normal file
334
src/routes/trackers/ai/usage/+page.svelte
Normal 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>
|
||||||
Reference in New Issue
Block a user