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 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,
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
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 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 },
|
||||
|
|
|
|||
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