feat: persist push subscriptions in sqlite
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m54s

This commit is contained in:
zv
2026-06-13 22:03:37 +02:00
parent 5a8e39dbe2
commit 1ae1be68ec
11 changed files with 668 additions and 68 deletions

View File

@@ -3,6 +3,7 @@ WEB_PUSH_VAPID_PRIVATE_KEY=
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
NOTIFICATIONS_CRON_SECRET= NOTIFICATIONS_CRON_SECRET=
WTR_APP_URL=http://127.0.0.1:3000 WTR_APP_URL=http://127.0.0.1:3000
WTR_DATABASE_PATH=./data/wtr.sqlite
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5 NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00 NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00 NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ out
npm-debug.log* npm-debug.log*
.DS_Store .DS_Store
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
data/*.sqlite
data/*.sqlite-*

View File

@@ -4,7 +4,7 @@
`wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz prognozę modelową łączącą IMGW ALARO z Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika. `wtr.` to mobilna PWA pogodowa dla Polski. Pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, dane hydrologiczne i ostrzeżenia z publicznych API IMGW oraz prognozę modelową łączącą IMGW ALARO z Open-Meteo. Open-Meteo Geocoding służy także do wyszukiwania miejscowości, a Nominatim / OpenStreetMap do opcjonalnego reverse geocodingu po zgodzie GPS użytkownika.
Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts, Lucide React i `web-push`. PWA korzysta z manifestu oraz własnego service workera. Stack: Next.js App Router, React, TypeScript, Tailwind CSS, TanStack Query, Zustand, Framer Motion, Recharts, Lucide React, `web-push` i SQLite przez `better-sqlite3`. PWA korzysta z manifestu oraz własnego service workera.
Najważniejsze katalogi: Najważniejsze katalogi:
@@ -50,7 +50,7 @@ Repozytorium nie ma obecnie skryptu testów ani formattera. `npm run typecheck`
- Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego obszaru. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony. - Dashboard pokazuje kompaktowo wyłącznie aktywne i nadchodzące ostrzeżenia meteo dla wybranego obszaru. Filtruj je po `validTo` względem czasu przeglądarki i automatycznie usuwaj wygasłe komunikaty bez przeładowania strony.
- Brief dnia i brief na jutro generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i ostrzeżeń IMGW. Nie traktuj ich jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API. Brief na jutro korzysta z pełnego jutrzejszego dnia prognozy i może informować o burzach na podstawie kodów modelu także bez oficjalnego ostrzeżenia IMGW. - Brief dnia i brief na jutro generuj deterministycznie w `lib/weather-brief.ts` z prognozy modelowej i ostrzeżeń IMGW. Nie traktuj ich jako odpowiedzi modelu AI i nie wymagaj klucza OpenAI API. Brief na jutro korzysta z pełnego jutrzejszego dnia prognozy i może informować o burzach na podstawie kodów modelu także bez oficjalnego ostrzeżenia IMGW.
- Preferencje jednostek temperatury i wiatru są ustawieniami prezentacyjnymi. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C` i `m/s`, a wybrane jednostki zapisuj w subskrypcji Web Push, żeby briefy serwerowe używały tego samego formatu co UI. - Preferencje jednostek temperatury i wiatru są ustawieniami prezentacyjnymi. Dane źródłowe oraz progi logiki pogody pozostają liczone w `°C` i `m/s`, a wybrane jednostki zapisuj w subskrypcji Web Push, żeby briefy serwerowe używały tego samego formatu co UI.
- Powiadomienia Web Push o ostrzeżeniach meteo, porannym briefie i wieczornym briefie na jutro konfiguruj przez `/settings`, `public/sw.js`, route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`, oraz self-hostowany worker `scripts/notification-worker.mjs`. Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. Obecny `lib/push-store.ts` jest pamięciowy i przed produkcją musi zostać zastąpiony trwałym magazynem subskrypcji. - Powiadomienia Web Push o ostrzeżeniach meteo, porannym briefie i wieczornym briefie na jutro konfiguruj przez `/settings`, `public/sw.js`, route handlery `app/api/notifications/*`, w tym testowy `/api/notifications/test`, oraz self-hostowany worker `scripts/notification-worker.mjs`. Endpointy harmonogramu nie uruchamiają się same bez workera albo zewnętrznego crona. Wymagają kluczy VAPID w zmiennych środowiskowych. iOS/iPadOS wymaga PWA z ekranu początkowego, ale Android i desktop nie powinny być blokowane wymogiem `standalone`. `lib/push-store.ts` zapisuje subskrypcje i historię wysyłek w SQLite wskazanym przez `WTR_DATABASE_PATH`, domyślnie `./data/wtr.sqlite`.
- GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim. - GPS wymaga świadomej zgody użytkownika i HTTPS. Zaokrąglaj współrzędne przed użyciem i utrzymuj widoczną atrybucję OpenStreetMap dla reverse geocodingu Nominatim.
- Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją. - Normalizuj zewnętrzne odpowiedzi i obsługuj `null`, puste pola oraz błędne wartości. Brak danych pokazuj jawnie zamiast uzupełniać estymacją.
- Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states. - Dla pobierania danych używaj TanStack Query z sensownym `queryKey`, cache i retry. W UI zachowuj loading, error, retry oraz empty states.

View File

@@ -25,6 +25,7 @@ Aplikacja pokazuje bieżącą analizę IMGW Hybrid, pomiary synoptyczne, prognoz
- Recharts - Recharts
- Lucide React - Lucide React
- `web-push` - `web-push`
- SQLite przez `better-sqlite3` dla trwałego magazynu subskrypcji Web Push
- własny service worker i manifest PWA - własny service worker i manifest PWA
## Szybki Start ## Szybki Start
@@ -50,6 +51,7 @@ WEB_PUSH_VAPID_PRIVATE_KEY=
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
NOTIFICATIONS_CRON_SECRET= NOTIFICATIONS_CRON_SECRET=
WTR_APP_URL=http://127.0.0.1:3000 WTR_APP_URL=http://127.0.0.1:3000
WTR_DATABASE_PATH=./data/wtr.sqlite
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5 NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00 NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00 NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
@@ -106,7 +108,7 @@ docs/ szczegółowa dokumentacja techniczna
## Status i Ograniczenia ## Status i Ograniczenia
- `lib/push-store.ts` przechowuje subskrypcje Web Push w pamięci procesu. Produkcyjne wdrożenie wymaga trwałego magazynu, np. bazy danych albo KV. - `lib/push-store.ts` przechowuje subskrypcje Web Push i historię wysyłek w lokalnym SQLite. Na self-hostingu zadbaj o trwały katalog dla `WTR_DATABASE_PATH` i backup pliku bazy.
- Endpoint IMGW Hybrid używany przez dashboard pochodzi z publicznego frontendu `meteo.imgw.pl`, a nie ze stabilnie opisanej dokumentacji `danepubliczne.imgw.pl`. - Endpoint IMGW Hybrid używany przez dashboard pochodzi z publicznego frontendu `meteo.imgw.pl`, a nie ze stabilnie opisanej dokumentacji `danepubliczne.imgw.pl`.
- Publiczne API IMGW potrafi zwrócić `404` z komunikatem `No products were found` dla pustych list ostrzeżeń; aplikacja traktuje to jako brak ostrzeżeń. - Publiczne API IMGW potrafi zwrócić `404` z komunikatem `No products were found` dla pustych list ostrzeżeń; aplikacja traktuje to jako brak ostrzeżeń.
- Prognoza jest prognozą modelową, nie pomiarem IMGW. Bieżące pomiary i prognozy są w UI rozdzielane. - Prognoza jest prognozą modelową, nie pomiarem IMGW. Bieżące pomiary i prognozy są w UI rozdzielane.

View File

@@ -14,7 +14,7 @@ Wszystkie zewnętrzne źródła danych są wywoływane przez route handlery Next
## Powiadomienia ## Powiadomienia
Endpointy powiadomień działają w runtime Node.js, bo korzystają z `web-push` i pamięciowego magazynu subskrypcji. Endpointy powiadomień działają w runtime Node.js, bo korzystają z `web-push` i lokalnego magazynu SQLite dla subskrypcji.
| Metoda | Endpoint | Przeznaczenie | | Metoda | Endpoint | Przeznaczenie |
| --- | --- | --- | | --- | --- | --- |

View File

@@ -60,6 +60,7 @@ WEB_PUSH_VAPID_PRIVATE_KEY=
WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com WEB_PUSH_VAPID_SUBJECT=mailto:admin@example.com
NOTIFICATIONS_CRON_SECRET= NOTIFICATIONS_CRON_SECRET=
WTR_APP_URL=http://127.0.0.1:3000 WTR_APP_URL=http://127.0.0.1:3000
WTR_DATABASE_PATH=./data/wtr.sqlite
NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5 NOTIFICATIONS_WARNING_INTERVAL_MINUTES=5
NOTIFICATIONS_MORNING_BRIEF_TIME=07:00 NOTIFICATIONS_MORNING_BRIEF_TIME=07:00
NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00 NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
@@ -67,6 +68,8 @@ NOTIFICATIONS_TOMORROW_BRIEF_TIME=18:00
Do zwykłego uruchomienia aplikacji pogodowej zmienne Web Push nie są wymagane. Są potrzebne dopiero dla powiadomień. Do zwykłego uruchomienia aplikacji pogodowej zmienne Web Push nie są wymagane. Są potrzebne dopiero dla powiadomień.
`WTR_DATABASE_PATH` wskazuje plik SQLite z subskrypcjami Web Push i historią wysyłek. Na self-hostingu ustaw go na ścieżkę w trwałym katalogu, np. `/home/admin/wtr-data/wtr.sqlite`, jeśli katalog projektu jest podmieniany podczas deploya.
## Jakość ## Jakość
Przed zakończeniem zmian uruchamiaj: Przed zakończeniem zmian uruchamiaj:

View File

@@ -31,6 +31,14 @@ Endpointy harmonogramu wymagają też sekretu crona w produkcji:
NOTIFICATIONS_CRON_SECRET= NOTIFICATIONS_CRON_SECRET=
``` ```
Subskrypcje Web Push i historia wysyłek są zapisywane w SQLite:
```bash
WTR_DATABASE_PATH=./data/wtr.sqlite
```
Jeśli zmienna nie jest ustawiona, aplikacja używa `./data/wtr.sqlite` względem katalogu projektu.
## Preferencje Subskrypcji ## Preferencje Subskrypcji
Subskrypcja Web Push zapisuje: Subskrypcja Web Push zapisuje:
@@ -114,6 +122,6 @@ x-cron-secret: <NOTIFICATIONS_CRON_SECRET>
## Ograniczenia ## Ograniczenia
Obecny `lib/push-store.ts` przechowuje subskrypcje i historię wysyłek w pamięci procesu. Po restarcie procesu te dane znikają. Produkcja wymaga trwałego magazynu, np. bazy danych albo KV. `lib/push-store.ts` przechowuje subskrypcje i historię wysyłek w lokalnym SQLite przez `better-sqlite3`. Dane przetrwają restart procesu, jeśli plik wskazany przez `WTR_DATABASE_PATH` znajduje się w trwałym katalogu. Na self-hostingu warto backupować plik bazy razem z plikami `-wal` i `-shm`, jeśli istnieją.
Powiadomienie testowe jest wysyłane na konkretny endpoint subskrypcji przekazany z `/settings`, a nie broadcastowane do wszystkich urządzeń. Powiadomienie testowe jest wysyłane na konkretny endpoint subskrypcji przekazany z `/settings`, a nie broadcastowane do wszystkich urządzeń.

View File

@@ -1,83 +1,265 @@
import Database from "better-sqlite3";
import fs from "node:fs";
import path from "node:path";
import { DEFAULT_TEMPERATURE_UNIT, DEFAULT_WIND_SPEED_UNIT, isTemperatureUnit, isWindSpeedUnit } from "@/lib/weather-utils";
import type { WarningPushSubscription } from "@/types/notifications"; import type { WarningPushSubscription } from "@/types/notifications";
import type { Province } from "@/types/province";
interface PushStore { interface PushDatabase {
subscriptions: Map<string, WarningPushSubscription>; database: Database.Database;
sentWarningIds: Map<string, Set<string>>;
sentMorningBriefDates: Map<string, Set<string>>;
sentTomorrowBriefDates: Map<string, Set<string>>;
} }
const globalStore = globalThis as typeof globalThis & { __wtrPushStore?: PushStore }; interface PushSubscriptionRow {
endpoint: string;
subscription_json: string;
province: string;
language: string;
enabled: number;
morning_brief_enabled: number;
tomorrow_brief_enabled: number;
latitude: number | null;
longitude: number | null;
location_name: string | null;
county_teryt: string | null;
temperature_unit: string | null;
wind_speed_unit: string | null;
created_at: string;
updated_at: string;
}
function getStore() { const globalStore = globalThis as typeof globalThis & { __wtrPushDatabase?: PushDatabase };
if (!globalStore.__wtrPushStore) {
globalStore.__wtrPushStore = { function getDatabasePath() {
subscriptions: new Map(), const configuredPath = process.env.WTR_DATABASE_PATH;
sentWarningIds: new Map(), if (configuredPath) {
sentMorningBriefDates: new Map(), return path.isAbsolute(configuredPath)
sentTomorrowBriefDates: new Map(), ? configuredPath
: path.join(/* turbopackIgnore: true */ process.cwd(), configuredPath);
}
return path.join(/* turbopackIgnore: true */ process.cwd(), "data", "wtr.sqlite");
}
function ensureDatabaseDirectory(databasePath: string) {
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
}
function initializeDatabase(database: Database.Database) {
database.pragma("journal_mode = WAL");
database.pragma("foreign_keys = ON");
database.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
endpoint TEXT PRIMARY KEY,
subscription_json TEXT NOT NULL,
province TEXT NOT NULL,
language TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
morning_brief_enabled INTEGER NOT NULL DEFAULT 0,
tomorrow_brief_enabled INTEGER NOT NULL DEFAULT 0,
latitude REAL,
longitude REAL,
location_name TEXT,
county_teryt TEXT,
temperature_unit TEXT NOT NULL DEFAULT '${DEFAULT_TEMPERATURE_UNIT}',
wind_speed_unit TEXT NOT NULL DEFAULT '${DEFAULT_WIND_SPEED_UNIT}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sent_warnings (
endpoint TEXT NOT NULL,
warning_id TEXT NOT NULL,
sent_at TEXT NOT NULL,
PRIMARY KEY (endpoint, warning_id),
FOREIGN KEY (endpoint) REFERENCES push_subscriptions(endpoint) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sent_morning_briefs (
endpoint TEXT NOT NULL,
date_key TEXT NOT NULL,
sent_at TEXT NOT NULL,
PRIMARY KEY (endpoint, date_key),
FOREIGN KEY (endpoint) REFERENCES push_subscriptions(endpoint) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sent_tomorrow_briefs (
endpoint TEXT NOT NULL,
date_key TEXT NOT NULL,
sent_at TEXT NOT NULL,
PRIMARY KEY (endpoint, date_key),
FOREIGN KEY (endpoint) REFERENCES push_subscriptions(endpoint) ON DELETE CASCADE
);
`);
}
function getDatabase() {
if (!globalStore.__wtrPushDatabase) {
const databasePath = getDatabasePath();
ensureDatabaseDirectory(databasePath);
const database = new Database(databasePath);
initializeDatabase(database);
globalStore.__wtrPushDatabase = { database };
}
return globalStore.__wtrPushDatabase.database;
}
function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription | null {
try {
const subscription = JSON.parse(row.subscription_json) as WarningPushSubscription["subscription"];
return {
endpoint: row.endpoint,
subscription,
province: row.province as Province,
language: row.language === "en" ? "en" : "pl",
enabled: row.enabled === 1,
morningBriefEnabled: row.morning_brief_enabled === 1,
tomorrowBriefEnabled: row.tomorrow_brief_enabled === 1,
latitude: row.latitude,
longitude: row.longitude,
locationName: row.location_name,
countyTeryt: row.county_teryt,
temperatureUnit: isTemperatureUnit(row.temperature_unit) ? row.temperature_unit : DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: isWindSpeedUnit(row.wind_speed_unit) ? row.wind_speed_unit : DEFAULT_WIND_SPEED_UNIT,
createdAt: row.created_at,
updatedAt: row.updated_at,
}; };
} catch {
return null;
} }
return globalStore.__wtrPushStore;
} }
export function upsertPushSubscription(subscription: WarningPushSubscription) { export function upsertPushSubscription(subscription: WarningPushSubscription) {
getStore().subscriptions.set(subscription.endpoint, subscription); getDatabase().prepare(`
INSERT INTO push_subscriptions (
endpoint,
subscription_json,
province,
language,
enabled,
morning_brief_enabled,
tomorrow_brief_enabled,
latitude,
longitude,
location_name,
county_teryt,
temperature_unit,
wind_speed_unit,
created_at,
updated_at
) VALUES (
@endpoint,
@subscriptionJson,
@province,
@language,
@enabled,
@morningBriefEnabled,
@tomorrowBriefEnabled,
@latitude,
@longitude,
@locationName,
@countyTeryt,
@temperatureUnit,
@windSpeedUnit,
@createdAt,
@updatedAt
)
ON CONFLICT(endpoint) DO UPDATE SET
subscription_json = excluded.subscription_json,
province = excluded.province,
language = excluded.language,
enabled = excluded.enabled,
morning_brief_enabled = excluded.morning_brief_enabled,
tomorrow_brief_enabled = excluded.tomorrow_brief_enabled,
latitude = excluded.latitude,
longitude = excluded.longitude,
location_name = excluded.location_name,
county_teryt = excluded.county_teryt,
temperature_unit = excluded.temperature_unit,
wind_speed_unit = excluded.wind_speed_unit,
updated_at = excluded.updated_at
`).run({
endpoint: subscription.endpoint,
subscriptionJson: JSON.stringify(subscription.subscription),
province: subscription.province,
language: subscription.language,
enabled: subscription.enabled ? 1 : 0,
morningBriefEnabled: subscription.morningBriefEnabled ? 1 : 0,
tomorrowBriefEnabled: subscription.tomorrowBriefEnabled ? 1 : 0,
latitude: subscription.latitude,
longitude: subscription.longitude,
locationName: subscription.locationName,
countyTeryt: subscription.countyTeryt,
temperatureUnit: subscription.temperatureUnit,
windSpeedUnit: subscription.windSpeedUnit,
createdAt: subscription.createdAt,
updatedAt: subscription.updatedAt,
});
} }
export function removePushSubscription(endpoint: string) { export function removePushSubscription(endpoint: string) {
const store = getStore(); getDatabase().prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(endpoint);
store.subscriptions.delete(endpoint);
store.sentWarningIds.delete(endpoint);
store.sentMorningBriefDates.delete(endpoint);
store.sentTomorrowBriefDates.delete(endpoint);
} }
export function getPushSubscriptions() { export function getPushSubscriptions() {
return Array.from(getStore().subscriptions.values()); return getDatabase()
.prepare("SELECT * FROM push_subscriptions ORDER BY created_at ASC")
.all()
.map((row) => parseSubscription(row as PushSubscriptionRow))
.filter((subscription): subscription is WarningPushSubscription => subscription !== null);
} }
export function getPushSubscription(endpoint: string) { export function getPushSubscription(endpoint: string) {
return getStore().subscriptions.get(endpoint) ?? null; const row = getDatabase().prepare("SELECT * FROM push_subscriptions WHERE endpoint = ?").get(endpoint);
return row ? parseSubscription(row as PushSubscriptionRow) : null;
} }
export function hasSentWarning(endpoint: string, warningId: string) { export function hasSentWarning(endpoint: string, warningId: string) {
return getStore().sentWarningIds.get(endpoint)?.has(warningId) ?? false; const row = getDatabase()
.prepare("SELECT 1 FROM sent_warnings WHERE endpoint = ? AND warning_id = ?")
.get(endpoint, warningId);
return Boolean(row);
} }
export function markWarningSent(endpoint: string, warningId: string) { export function markWarningSent(endpoint: string, warningId: string) {
const store = getStore(); getDatabase()
const sentIds = store.sentWarningIds.get(endpoint) ?? new Set<string>(); .prepare("INSERT OR IGNORE INTO sent_warnings (endpoint, warning_id, sent_at) VALUES (?, ?, ?)")
sentIds.add(warningId); .run(endpoint, warningId, new Date().toISOString());
store.sentWarningIds.set(endpoint, sentIds);
} }
export function pruneSentWarnings(activeWarningIds: Set<string>) { export function pruneSentWarnings(activeWarningIds: Set<string>) {
getStore().sentWarningIds.forEach((sentIds) => { const database = getDatabase();
sentIds.forEach((warningId) => { if (!activeWarningIds.size) {
if (!activeWarningIds.has(warningId)) sentIds.delete(warningId); database.prepare("DELETE FROM sent_warnings").run();
}); return;
}); }
database.transaction((warningIds: string[]) => {
const placeholders = warningIds.map(() => "?").join(", ");
database.prepare(`DELETE FROM sent_warnings WHERE warning_id NOT IN (${placeholders})`).run(...warningIds);
})(Array.from(activeWarningIds));
} }
export function hasSentMorningBrief(endpoint: string, dateKey: string) { export function hasSentMorningBrief(endpoint: string, dateKey: string) {
return getStore().sentMorningBriefDates.get(endpoint)?.has(dateKey) ?? false; const row = getDatabase()
.prepare("SELECT 1 FROM sent_morning_briefs WHERE endpoint = ? AND date_key = ?")
.get(endpoint, dateKey);
return Boolean(row);
} }
export function markMorningBriefSent(endpoint: string, dateKey: string) { export function markMorningBriefSent(endpoint: string, dateKey: string) {
const store = getStore(); getDatabase()
const sentDates = store.sentMorningBriefDates.get(endpoint) ?? new Set<string>(); .prepare("INSERT OR IGNORE INTO sent_morning_briefs (endpoint, date_key, sent_at) VALUES (?, ?, ?)")
sentDates.add(dateKey); .run(endpoint, dateKey, new Date().toISOString());
store.sentMorningBriefDates.set(endpoint, sentDates);
} }
export function hasSentTomorrowBrief(endpoint: string, dateKey: string) { export function hasSentTomorrowBrief(endpoint: string, dateKey: string) {
return getStore().sentTomorrowBriefDates.get(endpoint)?.has(dateKey) ?? false; const row = getDatabase()
.prepare("SELECT 1 FROM sent_tomorrow_briefs WHERE endpoint = ? AND date_key = ?")
.get(endpoint, dateKey);
return Boolean(row);
} }
export function markTomorrowBriefSent(endpoint: string, dateKey: string) { export function markTomorrowBriefSent(endpoint: string, dateKey: string) {
const store = getStore(); getDatabase()
const sentDates = store.sentTomorrowBriefDates.get(endpoint) ?? new Set<string>(); .prepare("INSERT OR IGNORE INTO sent_tomorrow_briefs (endpoint, date_key, sent_at) VALUES (?, ?, ?)")
sentDates.add(dateKey); .run(endpoint, dateKey, new Date().toISOString());
store.sentTomorrowBriefDates.set(endpoint, sentDates);
} }

View File

@@ -3,6 +3,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
allowedDevOrigins: ["192.168.0.174"], allowedDevOrigins: ["192.168.0.174"],
reactStrictMode: true, reactStrictMode: true,
serverExternalPackages: ["better-sqlite3"],
}; };
export default nextConfig; export default nextConfig;

405
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.80.0", "@tanstack/react-query": "^5.80.0",
"better-sqlite3": "^12.10.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.0.0", "framer-motion": "^12.0.0",
@@ -22,6 +23,7 @@
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
@@ -1451,6 +1453,16 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/better-sqlite3": {
"version": "7.6.13",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/d3-array": { "node_modules/@types/d3-array": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
@@ -2585,6 +2597,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.33", "version": "2.10.33",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz",
@@ -2597,6 +2629,20 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/better-sqlite3": {
"version": "12.10.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz",
"integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
},
"engines": {
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -2610,6 +2656,26 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/bn.js": { "node_modules/bn.js": {
"version": "4.12.3", "version": "4.12.3",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
@@ -2674,6 +2740,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": { "node_modules/buffer-equal-constant-time": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -2825,6 +2915,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/class-variance-authority": { "node_modules/class-variance-authority": {
"version": "0.7.1", "version": "0.7.1",
"resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
@@ -3136,6 +3232,30 @@
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3184,7 +3304,6 @@
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -3254,6 +3373,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-abstract": { "node_modules/es-abstract": {
"version": "1.24.2", "version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -3896,6 +4024,15 @@
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"engines": {
"node": ">=6"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3970,6 +4107,12 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -4078,6 +4221,12 @@
} }
} }
}, },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -4224,6 +4373,12 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
} }
}, },
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -4413,6 +4568,26 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4466,6 +4641,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/internal-slot": { "node_modules/internal-slot": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5214,6 +5395,18 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": { "node_modules/minimalistic-assert": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
@@ -5242,6 +5435,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/motion-dom": { "node_modules/motion-dom": {
"version": "12.40.0", "version": "12.40.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
@@ -5293,6 +5492,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
},
"node_modules/napi-postinstall": { "node_modules/napi-postinstall": {
"version": "0.3.4", "version": "0.3.4",
"resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz",
@@ -5397,6 +5602,18 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/node-abi": {
"version": "3.92.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
"license": "MIT",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-exports-info": { "node_modules/node-exports-info": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
@@ -5579,6 +5796,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -5921,6 +6147,33 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^2.0.0",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5943,6 +6196,16 @@
"react-is": "^16.13.1" "react-is": "^16.13.1"
} }
}, },
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5974,6 +6237,30 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.6", "version": "19.2.6",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
@@ -6034,6 +6321,20 @@
"pify": "^2.3.0" "pify": "^2.3.0"
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -6312,7 +6613,6 @@
"version": "7.8.1", "version": "7.8.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
"integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -6514,6 +6814,51 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6544,6 +6889,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string.prototype.includes": { "node_modules/string.prototype.includes": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -6852,6 +7206,34 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/thenify": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -6981,6 +7363,18 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -7228,7 +7622,6 @@
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/victory-vendor": { "node_modules/victory-vendor": {
@@ -7387,6 +7780,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -12,6 +12,7 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.80.0", "@tanstack/react-query": "^5.80.0",
"better-sqlite3": "^12.10.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.0.0", "framer-motion": "^12.0.0",
@@ -25,6 +26,7 @@
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",