src / store.ts
import { readFile, writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
export type AlertStatus = "pending" | "snoozed" | "dismissed";
export type AlertPriority = "low" | "normal" | "high";
export interface Alert {
id: string;
message: string;
dueAt: string; // ISO 8601 datetime
createdAt: string;
status: AlertStatus;
priority: AlertPriority;
tags: string[];
snoozedUntil?: string;
}
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
}
function alertsPath(dataPath: string): string {
return join(dataPath, "alerts.json");
}
export async function loadAlerts(dataPath: string): Promise<Alert[]> {
const path = alertsPath(dataPath);
if (!existsSync(path)) return [];
try {
const raw = await readFile(path, "utf-8");
return JSON.parse(raw) as Alert[];
} catch {
return [];
}
}
export async function saveAlerts(dataPath: string, alerts: Alert[]): Promise<void> {
await mkdir(dataPath, { recursive: true });
await writeFile(alertsPath(dataPath), JSON.stringify(alerts, null, 2), "utf-8");
}
export async function addAlert(
dataPath: string,
message: string,
dueAt: Date,
priority: AlertPriority,
tags: string[]
): Promise<Alert> {
const alerts = await loadAlerts(dataPath);
const alert: Alert = {
id: generateId(),
message,
dueAt: dueAt.toISOString(),
createdAt: new Date().toISOString(),
status: "pending",
priority,
tags,
};
alerts.push(alert);
await saveAlerts(dataPath, alerts);
return alert;
}
export async function updateAlert(dataPath: string, id: string, patch: Partial<Alert>): Promise<Alert | null> {
const alerts = await loadAlerts(dataPath);
const idx = alerts.findIndex(a => a.id === id);
if (idx === -1) return null;
alerts[idx] = { ...alerts[idx], ...patch };
await saveAlerts(dataPath, alerts);
return alerts[idx];
}
export async function deleteAlert(dataPath: string, id: string): Promise<boolean> {
const alerts = await loadAlerts(dataPath);
const filtered = alerts.filter(a => a.id !== id);
if (filtered.length === alerts.length) return false;
await saveAlerts(dataPath, filtered);
return true;
}
/** Returns alerts that are due or overdue and still pending (or snoozed but snooze expired). */
export function getDueAlerts(alerts: Alert[]): Alert[] {
const now = new Date();
return alerts.filter(a => {
if (a.status === "dismissed") return false;
if (a.status === "snoozed" && a.snoozedUntil) {
return new Date(a.snoozedUntil) <= now;
}
return new Date(a.dueAt) <= now;
});
}
/** Parse natural language time expressions into a Date. */
export function parseRelativeTime(expr: string): Date {
const now = new Date();
const s = expr.trim().toLowerCase();
// Absolute ISO date/datetime
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return new Date(s);
// Relative: "in 30 minutes", "in 2 hours", "in 3 days", "tomorrow", "tonight"
const inMatch = s.match(/^in\s+(\d+(?:\.\d+)?)\s+(minute|hour|day|week)s?$/);
if (inMatch) {
const n = parseFloat(inMatch[1]);
const unit = inMatch[2];
const ms = unit === "minute" ? n * 60_000
: unit === "hour" ? n * 3_600_000
: unit === "day" ? n * 86_400_000
: n * 7 * 86_400_000;
return new Date(now.getTime() + ms);
}
if (s === "tomorrow") {
const d = new Date(now); d.setDate(d.getDate() + 1); d.setHours(9, 0, 0, 0); return d;
}
if (s === "tonight") {
const d = new Date(now); d.setHours(20, 0, 0, 0); return d;
}
if (s === "end of day" || s === "eod") {
const d = new Date(now); d.setHours(17, 0, 0, 0); return d;
}
// Try native Date parse as fallback
const parsed = new Date(expr);
if (!isNaN(parsed.getTime())) return parsed;
throw new Error(`Cannot parse time expression: "${expr}". Use ISO date or phrases like "in 2 hours", "tomorrow", "in 3 days".`);
}