experiments in a post-browser web

Feeds & Series API#

This document covers the APIs for time-series data and external feeds in Peek.

Overview#

Peek uses two item types for temporal data:

  • series - Scalar values over time (numeric or text)
  • feed - Entries from external sources (RSS, APIs, logs)

Both store their data in the item_events table. The item defines what you're tracking; events record when things happened.

Core Concepts#

Items = Definitions#

Items with type: 'series' or type: 'feed' define what you're tracking:

// A series tracks scalar values
const series = await api.datastore.addItem('series', {
  content: 'AAPL Stock Price',
  metadata: JSON.stringify({ unit: 'USD', source: 'yahoo-finance' })
});

// A feed tracks entries from a source
const feed = await api.datastore.addItem('feed', {
  content: 'https://example.com/rss',
  metadata: JSON.stringify({ name: 'Example Blog', format: 'rss' })
});

Events = Facts#

Events are append-only records of what happened:

// Record a series observation
await api.datastore.addItemEvent(series.data.id, {
  value: 156.32,        // numeric value
  occurredAt: Date.now()
});

// Record a feed entry
await api.datastore.addItemEvent(feed.data.id, {
  content: 'https://example.com/article-123',  // entry URL
  occurredAt: pubDate,
  metadata: JSON.stringify({ title: 'Article Title', author: 'Jane' })
});

API Reference#

Item Operations#

Standard item CRUD works for series and feeds:

// Create
const result = await api.datastore.addItem('series', { content: 'Mood', metadata: '{}' });

// Read
const item = await api.datastore.getItem(itemId);

// Update
await api.datastore.updateItem(itemId, { metadata: '{"unit":"points"}' });

// Delete (also delete events first)
await api.datastore.deleteItemEvents(itemId);
await api.datastore.deleteItem(itemId);

// Query all series
const series = await api.datastore.queryItems({ type: 'series' });

// Query all feeds
const feeds = await api.datastore.queryItems({ type: 'feed' });

Event Operations#

addItemEvent(itemId, options)#

Add an event to a series or feed.

await api.datastore.addItemEvent(itemId, {
  content: 'text value or URL',      // optional
  value: 42,                          // optional, for numeric data
  occurredAt: Date.now(),             // optional, defaults to now
  metadata: JSON.stringify({...})     // optional, JSON string
});

getItemEvent(eventId)#

Get a single event by ID.

const event = await api.datastore.getItemEvent(eventId);
// { id, itemId, content, value, occurredAt, metadata, createdAt }

queryItemEvents(filter)#

Query events with filters.

const events = await api.datastore.queryItemEvents({
  itemId: 'item_123',           // single item
  // OR
  itemIds: ['item_1', 'item_2'], // multiple items
  since: Date.now() - 86400000,  // after timestamp
  until: Date.now(),             // before timestamp
  limit: 50,                     // max results
  offset: 0,                     // skip first N
  order: 'desc'                  // 'asc' or 'desc' by occurredAt
});

deleteItemEvent(eventId)#

Delete a single event.

await api.datastore.deleteItemEvent(eventId);

deleteItemEvents(itemId)#

Delete all events for an item.

const count = await api.datastore.deleteItemEvents(itemId);

getLatestItemEvent(itemId)#

Get the most recent event.

const latest = await api.datastore.getLatestItemEvent(itemId);

countItemEvents(itemId, filter)#

Count events for an item.

const count = await api.datastore.countItemEvents(itemId, {
  since: Date.now() - 86400000  // last 24 hours
});

Use-Case Examples#

1. Stock Price Tracking#

Track numeric values over time from a web source.

// Create the series
const stock = await api.datastore.addItem('series', {
  content: 'AAPL',
  metadata: JSON.stringify({
    name: 'Apple Stock Price',
    unit: 'USD',
    source_url: 'https://finance.yahoo.com/quote/AAPL'
  })
});
const stockId = stock.data.id;

// Record observations (from polling job)
async function recordPrice(price) {
  await api.datastore.addItemEvent(stockId, {
    value: price,
    occurredAt: Date.now()
  });
}

// Query last 7 days
const prices = await api.datastore.queryItemEvents({
  itemId: stockId,
  since: Date.now() - 7 * 24 * 60 * 60 * 1000,
  order: 'asc'
});

// Calculate average
const avg = prices.data.reduce((sum, e) => sum + e.value, 0) / prices.data.length;

// Get latest price
const latest = await api.datastore.getLatestItemEvent(stockId);
console.log(`Current: $${latest.data.value}, 7-day avg: $${avg.toFixed(2)}`);

2. Mood Tracking (Text Values)#

Track non-numeric values like mood or status.

// Create the series
const mood = await api.datastore.addItem('series', {
  content: 'Mood',
  metadata: JSON.stringify({ input: 'tag-board' })
});
const moodId = mood.data.id;

// Record mood (from tag button board)
async function recordMood(moodValue) {
  await api.datastore.addItemEvent(moodId, {
    content: moodValue,  // 'great', 'good', 'meh', 'bad'
    occurredAt: Date.now()
  });
}

// Usage
await recordMood('great');
await recordMood('meh');

// Query this week's moods
const moods = await api.datastore.queryItemEvents({
  itemId: moodId,
  since: Date.now() - 7 * 24 * 60 * 60 * 1000
});

// Count by mood
const counts = {};
for (const event of moods.data) {
  counts[event.content] = (counts[event.content] || 0) + 1;
}
console.log('Mood distribution:', counts);

3. Habit Tracking with Streaks#

Track daily habits and calculate streaks.

// Create the series
const pushups = await api.datastore.addItem('series', {
  content: 'Pushups',
  metadata: JSON.stringify({ frequency: 'daily', tag: 'pushups' })
});
const pushupId = pushups.data.id;

// Record daily check-in
async function logPushups(count) {
  const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
  await api.datastore.addItemEvent(pushupId, {
    value: count,
    occurredAt: Date.now(),
    metadata: JSON.stringify({ date: today })
  });
}

// Calculate current streak
async function getStreak() {
  const events = await api.datastore.queryItemEvents({
    itemId: pushupId,
    limit: 365,
    order: 'desc'
  });

  if (!events.success || events.data.length === 0) return 0;

  let streak = 0;
  let expectedDate = new Date();
  expectedDate.setHours(0, 0, 0, 0);

  for (const event of events.data) {
    const meta = JSON.parse(event.metadata || '{}');
    const eventDate = new Date(meta.date);
    eventDate.setHours(0, 0, 0, 0);

    // Check if this is the expected date (consecutive day)
    const diffDays = Math.round((expectedDate - eventDate) / 86400000);

    if (diffDays === 0) {
      streak++;
      expectedDate.setDate(expectedDate.getDate() - 1);
    } else if (diffDays === 1) {
      // Skip today if not logged yet
      expectedDate.setDate(expectedDate.getDate() - 1);
      if (expectedDate.getTime() === eventDate.getTime()) {
        streak++;
        expectedDate.setDate(expectedDate.getDate() - 1);
      } else {
        break;
      }
    } else {
      break;
    }
  }

  return streak;
}

// Usage
await logPushups(20);
const streak = await getStreak();
console.log(`Current streak: ${streak} days`);

// Get personal best
const allEvents = await api.datastore.queryItemEvents({ itemId: pushupId });
const max = Math.max(...allEvents.data.map(e => e.value || 0));
console.log(`Personal best: ${max} pushups`);

4. Ticket Availability Trigger#

Poll a page and notify when condition is met.

// Create the series (acts as trigger definition)
const tickets = await api.datastore.addItem('series', {
  content: 'Taylor Swift Tickets',
  metadata: JSON.stringify({
    source_url: 'https://ticketmaster.com/event/123',
    selector: '.buy-button',
    condition: 'exists',
    notified: false
  })
});
const ticketId = tickets.data.id;

// Polling function (run periodically)
async function checkTickets() {
  const item = await api.datastore.getItem(ticketId);
  const meta = JSON.parse(item.data.metadata || '{}');

  if (meta.notified) return; // Already notified

  // In a real extension, you'd load the page in a hidden window
  // and run document.querySelector(meta.selector)
  const available = await checkPageForElement(meta.source_url, meta.selector);

  // Record the check
  await api.datastore.addItemEvent(ticketId, {
    value: available ? 1 : 0,
    content: available ? 'Available!' : 'Not available',
    occurredAt: Date.now()
  });

  // Trigger notification
  if (available) {
    meta.notified = true;
    await api.datastore.updateItem(ticketId, {
      metadata: JSON.stringify(meta)
    });

    // Show notification
    new Notification('Tickets Available!', {
      body: 'Taylor Swift tickets are now on sale!'
    });
  }
}

5. RSS Feed Reader#

Subscribe to and poll RSS feeds.

// Subscribe to a feed
async function subscribe(url) {
  const response = await fetch(url);
  const xml = await response.text();
  const title = parseFeedTitle(xml); // Your XML parsing function

  const feed = await api.datastore.addItem('feed', {
    content: url,
    metadata: JSON.stringify({
      name: title,
      format: 'rss',
      lastPolledAt: 0
    })
  });

  return feed.data.id;
}

// Poll a feed for new entries
async function pollFeed(feedId) {
  const item = await api.datastore.getItem(feedId);
  const feedUrl = item.data.content;

  const response = await fetch(feedUrl);
  const xml = await response.text();
  const entries = parseFeedEntries(xml); // Your XML parsing function

  // Get existing entries to dedupe
  const existing = await api.datastore.queryItemEvents({ itemId: feedId, limit: 100 });
  const existingGuids = new Set(
    existing.data.map(e => JSON.parse(e.metadata || '{}').guid)
  );

  // Add new entries
  for (const entry of entries) {
    if (existingGuids.has(entry.guid)) continue;

    await api.datastore.addItemEvent(feedId, {
      content: entry.link,
      occurredAt: entry.pubDate,
      metadata: JSON.stringify({
        guid: entry.guid,
        title: entry.title,
        author: entry.author
      })
    });
  }

  // Update last polled
  const meta = JSON.parse(item.data.metadata || '{}');
  meta.lastPolledAt = Date.now();
  await api.datastore.updateItem(feedId, { metadata: JSON.stringify(meta) });
}

// Get all entries across feeds
async function getAllEntries(limit = 50) {
  const feeds = await api.datastore.queryItems({ type: 'feed' });
  const feedIds = feeds.data.map(f => f.id);

  const entries = await api.datastore.queryItemEvents({
    itemIds: feedIds,
    limit: limit,
    order: 'desc'
  });

  return entries.data.map(e => ({
    ...e,
    meta: JSON.parse(e.metadata || '{}')
  }));
}

6. System Metrics / Action Log#

Track internal application events.

// Create a log feed
const actionLog = await api.datastore.addItem('feed', {
  content: 'peek:system:actions',
  metadata: JSON.stringify({ format: 'internal' })
});
const logId = actionLog.data.id;

// Log an action
async function logAction(action, details = {}) {
  await api.datastore.addItemEvent(logId, {
    content: action,
    occurredAt: Date.now(),
    metadata: JSON.stringify(details)
  });
}

// Usage
await logAction('page_open', { url: 'https://example.com', source: 'bookmark' });
await logAction('settings_changed', { key: 'theme', value: 'dark' });
await logAction('extension_loaded', { id: 'feeds', version: '1.0.0' });

// Query recent actions
const recent = await api.datastore.queryItemEvents({
  itemId: logId,
  since: Date.now() - 3600000, // last hour
  order: 'desc'
});

// Count actions by type
const counts = {};
for (const event of recent.data) {
  counts[event.content] = (counts[event.content] || 0) + 1;
}
console.log('Action counts:', counts);

7. Spotify Listening History#

Pull data from external APIs.

// Create the feed
const spotify = await api.datastore.addItem('feed', {
  content: 'spotify:me:recently-played',
  metadata: JSON.stringify({
    format: 'api',
    service: 'spotify',
    lastSyncedAt: 0
  })
});
const spotifyId = spotify.data.id;

// Sync from Spotify API (requires OAuth token)
async function syncSpotify(accessToken) {
  const response = await fetch('https://api.spotify.com/v1/me/player/recently-played?limit=50', {
    headers: { 'Authorization': `Bearer ${accessToken}` }
  });
  const data = await response.json();

  // Get existing to dedupe
  const existing = await api.datastore.queryItemEvents({ itemId: spotifyId, limit: 100 });
  const existingIds = new Set(
    existing.data.map(e => JSON.parse(e.metadata || '{}').playedAt)
  );

  // Add new plays
  for (const item of data.items) {
    const playedAt = new Date(item.played_at).getTime();
    if (existingIds.has(item.played_at)) continue;

    await api.datastore.addItemEvent(spotifyId, {
      content: item.track.uri,
      occurredAt: playedAt,
      metadata: JSON.stringify({
        playedAt: item.played_at,
        trackName: item.track.name,
        artistName: item.track.artists[0]?.name,
        albumName: item.track.album?.name,
        durationMs: item.track.duration_ms
      })
    });
  }

  // Update sync time
  const meta = JSON.parse((await api.datastore.getItem(spotifyId)).data.metadata || '{}');
  meta.lastSyncedAt = Date.now();
  await api.datastore.updateItem(spotifyId, { metadata: JSON.stringify(meta) });
}

// Get top artists from listening history
async function getTopArtists(days = 30) {
  const plays = await api.datastore.queryItemEvents({
    itemId: spotifyId,
    since: Date.now() - days * 24 * 60 * 60 * 1000
  });

  const artistCounts = {};
  for (const play of plays.data) {
    const meta = JSON.parse(play.metadata || '{}');
    const artist = meta.artistName || 'Unknown';
    artistCounts[artist] = (artistCounts[artist] || 0) + 1;
  }

  return Object.entries(artistCounts)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 10);
}

Schema Reference#

items table (existing, extended)#

items (
  id TEXT PRIMARY KEY,
  type TEXT NOT NULL,  -- 'url', 'text', 'tagset', 'image', 'series', 'feed'
  content TEXT,        -- identifier, URL, or name
  metadata TEXT,       -- JSON with type-specific data
  ...
)

item_events table (new)#

item_events (
  id TEXT PRIMARY KEY,
  itemId TEXT NOT NULL,     -- FK to series or feed
  content TEXT,             -- text value, URL, or message
  value REAL,               -- numeric value (optional)
  occurredAt INTEGER,       -- when it happened (Unix ms)
  metadata TEXT,            -- JSON extras
  createdAt INTEGER         -- when recorded
)

-- Indexes
CREATE INDEX idx_item_events_item_time ON item_events(itemId, occurredAt DESC);
CREATE INDEX idx_item_events_occurred ON item_events(occurredAt DESC);

Best Practices#

  1. Use value for numbers, content for text/URLs - Makes queries and aggregations simpler.

  2. Store dedup keys in metadata - For feeds, store guid to avoid duplicate entries.

  3. Set occurredAt to event time, not record time - For RSS entries, use pubDate; for API data, use the original timestamp.

  4. Keep metadata lightweight - Truncate descriptions, store only what you need for display.

  5. Delete events when deleting items - Always call deleteItemEvents(itemId) before deleteItem(itemId).

  6. Use order: 'desc' for recent-first - Most UIs want newest first.

  7. Batch queries with itemIds - When showing entries from multiple feeds, query once with all IDs.