Переглянути джерело

feat: background HKO temperature collection via instrumentation hook

Extract HKO fetch logic into shared lib/hko.ts and add Next.js
instrumentation hook to collect temperature readings every 3 minutes
on server startup, so the Temperature Curve has data without keeping
the page open.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Horace Zhang 7 місяців тому
батько
коміт
7e73ff8c33

+ 3 - 0
packages/dashboard/next.config.mjs

@@ -12,6 +12,9 @@ expand(config({ path: resolve(__dirname, '../../.env'), override: true }));
 /** @type {import('next').NextConfig} */
 const nextConfig = {
   transpilePackages: ['@poly-weather/core', '@poly-weather/market-engine'],
+  experimental: {
+    instrumentationHook: true,
+  },
 };
 
 export default nextConfig;

+ 19 - 120
packages/dashboard/src/app/api/hko-current/route.ts

@@ -11,16 +11,14 @@
  */
 
 import { NextResponse } from 'next/server';
-import type { HKOCurrentTemp, HKOStation } from '@/lib/types';
+import type { HKOCurrentTemp } from '@/lib/types';
 import { getObservedDailyHigh, persistObservedTemp } from '@/lib/queries';
+import { fetchHKOCurrent, REFERENCE_STATION_JSON } from '@/lib/hko';
 
 export const dynamic = 'force-dynamic';
 
-const CSV_URL = 'https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather/latest_1min_temperature.csv';
 const JSON_URL = 'https://data.weather.gov.hk/weatherAPI/opendata/weather.php?dataType=rhrread&lang=en';
 const FLW_URL = 'https://data.weather.gov.hk/weatherAPI/opendata/weather.php?dataType=flw&lang=en';
-const REFERENCE_STATION_CSV = 'HK Observatory';
-const REFERENCE_STATION_JSON = 'Hong Kong Observatory';
 
 // In-memory cache (1-min TTL — CSV updates every minute)
 let cache: { data: HKOCurrentTemp; expiresAt: number } | null = null;
@@ -78,113 +76,6 @@ async function fetchWeatherInfo(): Promise<{ temp: number | null; icon: number |
   return { temp: dailyHigh, icon, forecastDesc };
 }
 
-/**
- * Parse the HKO 1-minute temperature CSV.
- * Format: "Date time,Automatic Weather Station,Air Temperature(degree Celsius)"
- * Example: "202603191730,HK Observatory,26.4"
- */
-function parseTemperatureCSV(csv: string): { stations: HKOStation[]; recordTime: string } | null {
-  const lines = csv.trim().split('\n');
-  if (lines.length < 2) return null;
-
-  const stations: HKOStation[] = [];
-  let recordTime = '';
-
-  for (let i = 1; i < lines.length; i++) {
-    const parts = lines[i].split(',');
-    if (parts.length < 3) continue;
-
-    const [datetime, place, tempStr] = parts;
-    if (!datetime || !place || !tempStr || tempStr.trim() === 'N/A') continue;
-
-    const temp = parseFloat(tempStr);
-    if (isNaN(temp)) continue;
-
-    stations.push({ place: place.trim(), value: temp, unit: 'C' });
-
-    if (!recordTime && datetime) {
-      // Convert "202603191730" to ISO format
-      const y = datetime.slice(0, 4);
-      const m = datetime.slice(4, 6);
-      const d = datetime.slice(6, 8);
-      const h = datetime.slice(8, 10);
-      const min = datetime.slice(10, 12);
-      recordTime = `${y}-${m}-${d}T${h}:${min}:00+08:00`;
-    }
-  }
-
-  return stations.length > 0 ? { stations, recordTime } : null;
-}
-
-async function fetchFromCSV(): Promise<HKOCurrentTemp | null> {
-  const res = await fetch(CSV_URL, {
-    signal: AbortSignal.timeout(10_000),
-    cache: 'no-store',
-  });
-
-  if (!res.ok) return null;
-
-  const text = await res.text();
-  const parsed = parseTemperatureCSV(text);
-  if (!parsed) return null;
-
-  const refStation = parsed.stations.find((s) => s.place === REFERENCE_STATION_CSV);
-
-  return {
-    current: {
-      temp: refStation?.value ?? parsed.stations[0]?.value ?? 0,
-      unit: 'C',
-      recordTime: parsed.recordTime,
-    },
-    stations: parsed.stations,
-  };
-}
-
-async function fetchFromJSON(): Promise<HKOCurrentTemp | null> {
-  const res = await fetch(JSON_URL, {
-    headers: { Accept: 'application/json' },
-    signal: AbortSignal.timeout(10_000),
-    cache: 'no-store',
-  });
-
-  if (!res.ok) return null;
-
-  const json = await res.json();
-  const stations: HKOStation[] = (json.temperature?.data ?? []).map(
-    (s: { place: string; value: number; unit: string }) => ({
-      place: s.place,
-      value: s.value,
-      unit: s.unit,
-    }),
-  );
-
-  const refStation = stations.find((s) => s.place === REFERENCE_STATION_JSON);
-
-  const result: HKOCurrentTemp = {
-    current: {
-      temp: refStation?.value ?? stations[0]?.value ?? 0,
-      unit: refStation?.unit ?? 'C',
-      recordTime: json.temperature?.recordTime ?? new Date().toISOString(),
-    },
-    stations,
-  };
-
-  // Add humidity if available
-  if (json.humidity?.data?.length) {
-    const hkoHumidity = json.humidity.data.find(
-      (h: { place: string; value: number }) => h.place === REFERENCE_STATION_JSON,
-    );
-    if (hkoHumidity) {
-      result.humidity = {
-        value: hkoHumidity.value,
-        recordTime: json.humidity.recordTime,
-      };
-    }
-  }
-
-  return result;
-}
-
 export async function GET() {
   try {
     // Return cached if fresh
@@ -192,26 +83,34 @@ export async function GET() {
       return NextResponse.json(cache.data);
     }
 
-    // Try CSV first (0.1°C precision), fallback to JSON (integer)
-    let result = await fetchFromCSV().catch(() => null);
+    const reading = await fetchHKOCurrent();
 
-    if (!result) {
-      result = await fetchFromJSON().catch(() => null);
-    }
-
-    if (!result) {
+    if (!reading) {
       return NextResponse.json(
         { error: 'Failed to fetch HKO data from all sources' },
         { status: 502 },
       );
     }
 
+    const result: HKOCurrentTemp = {
+      current: {
+        temp: reading.temp,
+        unit: reading.unit,
+        recordTime: reading.recordTime,
+      },
+      stations: reading.stations,
+    };
+
+    if (reading.humidity) {
+      result.humidity = reading.humidity;
+    }
+
     // Persist current reading to DB (fire-and-forget)
     persistObservedTemp({
       city: 'hong_kong',
-      temp: result.current.temp,
+      temp: reading.temp,
       unit: 'C',
-      recordTime: new Date(result.current.recordTime),
+      recordTime: new Date(reading.recordTime),
       source: 'hko',
     }).catch(() => {});
 

+ 44 - 0
packages/dashboard/src/instrumentation.ts

@@ -0,0 +1,44 @@
+/**
+ * Next.js instrumentation hook — runs once on server startup.
+ * Starts a background interval to collect HKO temperature readings
+ * every 3 minutes, so the Temperature Curve has data even when
+ * the dashboard page is not open.
+ */
+
+export async function register() {
+  // Only run on the Node.js server runtime, not on Edge
+  if (process.env.NEXT_RUNTIME === 'nodejs') {
+    const HKO_POLL_INTERVAL = 3 * 60 * 1000; // 3 minutes
+
+    // Dynamic import to avoid bundling server-only code
+    const { fetchHKOCurrent } = await import('@/lib/hko');
+    const { persistObservedTemp } = await import('@/lib/queries');
+
+    async function collectHKOTemp() {
+      try {
+        const reading = await fetchHKOCurrent();
+        if (!reading) return;
+
+        await persistObservedTemp({
+          city: 'hong_kong',
+          temp: reading.temp,
+          unit: 'C',
+          recordTime: new Date(reading.recordTime),
+          source: 'hko',
+        });
+
+        console.log(`[hko-cron] ${reading.temp}°C @ ${reading.recordTime}`);
+      } catch (err) {
+        console.error('[hko-cron] Failed:', err);
+      }
+    }
+
+    // Initial collection on startup
+    collectHKOTemp();
+
+    // Then every 3 minutes
+    setInterval(collectHKOTemp, HKO_POLL_INTERVAL);
+
+    console.log('[hko-cron] Background temperature collection started (every 3 min)');
+  }
+}

+ 132 - 0
packages/dashboard/src/lib/hko.ts

@@ -0,0 +1,132 @@
+/**
+ * Shared HKO temperature fetching logic.
+ * Used by both /api/hko-current (dashboard) and /api/cron/hko-temp (background collection).
+ */
+
+import type { HKOStation } from './types';
+
+const CSV_URL = 'https://data.weather.gov.hk/weatherAPI/hko_data/regional-weather/latest_1min_temperature.csv';
+const JSON_URL = 'https://data.weather.gov.hk/weatherAPI/opendata/weather.php?dataType=rhrread&lang=en';
+
+export const REFERENCE_STATION_CSV = 'HK Observatory';
+export const REFERENCE_STATION_JSON = 'Hong Kong Observatory';
+
+export interface HKOReading {
+  temp: number;
+  unit: string;
+  recordTime: string;
+  stations: HKOStation[];
+  humidity?: { value: number; recordTime: string };
+}
+
+/**
+ * Parse the HKO 1-minute temperature CSV.
+ * Format: "Date time,Automatic Weather Station,Air Temperature(degree Celsius)"
+ * Example: "202603191730,HK Observatory,26.4"
+ */
+export function parseTemperatureCSV(csv: string): { stations: HKOStation[]; recordTime: string } | null {
+  const lines = csv.trim().split('\n');
+  if (lines.length < 2) return null;
+
+  const stations: HKOStation[] = [];
+  let recordTime = '';
+
+  for (let i = 1; i < lines.length; i++) {
+    const parts = lines[i].split(',');
+    if (parts.length < 3) continue;
+
+    const [datetime, place, tempStr] = parts;
+    if (!datetime || !place || !tempStr || tempStr.trim() === 'N/A') continue;
+
+    const temp = parseFloat(tempStr);
+    if (isNaN(temp)) continue;
+
+    stations.push({ place: place.trim(), value: temp, unit: 'C' });
+
+    if (!recordTime && datetime) {
+      const y = datetime.slice(0, 4);
+      const m = datetime.slice(4, 6);
+      const d = datetime.slice(6, 8);
+      const h = datetime.slice(8, 10);
+      const min = datetime.slice(10, 12);
+      recordTime = `${y}-${m}-${d}T${h}:${min}:00+08:00`;
+    }
+  }
+
+  return stations.length > 0 ? { stations, recordTime } : null;
+}
+
+export async function fetchFromCSV(): Promise<HKOReading | null> {
+  const res = await fetch(CSV_URL, {
+    signal: AbortSignal.timeout(10_000),
+    cache: 'no-store',
+  });
+
+  if (!res.ok) return null;
+
+  const text = await res.text();
+  const parsed = parseTemperatureCSV(text);
+  if (!parsed) return null;
+
+  const refStation = parsed.stations.find((s) => s.place === REFERENCE_STATION_CSV);
+
+  return {
+    temp: refStation?.value ?? parsed.stations[0]?.value ?? 0,
+    unit: 'C',
+    recordTime: parsed.recordTime,
+    stations: parsed.stations,
+  };
+}
+
+export async function fetchFromJSON(): Promise<HKOReading | null> {
+  const res = await fetch(JSON_URL, {
+    headers: { Accept: 'application/json' },
+    signal: AbortSignal.timeout(10_000),
+    cache: 'no-store',
+  });
+
+  if (!res.ok) return null;
+
+  const json = await res.json();
+  const stations: HKOStation[] = (json.temperature?.data ?? []).map(
+    (s: { place: string; value: number; unit: string }) => ({
+      place: s.place,
+      value: s.value,
+      unit: s.unit,
+    }),
+  );
+
+  const refStation = stations.find((s) => s.place === REFERENCE_STATION_JSON);
+
+  const result: HKOReading = {
+    temp: refStation?.value ?? stations[0]?.value ?? 0,
+    unit: refStation?.unit ?? 'C',
+    recordTime: json.temperature?.recordTime ?? new Date().toISOString(),
+    stations,
+  };
+
+  if (json.humidity?.data?.length) {
+    const hkoHumidity = json.humidity.data.find(
+      (h: { place: string; value: number }) => h.place === REFERENCE_STATION_JSON,
+    );
+    if (hkoHumidity) {
+      result.humidity = {
+        value: hkoHumidity.value,
+        recordTime: json.humidity.recordTime,
+      };
+    }
+  }
+
+  return result;
+}
+
+/**
+ * Fetch current HKO temperature. Tries CSV first (0.1°C precision), falls back to JSON.
+ */
+export async function fetchHKOCurrent(): Promise<HKOReading | null> {
+  let result = await fetchFromCSV().catch(() => null);
+  if (!result) {
+    result = await fetchFromJSON().catch(() => null);
+  }
+  return result;
+}