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:
parent
5706248a6b
commit
4c16fead97
6 changed files with 482 additions and 0 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
|
|
||||||
64
server/src/routes/watch.ts
Normal file
64
server/src/routes/watch.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
175
server/src/watcher/service.ts
Normal file
175
server/src/watcher/service.ts
Normal 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?.();
|
||||||
|
}
|
||||||
|
|
@ -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
217
web/src/pages/WatchPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue