Phase 6: market watcher

Watch rules (sell below/above, buy above) per item+city, background
poll loop with configurable interval, fresh price fetch for watched
items, alert feed with unseen tracking, Market Watch page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Заид Омар Медхат 2026-08-23 03:28:13 +05:00
parent 5706248a6b
commit 4c16fead97
6 changed files with 482 additions and 0 deletions

View file

@ -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 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 ( CREATE TABLE IF NOT EXISTS zones (
albion_id TEXT PRIMARY KEY, albion_id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,

View file

@ -7,6 +7,8 @@ import { marketRoutes } from './routes/market.js';
import { economyRoutes } from './routes/economy.js'; import { economyRoutes } from './routes/economy.js';
import { profileRoutes } from './routes/profiles.js'; import { profileRoutes } from './routes/profiles.js';
import { travelRoutes } from './routes/travel.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' } }); const app = Fastify({ logger: { level: 'info' } });
@ -19,6 +21,8 @@ await app.register(marketRoutes);
await app.register(economyRoutes); await app.register(economyRoutes);
await app.register(profileRoutes); await app.register(profileRoutes);
await app.register(travelRoutes); await app.register(travelRoutes);
await app.register(watchRoutes);
startWatcherLoop();
await app.listen({ port: API_PORT, host: '127.0.0.1' }); await app.listen({ port: API_PORT, host: '127.0.0.1' });
console.log(`API listening on http://127.0.0.1:${API_PORT}`); console.log(`API listening on http://127.0.0.1:${API_PORT}`);

View file

@ -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<void> {
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 };
});
}

View file

@ -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<WatchCondition, (rule: WatchRule, price: number) => 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<PollResult> {
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?.();
}

View file

@ -6,10 +6,12 @@ import SettingsPage from './pages/SettingsPage';
import OpportunitiesPage from './pages/OpportunitiesPage'; import OpportunitiesPage from './pages/OpportunitiesPage';
import CharacterPage from './pages/CharacterPage'; import CharacterPage from './pages/CharacterPage';
import TravelPage from './pages/TravelPage'; import TravelPage from './pages/TravelPage';
import WatchPage from './pages/WatchPage';
const PAGES = [ const PAGES = [
{ id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage }, { id: 'opportunities', label: 'Opportunities', component: OpportunitiesPage },
{ id: 'travel', label: 'Travel Planner', component: TravelPage }, { id: 'travel', label: 'Travel Planner', component: TravelPage },
{ id: 'watch', label: 'Market Watch', component: WatchPage },
{ id: 'character', label: 'Character', component: CharacterPage }, { id: 'character', label: 'Character', component: CharacterPage },
{ id: 'market', label: 'Market Data', component: MarketPage }, { id: 'market', label: 'Market Data', component: MarketPage },
{ id: 'items', label: 'Item Browser', component: ItemsPage }, { id: 'items', label: 'Item Browser', component: ItemsPage },

217
web/src/pages/WatchPage.tsx Normal file
View file

@ -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<Rule[]>([]);
const [alerts, setAlerts] = useState<Alert[]>([]);
const [status, setStatus] = useState<Status | null>(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<Rule['condition']>('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 (
<div>
<h2>Market Watch</h2>
<div className="card">
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
<span>
Checks every{' '}
<input type="number" min={2} value={intervalMin} style={{ width: 60 }}
onChange={(e) => setIntervalMin(Number(e.target.value))} />{' '}
minutes
</span>
<button onClick={saveInterval}>Save interval</button>
<button className="primary" disabled={polling} onClick={pollNow}>{polling ? 'Checking…' : 'Check now'}</button>
{status?.lastPoll && (
<span className="muted">
last check {new Date(status.lastPoll.at).toLocaleTimeString()}: {status.lastPoll.checkedRules} rules,{' '}
{status.lastPoll.newAlerts} new alerts
</span>
)}
</div>
{status?.lastPoll?.error && <p style={{ color: '#e08869' }}>{status.lastPoll.error}</p>}
</div>
<div className="card">
<h3>Add watch rule</h3>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
{item ? (
<span>
{item.name_en ?? item.unique_name} <button onClick={() => setItem(null)}>change</button>
</span>
) : (
<input type="text" placeholder="Search item…" value={q} onChange={(e) => setQ(e.target.value)} />
)}
<select value={city} onChange={(e) => setCity(e.target.value)}>
{CITIES.map((c) => <option key={c}>{c}</option>)}
</select>
<select value={condition} onChange={(e) => setCondition(e.target.value as Rule['condition'])}>
{CONDITIONS.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
<input type="number" placeholder="threshold silver" value={threshold} style={{ width: 130 }}
onChange={(e) => setThreshold(e.target.value)} />
<button className="primary" disabled={!item || threshold === ''} onClick={addRule}>Add</button>
</div>
{!item && results.length > 0 && (
<table style={{ marginTop: 8 }}>
<tbody>
{results.slice(0, 8).map((r) => (
<tr key={r.unique_name} className="clickable" onClick={() => { setItem(r); setResults([]); setQ(''); }}>
<td>{r.name_en ?? r.unique_name}</td>
<td className="muted">{r.unique_name}</td>
</tr>
))}
</tbody>
</table>
)}
{rules.length > 0 && (
<table style={{ marginTop: 12 }}>
<thead>
<tr><th>Item</th><th>City</th><th>Condition</th><th>Threshold</th><th></th><th></th></tr>
</thead>
<tbody>
{rules.map((r) => (
<tr key={r.id} style={{ opacity: r.enabled ? 1 : 0.5 }}>
<td>{r.itemName ?? r.item}</td>
<td>{r.city}</td>
<td className="muted">{CONDITIONS.find((c) => c.value === r.condition)?.label}</td>
<td>{r.threshold.toLocaleString()}</td>
<td><button onClick={() => toggleRule(r)}>{r.enabled ? 'Pause' : 'Resume'}</button></td>
<td><button onClick={() => removeRule(r.id)}>Delete</button></td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="card">
<h3>
Alerts {unseen > 0 && <span className="tag" style={{ color: '#e0c069' }}>{unseen} new</span>}
</h3>
{unseen > 0 && <button onClick={markSeen}>Mark all seen</button>}
{alerts.length === 0 && <p className="muted">No alerts yet.</p>}
<ul>
{alerts.map((a) => (
<li key={a.id} style={{ fontWeight: a.seen ? 400 : 600, marginBottom: 4 }}>
[{new Date(a.triggered_at).toLocaleString()}] {a.name_en ?? a.item} @ {a.city}: {a.message}
</li>
))}
</ul>
</div>
</div>
);
}