diff --git a/server/src/db/index.ts b/server/src/db/index.ts index c343654..70f8df9 100644 --- a/server/src/db/index.ts +++ b/server/src/db/index.ts @@ -57,6 +57,26 @@ CREATE TABLE IF NOT EXISTS market_prices ( ); CREATE INDEX IF NOT EXISTS idx_market_prices_city ON market_prices(city); +CREATE TABLE IF NOT EXISTS watch_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item TEXT NOT NULL, + city TEXT NOT NULL, + quality INTEGER NOT NULL DEFAULT 1, + condition TEXT NOT NULL, -- 'sell_below' | 'sell_above' | 'buy_above' + threshold REAL NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS watch_alerts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_id INTEGER NOT NULL REFERENCES watch_rules(id) ON DELETE CASCADE, + message TEXT NOT NULL, + price REAL, + triggered_at TEXT NOT NULL, + seen INTEGER NOT NULL DEFAULT 0 +); + CREATE TABLE IF NOT EXISTS zones ( albion_id TEXT PRIMARY KEY, name TEXT NOT NULL, diff --git a/server/src/index.ts b/server/src/index.ts index adede02..3028d43 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -7,6 +7,8 @@ import { marketRoutes } from './routes/market.js'; import { economyRoutes } from './routes/economy.js'; import { profileRoutes } from './routes/profiles.js'; import { travelRoutes } from './routes/travel.js'; +import { watchRoutes } from './routes/watch.js'; +import { startWatcherLoop } from './watcher/service.js'; const app = Fastify({ logger: { level: 'info' } }); @@ -19,6 +21,8 @@ await app.register(marketRoutes); await app.register(economyRoutes); await app.register(profileRoutes); await app.register(travelRoutes); +await app.register(watchRoutes); +startWatcherLoop(); await app.listen({ port: API_PORT, host: '127.0.0.1' }); console.log(`API listening on http://127.0.0.1:${API_PORT}`); diff --git a/server/src/routes/watch.ts b/server/src/routes/watch.ts new file mode 100644 index 0000000..19daa0a --- /dev/null +++ b/server/src/routes/watch.ts @@ -0,0 +1,64 @@ +import type { FastifyInstance } from 'fastify'; +import { setMeta } from '../db/index.js'; +import { + createRule, + deleteRule, + getWatcherStatus, + listAlerts, + listRules, + markAlertsSeen, + pollWatches, + setRuleEnabled, + startWatcherLoop, + type WatchCondition, +} from '../watcher/service.js'; + +export async function watchRoutes(app: FastifyInstance): Promise { + app.get('/api/watch/rules', async () => ({ rules: listRules(), status: getWatcherStatus() })); + + app.post('/api/watch/rules', async (req, reply) => { + const body = (req.body ?? {}) as { + item?: string; + city?: string; + quality?: number; + condition?: WatchCondition; + threshold?: number; + }; + if (!body.item || !body.city || !body.condition || body.threshold == null) { + return reply.code(400).send({ error: 'item, city, condition and threshold are required' }); + } + createRule({ item: body.item, city: body.city, quality: body.quality, condition: body.condition, threshold: body.threshold }); + return { ok: true }; + }); + + app.delete('/api/watch/rules/:id', async (req) => { + deleteRule(Number((req.params as { id: string }).id)); + return { ok: true }; + }); + + app.post('/api/watch/rules/:id/enabled', async (req) => { + const { enabled } = (req.body ?? {}) as { enabled?: boolean }; + setRuleEnabled(Number((req.params as { id: string }).id), enabled ?? true); + return { ok: true }; + }); + + app.get('/api/watch/alerts', async (req) => { + const unseenOnly = (req.query as { unseen?: string }).unseen === 'true'; + return { alerts: listAlerts(unseenOnly) }; + }); + + app.post('/api/watch/alerts/seen', async () => { + markAlertsSeen(); + return { ok: true }; + }); + + app.post('/api/watch/poll', async () => pollWatches()); + + app.put('/api/watch/interval', async (req, reply) => { + const { minutes } = (req.body ?? {}) as { minutes?: number }; + if (!minutes || minutes < 2) return reply.code(400).send({ error: 'minutes must be >= 2' }); + setMeta('watch_interval_min', String(minutes)); + startWatcherLoop(); + return { ok: true }; + }); +} diff --git a/server/src/watcher/service.ts b/server/src/watcher/service.ts new file mode 100644 index 0000000..d1e36ca --- /dev/null +++ b/server/src/watcher/service.ts @@ -0,0 +1,175 @@ +import { getDb, getMeta, setMeta } from '../db/index.js'; +import { fetchPrices, normalizeAodpDate } from '../market/client.js'; +import { getAlbionServer, getPricesForItem } from '../market/service.js'; + +export type WatchCondition = 'sell_below' | 'sell_above' | 'buy_above'; + +export interface WatchRule { + id: number; + item: string; + itemName?: string | null; + city: string; + quality: number; + condition: WatchCondition; + threshold: number; + enabled: boolean; + createdAt: string; +} + +const CONDITION_LABELS: Record string> = { + sell_below: (r, p) => `cheap: sell orders at ${Math.round(p).toLocaleString()} (below ${r.threshold.toLocaleString()})`, + sell_above: (r, p) => `expensive: sell orders at ${Math.round(p).toLocaleString()} (above ${r.threshold.toLocaleString()})`, + buy_above: (r, p) => `buy orders at ${Math.round(p).toLocaleString()} (above ${r.threshold.toLocaleString()}) — instant-sell opportunity`, +}; + +export function listRules(): WatchRule[] { + return ( + getDb() + .prepare( + `SELECT w.*, i.name_en FROM watch_rules w LEFT JOIN items i ON i.unique_name = w.item ORDER BY w.id DESC`, + ) + .all() as any[] + ).map((r) => ({ + id: r.id, + item: r.item, + itemName: r.name_en, + city: r.city, + quality: r.quality, + condition: r.condition, + threshold: r.threshold, + enabled: !!r.enabled, + createdAt: r.created_at, + })); +} + +export function createRule(rule: { item: string; city: string; quality?: number; condition: WatchCondition; threshold: number }): void { + getDb() + .prepare('INSERT INTO watch_rules (item, city, quality, condition, threshold, enabled, created_at) VALUES (?, ?, ?, ?, ?, 1, ?)') + .run(rule.item, rule.city, rule.quality ?? 1, rule.condition, rule.threshold, new Date().toISOString()); +} + +export function deleteRule(id: number): void { + getDb().prepare('DELETE FROM watch_rules WHERE id = ?').run(id); +} + +export function setRuleEnabled(id: number, enabled: boolean): void { + getDb().prepare('UPDATE watch_rules SET enabled = ? WHERE id = ?').run(enabled ? 1 : 0, id); +} + +export function listAlerts(unseenOnly = false): any[] { + return getDb() + .prepare( + `SELECT a.*, w.item, w.city, i.name_en + FROM watch_alerts a + JOIN watch_rules w ON w.id = a.rule_id + LEFT JOIN items i ON i.unique_name = w.item + ${unseenOnly ? 'WHERE a.seen = 0' : ''} + ORDER BY a.id DESC LIMIT 100`, + ) + .all(); +} + +export function markAlertsSeen(): void { + getDb().prepare('UPDATE watch_alerts SET seen = 1 WHERE seen = 0').run(); +} + +// --------------------------------------------------------------------------- + +export interface PollResult { + checkedRules: number; + newAlerts: number; + error?: string; + at: string; +} + +let lastPoll: PollResult | null = null; +let timer: NodeJS.Timeout | null = null; + +export function getWatcherStatus() { + return { + lastPoll, + intervalMinutes: Number(getMeta('watch_interval_min') ?? 15), + running: timer != null, + }; +} + +/** Fetch fresh prices for all watched item/city pairs and evaluate the rules. */ +export async function pollWatches(): Promise { + const db = getDb(); + const rules = listRules().filter((r) => r.enabled); + const result: PollResult = { checkedRules: rules.length, newAlerts: 0, at: new Date().toISOString() }; + if (rules.length === 0) { + lastPoll = result; + return result; + } + + try { + const items = [...new Set(rules.map((r) => r.item))]; + const cities = [...new Set(rules.map((r) => r.city))]; + const rows = await fetchPrices({ server: getAlbionServer(), items, cities }); + + const upsert = db.prepare(` + INSERT INTO market_prices (item, city, quality, sell_price_min, sell_min_date, buy_price_max, buy_max_date, fetched_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(item, city, quality) DO UPDATE SET + sell_price_min = excluded.sell_price_min, + sell_min_date = excluded.sell_min_date, + buy_price_max = excluded.buy_price_max, + buy_max_date = excluded.buy_max_date, + fetched_at = excluded.fetched_at + `); + const now = new Date().toISOString(); + db.transaction(() => { + for (const r of rows) { + const sellDate = normalizeAodpDate(r.sell_price_min_date); + const buyDate = normalizeAodpDate(r.buy_price_max_date); + if (!sellDate && !buyDate) continue; + upsert.run(r.item_id, r.city, r.quality, r.sell_price_min > 0 ? r.sell_price_min : null, sellDate, r.buy_price_max > 0 ? r.buy_price_max : null, buyDate, now); + } + })(); + } catch (e) { + result.error = e instanceof Error ? e.message : String(e); + lastPoll = result; + setMeta('watch_last_error', result.error); + return result; + } + + const insertAlert = db.prepare('INSERT INTO watch_alerts (rule_id, message, price, triggered_at, seen) VALUES (?, ?, ?, ?, 0)'); + const hasUnseen = db.prepare('SELECT COUNT(*) AS n FROM watch_alerts WHERE rule_id = ? AND seen = 0'); + + for (const rule of rules) { + const prices = getPricesForItem(rule.item).filter((p) => p.city === rule.city && p.quality === rule.quality); + if (prices.length === 0) continue; + const p = prices[0]; + let triggered = false; + let price = 0; + if (rule.condition === 'sell_below' && p.sellPrice != null && p.sellPrice <= rule.threshold) { + triggered = true; + price = p.sellPrice; + } else if (rule.condition === 'sell_above' && p.sellPrice != null && p.sellPrice >= rule.threshold) { + triggered = true; + price = p.sellPrice; + } else if (rule.condition === 'buy_above' && p.buyPrice != null && p.buyPrice >= rule.threshold) { + triggered = true; + price = p.buyPrice; + } + if (!triggered) continue; + // don't stack duplicate alerts while one is still unread + const unseen = hasUnseen.get(rule.id) as { n: number }; + if (unseen.n > 0) continue; + insertAlert.run(rule.id, CONDITION_LABELS[rule.condition](rule, price), price, new Date().toISOString()); + result.newAlerts++; + } + + lastPoll = result; + return result; +} + +export function startWatcherLoop(): void { + if (timer) clearInterval(timer); + const minutes = Math.max(2, Number(getMeta('watch_interval_min') ?? 15)); + timer = setInterval(() => { + void pollWatches().catch(() => undefined); + }, minutes * 60_000); + timer.unref?.(); +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 2ee75c8..eddeff7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -6,10 +6,12 @@ import SettingsPage from './pages/SettingsPage'; import OpportunitiesPage from './pages/OpportunitiesPage'; import CharacterPage from './pages/CharacterPage'; import TravelPage from './pages/TravelPage'; +import WatchPage from './pages/WatchPage'; const PAGES = [ { id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage }, { id: 'travel', label: 'Travel Planner', component: TravelPage }, + { id: 'watch', label: 'Market Watch', component: WatchPage }, { id: 'character', label: 'Character', component: CharacterPage }, { id: 'market', label: 'Market Data', component: MarketPage }, { id: 'items', label: 'Item Browser', component: ItemsPage }, diff --git a/web/src/pages/WatchPage.tsx b/web/src/pages/WatchPage.tsx new file mode 100644 index 0000000..efeec38 --- /dev/null +++ b/web/src/pages/WatchPage.tsx @@ -0,0 +1,217 @@ +import { useEffect, useState } from 'react'; + +interface Rule { + id: number; + item: string; + itemName: string | null; + city: string; + quality: number; + condition: 'sell_below' | 'sell_above' | 'buy_above'; + threshold: number; + enabled: boolean; +} + +interface Alert { + id: number; + item: string; + city: string; + name_en: string | null; + message: string; + triggered_at: string; + seen: number; +} + +interface Status { + lastPoll: { checkedRules: number; newAlerts: number; error?: string; at: string } | null; + intervalMinutes: number; + running: boolean; +} + +const CITIES = ['Bridgewatch', 'Caerleon', 'Fort Sterling', 'Lymhurst', 'Martlock', 'Thetford', 'Brecilien', 'Black Market']; +const CONDITIONS = [ + { value: 'sell_below', label: 'sell price drops below (buy cheap)' }, + { value: 'sell_above', label: 'sell price rises above (sell your stock)' }, + { value: 'buy_above', label: 'buy orders rise above (instant-sell)' }, +] as const; + +export default function WatchPage() { + const [rules, setRules] = useState([]); + const [alerts, setAlerts] = useState([]); + const [status, setStatus] = useState(null); + const [q, setQ] = useState(''); + const [results, setResults] = useState<{ unique_name: string; name_en: string | null }[]>([]); + const [item, setItem] = useState<{ unique_name: string; name_en: string | null } | null>(null); + const [city, setCity] = useState('Lymhurst'); + const [condition, setCondition] = useState('sell_below'); + const [threshold, setThreshold] = useState(''); + const [polling, setPolling] = useState(false); + const [intervalMin, setIntervalMin] = useState(15); + + const load = async () => { + const r = await fetch('/api/watch/rules').then((x) => x.json()); + setRules(r.rules ?? []); + setStatus(r.status ?? null); + if (r.status?.intervalMinutes) setIntervalMin(r.status.intervalMinutes); + const a = await fetch('/api/watch/alerts').then((x) => x.json()); + setAlerts(a.alerts ?? []); + }; + useEffect(() => { + load(); + }, []); + + useEffect(() => { + if (!q.trim()) return setResults([]); + const t = setTimeout(() => { + fetch(`/api/items/search?q=${encodeURIComponent(q)}`) + .then((r) => r.json()) + .then((d) => setResults(d.results ?? [])); + }, 250); + return () => clearTimeout(t); + }, [q]); + + const addRule = async () => { + if (!item || threshold === '') return; + await fetch('/api/watch/rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ item: item.unique_name, city, condition, threshold: Number(threshold) }), + }); + setItem(null); + setThreshold(''); + load(); + }; + + const removeRule = async (id: number) => { + await fetch(`/api/watch/rules/${id}`, { method: 'DELETE' }); + load(); + }; + + const toggleRule = async (r: Rule) => { + await fetch(`/api/watch/rules/${r.id}/enabled`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled: !r.enabled }), + }); + load(); + }; + + const pollNow = async () => { + setPolling(true); + try { + await fetch('/api/watch/poll', { method: 'POST' }); + await load(); + } finally { + setPolling(false); + } + }; + + const markSeen = async () => { + await fetch('/api/watch/alerts/seen', { method: 'POST' }); + load(); + }; + + const saveInterval = async () => { + await fetch('/api/watch/interval', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ minutes: intervalMin }), + }); + load(); + }; + + const unseen = alerts.filter((a) => !a.seen).length; + + return ( +
+

Market Watch

+ +
+
+ + Checks every{' '} + setIntervalMin(Number(e.target.value))} />{' '} + minutes + + + + {status?.lastPoll && ( + + last check {new Date(status.lastPoll.at).toLocaleTimeString()}: {status.lastPoll.checkedRules} rules,{' '} + {status.lastPoll.newAlerts} new alerts + + )} +
+ {status?.lastPoll?.error &&

{status.lastPoll.error}

} +
+ +
+

Add watch rule

+
+ {item ? ( + + {item.name_en ?? item.unique_name} + + ) : ( + setQ(e.target.value)} /> + )} + + + setThreshold(e.target.value)} /> + +
+ {!item && results.length > 0 && ( + + + {results.slice(0, 8).map((r) => ( + { setItem(r); setResults([]); setQ(''); }}> + + + + ))} + +
{r.name_en ?? r.unique_name}{r.unique_name}
+ )} + {rules.length > 0 && ( + + + + + + {rules.map((r) => ( + + + + + + + + + ))} + +
ItemCityConditionThreshold
{r.itemName ?? r.item}{r.city}{CONDITIONS.find((c) => c.value === r.condition)?.label}{r.threshold.toLocaleString()}
+ )} +
+ +
+

+ Alerts {unseen > 0 && {unseen} new} +

+ {unseen > 0 && } + {alerts.length === 0 &&

No alerts yet.

} +
    + {alerts.map((a) => ( +
  • + [{new Date(a.triggered_at).toLocaleString()}] {a.name_en ?? a.item} @ {a.city}: {a.message} +
  • + ))} +
+
+
+ ); +}