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

@@ -346,6 +346,13 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
external: 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',
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 './stats';
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 dataholdersSchema = pgSchema('dataholders');
export const statsSchema = pgSchema('stats');
export const aiSchema = pgSchema('ai');

View File

@@ -27,6 +27,9 @@ export const ownerOnlyPrefixes = [
'/trackers/houses',
'/trackers/data-holders',
'/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',
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
'/api/spotify'
@@ -55,7 +58,8 @@ export const ownerWritePrefixes = [
'/trackers/trains',
'/trackers/wildlife',
'/trackers/music',
'/trackers/pc'
'/trackers/pc',
'/trackers/ai'
] as const;
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');

View File

@@ -74,6 +74,16 @@ export const trackerIcons: Record<string, TrackerIcon> = {
ink: WHITE,
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: {
from: '#f472b6',
to: '#db2777',