[object Object]

← back to Animals

yolo: Adoption events on /shows page

18ee2c0b370471718faca197beb6d1d678219f9f · 2026-05-08 00:47:15 -0700 · animal-yolo

Task: yolo.shelter-adoption-feed
Prompt: In ~/Projects/animals: queue a `events` table insert per CA shelter (category='shelter' or 'rescue') with kind='adoption_event' that's a recurring weekly Saturday 11am-3pm 'Open adoption hours' tied t

Files touched

Diff

commit 18ee2c0b370471718faca197beb6d1d678219f9f
Author: animal-yolo <steve@designerwallcoverings.com>
Date:   Fri May 8 00:47:15 2026 -0700

    yolo: Adoption events on /shows page
    
    Task: yolo.shelter-adoption-feed
    Prompt: In ~/Projects/animals: queue a `events` table insert per CA shelter (category='shelter' or 'rescue') with kind='adoption_event' that's a recurring weekly Saturday 11am-3pm 'Open adoption hours' tied t
---
 agents/animal-yolo/state.json                  |   5 +
 package.json                                   |   1 +
 src/scripts/seed_ca_shelter_adoption_events.js | 160 +++++++++++++++++++++++++
 src/server/render.js                           |  28 ++++-
 4 files changed, 192 insertions(+), 2 deletions(-)

diff --git a/agents/animal-yolo/state.json b/agents/animal-yolo/state.json
new file mode 100644
index 0000000..a1cbf4b
--- /dev/null
+++ b/agents/animal-yolo/state.json
@@ -0,0 +1,5 @@
+{
+  "cursor": 1,
+  "runs": 1,
+  "started_at": "2026-05-08T07:01:22.386Z"
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index 885ca1f..e31236f 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
     "research:traffic": "node src/scripts/research_traffic.js",
     "ingest:clinics:aaha": "node src/ingest/aaha_accredited.js",
     "ingest:shelters": "node src/ingest/petfinder_shelters.js",
+    "seed:ca-shelter-events": "node src/scripts/seed_ca_shelter_adoption_events.js",
     "ingest:reddit:dry": "DRY=1 LIMIT=10 node src/ingest/reddit.js",
     "ingest:nextdoor": "node src/ingest/nextdoor.js",
     "audit:websites": "node src/audit/site_audit.js",
diff --git a/src/scripts/seed_ca_shelter_adoption_events.js b/src/scripts/seed_ca_shelter_adoption_events.js
new file mode 100644
index 0000000..0a53595
--- /dev/null
+++ b/src/scripts/seed_ca_shelter_adoption_events.js
@@ -0,0 +1,160 @@
+#!/usr/bin/env node
+// Seed weekly recurring "Open adoption hours" events for every CA shelter/rescue
+// with known coordinates. The events table renders on /shows + the map view.
+//
+// Idempotency model: ONE row per shelter per calendar month. We anchor each
+// month's row to the next-upcoming Saturday at 11:00 America/Los_Angeles
+// (running into 15:00 PT). Re-running mid-month is a no-op because the
+// existence check is "any row already exists for this shelter in the same
+// PT calendar month as the target Saturday".
+//
+// Run via:  node src/scripts/seed_ca_shelter_adoption_events.js
+// Or (recommended) on the 1st of each month from cron / a launchd plist.
+//
+// The event description carries the recurrence semantics ("Every Saturday
+// 11am–3pm"). The events table itself has no recurrence column today.
+
+import 'dotenv/config';
+import { pool } from '../lib/db.js';
+import { log } from '../lib/log.js';
+
+const SOURCE = 'shelter_recurring';
+const KIND = 'adoption_event';
+const TITLE = 'Open adoption hours';
+const TZ = 'America/Los_Angeles';
+
+// Build a UTC Date such that, when rendered in America/Los_Angeles, it shows
+// the requested wall-clock time. Handles PDT/PST without a tz library.
+function ptWallClockToUtc(year, month, day, hour) {
+  let guess = new Date(Date.UTC(year, month - 1, day, hour, 0, 0));
+  for (let i = 0; i < 4; i++) {
+    const parts = new Intl.DateTimeFormat('en-US', {
+      timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
+      hour: '2-digit', hour12: false,
+    }).formatToParts(guess);
+    const obj = Object.fromEntries(parts.map(p => [p.type, p.value]));
+    const ptYear = parseInt(obj.year, 10);
+    const ptMonth = parseInt(obj.month, 10);
+    const ptDay = parseInt(obj.day, 10);
+    const ptHour = parseInt(obj.hour, 10) % 24;
+    if (ptYear === year && ptMonth === month && ptDay === day && ptHour === hour) return guess;
+    // Diff in hours from the desired wall-clock to the actual rendered wall-clock.
+    const target = Date.UTC(year, month - 1, day, hour);
+    const actual = Date.UTC(ptYear, ptMonth - 1, ptDay, ptHour);
+    guess = new Date(guess.getTime() + (target - actual));
+  }
+  return guess;
+}
+
+// "Today" in PT — used to find the next Saturday. Returns {y,m,d,dow}.
+function ptTodayParts(now = new Date()) {
+  const parts = new Intl.DateTimeFormat('en-US', {
+    timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short',
+  }).formatToParts(now);
+  const obj = Object.fromEntries(parts.map(p => [p.type, p.value]));
+  const dowMap = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
+  return {
+    y: parseInt(obj.year, 10),
+    m: parseInt(obj.month, 10),
+    d: parseInt(obj.day, 10),
+    dow: dowMap[obj.weekday],
+  };
+}
+
+// Next Saturday strictly after today (today included only if dow === 6 and we
+// want a clean answer — we go strictly forward to keep the run idempotent
+// across re-runs on the same Saturday).
+function nextSaturday(now = new Date()) {
+  const t = ptTodayParts(now);
+  const daysAhead = t.dow === 6 ? 7 : (6 - t.dow + 7) % 7 || 7;
+  // Compose a UTC date for noon PT on today, then add days; re-extract PT parts.
+  const anchor = ptWallClockToUtc(t.y, t.m, t.d, 12);
+  const target = new Date(anchor.getTime() + daysAhead * 86400 * 1000);
+  const tp = ptTodayParts(target);
+  return { y: tp.y, m: tp.m, d: tp.d };
+}
+
+async function main() {
+  const sat = nextSaturday();
+  const startUtc = ptWallClockToUtc(sat.y, sat.m, sat.d, 11);
+  const endUtc = ptWallClockToUtc(sat.y, sat.m, sat.d, 15);
+
+  log.info('seed_ca_shelter_adoption_events: target Saturday', {
+    pt_date: `${sat.y}-${String(sat.m).padStart(2, '0')}-${String(sat.d).padStart(2, '0')}`,
+    start_utc: startUtc.toISOString(),
+    end_utc: endUtc.toISOString(),
+  });
+
+  const shelters = (await pool.query(`
+    SELECT id, name, address, city, state, zip, latitude, longitude
+    FROM businesses
+    WHERE state = 'CA'
+      AND category IN ('shelter','rescue')
+      AND opt_out_flag = FALSE
+      AND latitude IS NOT NULL
+      AND longitude IS NOT NULL
+    ORDER BY id ASC`)).rows;
+
+  log.info(`found ${shelters.length} CA shelter/rescue rows with coords`);
+
+  let inserted = 0;
+  let skipped_existing = 0;
+
+  for (const s of shelters) {
+    const sourceUrl = `internal://shelter/${s.id}/adoption-event`;
+
+    // "One per shelter per month": skip if any row already exists for this
+    // (source, source_url) inside the PT calendar month of the target Saturday.
+    // Bucket on the wall-clock month in America/Los_Angeles (so a midnight-PT
+    // boundary rollover doesn't accidentally count toward the wrong month).
+    const exists = await pool.query(`
+      SELECT 1 FROM events
+      WHERE source = $1
+        AND source_url = $2
+        AND date_trunc('month', start_at AT TIME ZONE $3) =
+            date_trunc('month', $4::timestamptz AT TIME ZONE $3)
+      LIMIT 1`,
+      [SOURCE, sourceUrl, TZ, startUtc.toISOString()]);
+
+    if (exists.rowCount > 0) {
+      skipped_existing++;
+      continue;
+    }
+
+    const venue = s.name;
+    const description =
+      `Every Saturday 11am–3pm — drop in to meet adoptable pets at ${s.name}. ` +
+      `Hosted weekly; this listing represents the upcoming ${sat.y}-${String(sat.m).padStart(2,'0')}-${String(sat.d).padStart(2,'0')} session.`;
+
+    try {
+      await pool.query(`
+        INSERT INTO events
+          (kind, title, organizer, venue, address, city, state, zip,
+           latitude, longitude, start_at, end_at, source, source_url,
+           description, status)
+        VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,'scheduled')
+        ON CONFLICT (source, source_url, start_at, title) DO NOTHING`,
+        [
+          KIND, TITLE, s.name, venue, s.address, s.city, s.state, s.zip,
+          s.latitude, s.longitude, startUtc.toISOString(), endUtc.toISOString(),
+          SOURCE, sourceUrl, description,
+        ]);
+      inserted++;
+    } catch (err) {
+      log.error('insert failed', { biz_id: s.id, name: s.name, err: err.message });
+    }
+  }
+
+  log.info('seed_ca_shelter_adoption_events: done', {
+    candidates: shelters.length,
+    inserted,
+    skipped_existing,
+  });
+
+  await pool.end();
+}
+
+main().catch(err => {
+  log.error('seed_ca_shelter_adoption_events: fatal', { err: err.message, stack: err.stack });
+  process.exit(1);
+});
diff --git a/src/server/render.js b/src/server/render.js
index 66aa9e5..bbae193 100644
--- a/src/server/render.js
+++ b/src/server/render.js
@@ -1085,7 +1085,18 @@ export function renderShows({ events, filters, ad }) {
     <select name="kind">${kinds.map(k => `<option value="${k}" ${filters.kind===k?'selected':''}>${k?KIND_LABEL[k]:'All kinds'}</option>`).join('')}</select>
     <button type="submit">Filter</button>
   </form>
-  ${points.length ? `<div id="show-map" class="map" style="height:380px"></div>` : ''}
+  ${points.length ? `
+  <div id="show-map" class="map" style="height:380px"></div>
+  <p class="map-legend muted" style="font-size:12px;margin:6px 0 18px">
+    <span style="display:inline-flex;align-items:center;gap:4px;margin-right:14px">
+      <span aria-hidden="true" style="display:inline-block;width:14px;height:14px;border-radius:50%;background:#c97a3e;border:2px solid #c97a3e"></span>
+      Show / trial
+    </span>
+    <span style="display:inline-flex;align-items:center;gap:4px">
+      <span aria-hidden="true" style="color:#e23a6f;font-size:18px;line-height:1">♥</span>
+      Adoption event
+    </span>
+  </p>` : ''}
   ${list || '<p class="empty">No events scheduled yet — scraper run pending.</p>'}
   ${adSlot(ad, 'native', '/shows')}
 </main>
@@ -1104,8 +1115,21 @@ ${points.length ? `
   if (!pts.length) return;
   const map = L.map('show-map').setView([pts[0].lat, pts[0].lng], 6);
   L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OpenStreetMap contributors',maxZoom:18}).addTo(map);
+  // Heart-glyph divIcon for adoption events; circle marker for everything else.
+  // Heart is rendered via CSS+text (no remote SVG fetch) so the map stays
+  // self-contained, and the icon is built without innerHTML so adversarial
+  // titles can never reach the DOM as HTML.
+  const HEART_ICON = L.divIcon({
+    className: 'event-heart',
+    html: '<span aria-hidden="true" style="display:block;width:24px;height:24px;line-height:24px;text-align:center;font-size:22px;color:#e23a6f;text-shadow:0 0 2px #fff,0 0 2px #fff;">♥</span>',
+    iconSize: [24, 24],
+    iconAnchor: [12, 12],
+    popupAnchor: [0, -10],
+  });
   const grp = L.featureGroup(pts.map(p => {
-    const m = L.circleMarker([p.lat,p.lng], { radius:9, color:'#c97a3e', fillColor:'#c97a3e', fillOpacity:0.85, weight:2 });
+    const m = (p.kind === 'adoption_event')
+      ? L.marker([p.lat, p.lng], { icon: HEART_ICON, alt: 'Adoption event' })
+      : L.circleMarker([p.lat,p.lng], { radius:9, color:'#c97a3e', fillColor:'#c97a3e', fillOpacity:0.85, weight:2 });
     const popup = document.createElement('strong');
     popup.textContent = p.title;
     m.bindPopup(popup);

← 7d3642f yolo: Wire /my-pets to /dog-park — auto-fill avatar  ·  back to Animals  ·  rel=alternate JSON + HTTP Link header on /breeds and /photog f22df9f →