experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(items): add event item type (foundations)

Adds 'event' to the items.type allowlist (schema CHECK + ItemType
union + datastore migration for existing databases) and gives it a
calendar emoji icon in card-helpers' TYPE_ICONS. getItemDisplayInfo
recognises type=event and surfaces metadata.startsAt as the subtitle
when present, falling back to "Event". ICS generation and the cal
widget land as separate slices on top of this.

+169 -7
+9
app/lib/card-helpers.js
··· 20 20 entity: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F48E}</text></svg>', 21 21 feed: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F4E1}</text></svg>', 22 22 series: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F4DA}</text></svg>', 23 + event: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">\u{1F4C5}</text></svg>', 23 24 unknown: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">?</text></svg>' 24 25 }; 25 26 ··· 265 266 title = item.title || item.content || 'Series'; 266 267 subtitle = 'Series'; 267 268 faviconUrl = TYPE_ICONS.series; 269 + } else if (itemType === 'event') { 270 + title = item.title || item.content || 'Event'; 271 + let eventMeta = {}; 272 + try { eventMeta = JSON.parse(item.metadata || '{}'); } catch {} 273 + subtitle = eventMeta.startsAt 274 + ? new Date(eventMeta.startsAt).toLocaleString() 275 + : 'Event'; 276 + faviconUrl = TYPE_ICONS.event; 268 277 } 269 278 } 270 279
+81
backend/electron/datastore.ts
··· 382 382 migrateTagsLastUsedColumn(); 383 383 migrateContextHistory(); 384 384 migrateEntityType(); 385 + migrateEventType(); 385 386 migrateStandalonePriceEntities(); 386 387 migrateStandaloneDateEntities(); 387 388 migrateNoisyDateEntities(); ··· 1571 1572 WHERE content LIKE 'http%'`); 1572 1573 } catch (error) { 1573 1574 DEBUG && console.log('main', `Entity indexes:`, (error as Error).message); 1575 + } 1576 + } 1577 + 1578 + /** 1579 + * Add 'event' to items type CHECK constraint for existing databases. 1580 + * Mirrors migrateEntityType — SQLite can't ALTER a CHECK, so we recreate. 1581 + */ 1582 + function migrateEventType(): void { 1583 + if (!db) return; 1584 + 1585 + const tableExists = db.prepare( 1586 + `SELECT name FROM sqlite_master WHERE type='table' AND name='items'` 1587 + ).get(); 1588 + if (!tableExists) return; 1589 + 1590 + const tableSchema = db.prepare( 1591 + `SELECT sql FROM sqlite_master WHERE type='table' AND name='items'` 1592 + ).get() as { sql: string } | undefined; 1593 + if (!tableSchema) return; 1594 + 1595 + if (tableSchema.sql.includes("'event'")) return; 1596 + 1597 + DEBUG && console.log('main', "Adding 'event' to items CHECK constraint"); 1598 + 1599 + const columns = db.prepare(`PRAGMA table_info(items)`).all() as { name: string }[]; 1600 + const columnNames = columns.map(c => c.name); 1601 + 1602 + db.pragma('foreign_keys = OFF'); 1603 + try { 1604 + db.exec(`DROP TABLE IF EXISTS items_event_mig`); 1605 + 1606 + db.exec(` 1607 + CREATE TABLE IF NOT EXISTS items_event_mig ( 1608 + id TEXT PRIMARY KEY, 1609 + type TEXT NOT NULL CHECK(type IN ('url', 'text', 'tagset', 'image', 'series', 'feed', 'entity', 'event')), 1610 + content TEXT, 1611 + mimeType TEXT DEFAULT '', 1612 + metadata TEXT DEFAULT '{}', 1613 + syncId TEXT DEFAULT '', 1614 + syncedAt INTEGER DEFAULT 0, 1615 + createdAt INTEGER NOT NULL, 1616 + updatedAt INTEGER NOT NULL, 1617 + deletedAt INTEGER DEFAULT 0, 1618 + starred INTEGER DEFAULT 0, 1619 + archived INTEGER DEFAULT 0, 1620 + visitCount INTEGER DEFAULT 0, 1621 + lastVisitAt INTEGER DEFAULT 0, 1622 + frecencyScore INTEGER DEFAULT 0, 1623 + title TEXT DEFAULT '', 1624 + domain TEXT DEFAULT '', 1625 + favicon TEXT DEFAULT '', 1626 + thumbnail TEXT DEFAULT '' 1627 + ) 1628 + `); 1629 + 1630 + const migColumns = ['id', 'type', 'content', 'mimeType', 'metadata', 'syncId', 'syncedAt', 1631 + 'createdAt', 'updatedAt', 'deletedAt', 'starred', 'archived', 'visitCount', 'lastVisitAt', 1632 + 'frecencyScore', 'title', 'domain', 'favicon', 'thumbnail']; 1633 + const existingCols = migColumns.filter(c => columnNames.includes(c)); 1634 + const colList = existingCols.join(', '); 1635 + 1636 + db.exec(`INSERT INTO items_event_mig (${colList}) SELECT ${colList} FROM items`); 1637 + db.exec(`DROP TABLE items`); 1638 + db.exec(`ALTER TABLE items_event_mig RENAME TO items`); 1639 + 1640 + db.exec(` 1641 + CREATE INDEX IF NOT EXISTS idx_items_type ON items(type); 1642 + CREATE INDEX IF NOT EXISTS idx_items_syncId ON items(syncId); 1643 + CREATE INDEX IF NOT EXISTS idx_items_deletedAt ON items(deletedAt); 1644 + CREATE INDEX IF NOT EXISTS idx_items_createdAt ON items(createdAt DESC); 1645 + CREATE INDEX IF NOT EXISTS idx_items_starred ON items(starred); 1646 + CREATE INDEX IF NOT EXISTS idx_items_lastVisitAt ON items(lastVisitAt); 1647 + CREATE INDEX IF NOT EXISTS idx_items_visitCount ON items(visitCount); 1648 + CREATE INDEX IF NOT EXISTS idx_items_frecencyScore ON items(frecencyScore DESC); 1649 + CREATE INDEX IF NOT EXISTS idx_items_domain ON items(domain); 1650 + `); 1651 + 1652 + DEBUG && console.log('main', 'Event type migration complete'); 1653 + } finally { 1654 + db.pragma('foreign_keys = ON'); 1574 1655 } 1575 1656 } 1576 1657
+2 -1
backend/types/index.ts
··· 49 49 // - series: Time-series data (numeric or text values over time) 50 50 // - feed: External feeds (RSS, ATProto, APIs, logs) 51 51 // - entity: Extracted entities (people, places, orgs, events, etc.) 52 - export type ItemType = 'url' | 'text' | 'tagset' | 'image' | 'series' | 'feed' | 'entity'; 52 + // - event: Calendar events (with start/end time, generates ICS) 53 + export type ItemType = 'url' | 'text' | 'tagset' | 'image' | 'series' | 'feed' | 'entity' | 'event'; 53 54 54 55 export interface Item { 55 56 id: string;
+1 -1
schema/generated/sqlite-full.sql
··· 5 5 -- Unified content storage - URLs, text notes, tagsets, and images 6 6 CREATE TABLE IF NOT EXISTS items ( 7 7 id TEXT PRIMARY KEY NOT NULL, 8 - type TEXT NOT NULL CHECK(type IN ('url', 'text', 'tagset', 'image', 'series', 'feed', 'entity')), 8 + type TEXT NOT NULL CHECK(type IN ('url', 'text', 'tagset', 'image', 'series', 'feed', 'entity', 'event')), 9 9 content TEXT, 10 10 mimeType TEXT DEFAULT '', 11 11 metadata TEXT DEFAULT '{}',
+1 -1
schema/generated/sqlite-sync.sql
··· 5 5 -- Unified content storage - URLs, text notes, tagsets, and images 6 6 CREATE TABLE IF NOT EXISTS items ( 7 7 id TEXT PRIMARY KEY NOT NULL, 8 - type TEXT NOT NULL CHECK(type IN ('url', 'text', 'tagset', 'image', 'series', 'feed', 'entity')), 8 + type TEXT NOT NULL CHECK(type IN ('url', 'text', 'tagset', 'image', 'series', 'feed', 'entity', 'event')), 9 9 content TEXT, 10 10 mimeType TEXT DEFAULT '', 11 11 metadata TEXT DEFAULT '{}',
+1 -1
schema/generated/types.rs
··· 9 9 pub struct SchemaItems { 10 10 /// Unique identifier (UUID or generated ID) 11 11 pub id: String, 12 - /// Content type: url, text, tagset, image, series, feed, or entity 12 + /// Content type: url, text, tagset, image, series, feed, entity, or event 13 13 pub r#type: String, 14 14 /// URL for type=url, text content for type=text, null for tagset/image 15 15 pub content: Option<String>,
+1 -1
schema/generated/types.ts
··· 8 8 export interface SchemaItems { 9 9 /** Unique identifier (UUID or generated ID) */ 10 10 id: string; 11 - /** Content type: url, text, tagset, image, series, feed, or entity */ 11 + /** Content type: url, text, tagset, image, series, feed, entity, or event */ 12 12 type: string; 13 13 /** URL for type=url, text content for type=text, null for tagset/image */ 14 14 content: string | null;
+2 -2
schema/v1.json
··· 16 16 "type": { 17 17 "type": "text", 18 18 "not_null": true, 19 - "check": "type IN ('url', 'text', 'tagset', 'image', 'series', 'feed', 'entity')", 19 + "check": "type IN ('url', 'text', 'tagset', 'image', 'series', 'feed', 'entity', 'event')", 20 20 "sync": true, 21 - "description": "Content type: url, text, tagset, image, series, feed, or entity" 21 + "description": "Content type: url, text, tagset, image, series, feed, entity, or event" 22 22 }, 23 23 "content": { 24 24 "type": "text",
+71
tests/desktop/event-item-type.spec.ts
··· 1 + /** 2 + * Regression coverage: items.type === 'event' must be accepted by the 3 + * datastore (CHECK constraint allowlist) and getItemDisplayInfo must 4 + * surface the event icon + a sensible subtitle. 5 + * 6 + * First slice of the event-type feature — this guards the foundations 7 + * (schema + icon) before ICS generation and the cal widget land. 8 + */ 9 + import { test, expect, DesktopApp } from '../fixtures/desktop-app'; 10 + import { Page } from '@playwright/test'; 11 + import { createPerDescribeApp } from '../helpers/test-app'; 12 + 13 + test.describe('event item type @desktop', () => { 14 + let app: DesktopApp; 15 + let bgWindow: Page; 16 + 17 + test.beforeAll(async () => { 18 + ({ app, bgWindow } = await createPerDescribeApp('event-item-type')); 19 + }); 20 + 21 + test.afterAll(async () => { 22 + if (app) await app.close(); 23 + }); 24 + 25 + test('addItem accepts type=event and round-trips', async () => { 26 + const startsAt = Date.now() + 86_400_000; // tomorrow 27 + const result = await bgWindow.evaluate(async (ts: number) => { 28 + return await (window as any).app.datastore.addItem('event', { 29 + content: 'Coffee with Sam', 30 + title: 'Coffee with Sam', 31 + metadata: JSON.stringify({ startsAt: ts }), 32 + }); 33 + }, startsAt); 34 + expect(result.success).toBe(true); 35 + const itemId = result.data?.id; 36 + expect(itemId).toBeTruthy(); 37 + 38 + const fetched = await bgWindow.evaluate(async (id: string) => { 39 + return await (window as any).app.datastore.getItem(id); 40 + }, itemId); 41 + expect(fetched.success).toBe(true); 42 + expect(fetched.data?.type).toBe('event'); 43 + expect(fetched.data?.content).toBe('Coffee with Sam'); 44 + }); 45 + 46 + test('getItemDisplayInfo returns event icon + start-time subtitle', async () => { 47 + const startsAt = Date.UTC(2030, 5, 1, 14, 30); // 2030-06-01T14:30Z 48 + 49 + const display = await bgWindow.evaluate(async (ts: number) => { 50 + const mod = await import('peek://app/lib/card-helpers.js'); 51 + return mod.getItemDisplayInfo({ 52 + type: 'event', 53 + content: 'Conference', 54 + title: 'Conference', 55 + metadata: JSON.stringify({ startsAt: ts }), 56 + }); 57 + }, startsAt); 58 + 59 + expect(display.itemType).toBe('event'); 60 + expect(display.title).toBe('Conference'); 61 + // Subtitle is locale-formatted; just assert it's non-empty and 62 + // not the literal 'Event' fallback (since startsAt was provided). 63 + expect(display.subtitle).toBeTruthy(); 64 + expect(display.subtitle).not.toBe('Event'); 65 + expect(display.faviconUrl).toContain('data:image/svg+xml'); 66 + // U+1F4C5 = 📅 (calendar). Embedded literally in the SVG payload. 67 + expect(display.faviconUrl).toContain('\u{1F4C5}'); 68 + // Negative check: not the unknown fallback "?". 69 + expect(display.faviconUrl).not.toMatch(/>\?</); 70 + }); 71 + });