Phase 2: market data layer
AODP price client (chunked, throttled, proxy-aware), snapshot storage with freshness ages, manual price overrides that outrank API data, async refresh job with progress, Market Data + Settings pages. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
7ba275dd77
commit
7ff3e6ec95
11 changed files with 832 additions and 2 deletions
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -3422,6 +3422,15 @@
|
|||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "8.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
|
||||
"integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
|
|
@ -3729,7 +3738,8 @@
|
|||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"fastify": "^5.4.0"
|
||||
"fastify": "^5.4.0",
|
||||
"undici": "^8.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"fastify": "^5.4.0"
|
||||
"fastify": "^5.4.0",
|
||||
"undici": "^8.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,30 @@ CREATE TABLE IF NOT EXISTS recipe_inputs (
|
|||
PRIMARY KEY (recipe_id, input_item)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_recipe_inputs_item ON recipe_inputs(input_item);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS market_prices (
|
||||
item TEXT NOT NULL,
|
||||
city TEXT NOT NULL,
|
||||
quality INTEGER NOT NULL DEFAULT 1,
|
||||
sell_price_min REAL,
|
||||
sell_min_date TEXT, -- when AODP last observed this sell order (UTC ISO)
|
||||
buy_price_max REAL,
|
||||
buy_max_date TEXT,
|
||||
fetched_at TEXT NOT NULL,
|
||||
PRIMARY KEY (item, city, quality)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_market_prices_city ON market_prices(city);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS price_overrides (
|
||||
item TEXT NOT NULL,
|
||||
city TEXT NOT NULL,
|
||||
quality INTEGER NOT NULL DEFAULT 1,
|
||||
sell_price REAL,
|
||||
buy_price REAL,
|
||||
note TEXT,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (item, city, quality)
|
||||
);
|
||||
`;
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { API_PORT } from './config.js';
|
|||
import { getDb } from './db/index.js';
|
||||
import { gamedataRoutes } from './routes/gamedata.js';
|
||||
import { itemRoutes } from './routes/items.js';
|
||||
import { marketRoutes } from './routes/market.js';
|
||||
|
||||
const app = Fastify({ logger: { level: 'info' } });
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ getDb(); // initialize schema on startup
|
|||
app.get('/api/health', async () => ({ ok: true }));
|
||||
await app.register(gamedataRoutes);
|
||||
await app.register(itemRoutes);
|
||||
await app.register(marketRoutes);
|
||||
|
||||
await app.listen({ port: API_PORT, host: '127.0.0.1' });
|
||||
console.log(`API listening on http://127.0.0.1:${API_PORT}`);
|
||||
|
|
|
|||
116
server/src/market/client.ts
Normal file
116
server/src/market/client.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/** Client for the Albion Online Data Project (AODP) REST API. */
|
||||
import { fetch as undiciFetch, ProxyAgent } from 'undici';
|
||||
import { getMeta } from '../db/index.js';
|
||||
|
||||
/**
|
||||
* AODP is blocked in some regions. If the user's VPN is browser-only, they can
|
||||
* instead set a proxy (Settings page, or the HTTPS_PROXY env var) and the app
|
||||
* routes its market requests through it.
|
||||
*/
|
||||
function getProxyUrl(): string | null {
|
||||
return getMeta('proxy_url') || process.env.HTTPS_PROXY || process.env.HTTP_PROXY || null;
|
||||
}
|
||||
|
||||
async function proxyAwareFetch(url: string, timeoutMs: number): Promise<{ ok: boolean; status: number; json(): Promise<unknown> }> {
|
||||
const proxyUrl = getProxyUrl();
|
||||
const signal = AbortSignal.timeout(timeoutMs);
|
||||
if (proxyUrl) {
|
||||
return undiciFetch(url, { signal, dispatcher: new ProxyAgent(proxyUrl) });
|
||||
}
|
||||
return fetch(url, { signal });
|
||||
}
|
||||
|
||||
export const SERVER_HOSTS: Record<string, string> = {
|
||||
europe: 'europe.albion-online-data.com',
|
||||
americas: 'west.albion-online-data.com',
|
||||
asia: 'east.albion-online-data.com',
|
||||
};
|
||||
|
||||
/** Locations with player markets we track by default. */
|
||||
export const CITIES = [
|
||||
'Bridgewatch',
|
||||
'Caerleon',
|
||||
'Fort Sterling',
|
||||
'Lymhurst',
|
||||
'Martlock',
|
||||
'Thetford',
|
||||
'Brecilien',
|
||||
'Black Market',
|
||||
] as const;
|
||||
|
||||
export interface AodpPriceRow {
|
||||
item_id: string;
|
||||
city: string;
|
||||
quality: number;
|
||||
sell_price_min: number;
|
||||
sell_price_min_date: string;
|
||||
sell_price_max: number;
|
||||
sell_price_max_date: string;
|
||||
buy_price_max: number;
|
||||
buy_price_max_date: string;
|
||||
buy_price_min: number;
|
||||
buy_price_min_date: string;
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 100; // item ids per request (keeps URLs short)
|
||||
const REQUEST_DELAY_MS = 500; // stay well under AODP's ~180 req/min limit
|
||||
const REQUEST_TIMEOUT_MS = 20000;
|
||||
|
||||
/** AODP uses 0001-01-01T00:00:00 as "never observed". */
|
||||
export function normalizeAodpDate(d: string | null | undefined): string | null {
|
||||
if (!d || d.startsWith('0001-')) return null;
|
||||
return d.endsWith('Z') ? d : `${d}Z`; // AODP timestamps are UTC without the Z
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export class AodpUnreachableError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super(
|
||||
'Market data API (albion-online-data.com) is unreachable. ' +
|
||||
'If you are in a region where it is blocked, check that your VPN is on. ' +
|
||||
`(${cause instanceof Error ? cause.message : String(cause)})`,
|
||||
);
|
||||
this.name = 'AodpUnreachableError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface FetchPricesOptions {
|
||||
server: string; // key of SERVER_HOSTS
|
||||
items: string[];
|
||||
cities?: string[];
|
||||
qualities?: number[];
|
||||
onProgress?: (done: number, total: number) => void;
|
||||
}
|
||||
|
||||
export async function fetchPrices(opts: FetchPricesOptions): Promise<AodpPriceRow[]> {
|
||||
const host = SERVER_HOSTS[opts.server] ?? SERVER_HOSTS.europe;
|
||||
const cities = opts.cities ?? [...CITIES];
|
||||
const qualities = opts.qualities ?? [1, 2, 3, 4, 5];
|
||||
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < opts.items.length; i += CHUNK_SIZE) {
|
||||
chunks.push(opts.items.slice(i, i + CHUNK_SIZE));
|
||||
}
|
||||
|
||||
const all: AodpPriceRow[] = [];
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const url =
|
||||
`https://${host}/api/v2/stats/prices/${encodeURIComponent(chunks[i].join(','))}` +
|
||||
`?locations=${encodeURIComponent(cities.join(','))}` +
|
||||
`&qualities=${qualities.join(',')}`;
|
||||
try {
|
||||
const res = await proxyAwareFetch(url, REQUEST_TIMEOUT_MS);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const rows = (await res.json()) as AodpPriceRow[];
|
||||
all.push(...rows);
|
||||
} catch (e) {
|
||||
throw new AodpUnreachableError(e);
|
||||
}
|
||||
opts.onProgress?.(i + 1, chunks.length);
|
||||
if (i < chunks.length - 1) await sleep(REQUEST_DELAY_MS);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
249
server/src/market/service.ts
Normal file
249
server/src/market/service.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { getDb, getMeta, setMeta } from '../db/index.js';
|
||||
import {
|
||||
AodpUnreachableError,
|
||||
CITIES,
|
||||
fetchPrices,
|
||||
normalizeAodpDate,
|
||||
SERVER_HOSTS,
|
||||
} from './client.js';
|
||||
|
||||
export function getAlbionServer(): string {
|
||||
const s = getMeta('albion_server');
|
||||
return s && s in SERVER_HOSTS ? s : 'europe';
|
||||
}
|
||||
|
||||
export function setAlbionServer(server: string): void {
|
||||
if (!(server in SERVER_HOSTS)) throw new Error(`unknown server: ${server}`);
|
||||
setMeta('albion_server', server);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Refresh job (long-running; started async, polled by the UI)
|
||||
|
||||
export interface RefreshJob {
|
||||
status: 'idle' | 'running' | 'done' | 'error';
|
||||
totalChunks: number;
|
||||
doneChunks: number;
|
||||
itemCount: number;
|
||||
rowsUpserted: number;
|
||||
error?: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
}
|
||||
|
||||
let job: RefreshJob = { status: 'idle', totalChunks: 0, doneChunks: 0, itemCount: 0, rowsUpserted: 0 };
|
||||
|
||||
export function getRefreshJob(): RefreshJob {
|
||||
return job;
|
||||
}
|
||||
|
||||
export interface RefreshOptions {
|
||||
minTier?: number;
|
||||
maxTier?: number;
|
||||
cities?: string[];
|
||||
items?: string[]; // explicit list overrides tier-based selection
|
||||
}
|
||||
|
||||
/** Items worth pricing: everything that appears in a recipe (as output or input). */
|
||||
function selectRecipeRelevantItems(minTier: number, maxTier: number): string[] {
|
||||
const rows = getDb()
|
||||
.prepare(
|
||||
`SELECT DISTINCT i.unique_name AS name
|
||||
FROM items i
|
||||
WHERE i.tier BETWEEN ? AND ?
|
||||
AND (
|
||||
EXISTS (SELECT 1 FROM recipes r WHERE r.output_item = i.unique_name)
|
||||
OR EXISTS (SELECT 1 FROM recipe_inputs ri WHERE ri.input_item = i.unique_name)
|
||||
)`,
|
||||
)
|
||||
.all(minTier, maxTier) as { name: string }[];
|
||||
return rows.map((r) => r.name);
|
||||
}
|
||||
|
||||
export function startRefresh(opts: RefreshOptions): { started: boolean; reason?: string } {
|
||||
if (job.status === 'running') return { started: false, reason: 'a refresh is already running' };
|
||||
|
||||
const items =
|
||||
opts.items && opts.items.length > 0
|
||||
? opts.items
|
||||
: selectRecipeRelevantItems(opts.minTier ?? 2, opts.maxTier ?? 8);
|
||||
if (items.length === 0) return { started: false, reason: 'no items match the selection' };
|
||||
|
||||
const cities = opts.cities && opts.cities.length > 0 ? opts.cities : [...CITIES];
|
||||
job = {
|
||||
status: 'running',
|
||||
totalChunks: Math.ceil(items.length / 100),
|
||||
doneChunks: 0,
|
||||
itemCount: items.length,
|
||||
rowsUpserted: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const rows = await fetchPrices({
|
||||
server: getAlbionServer(),
|
||||
items,
|
||||
cities,
|
||||
onProgress: (done, total) => {
|
||||
job.doneChunks = done;
|
||||
job.totalChunks = total;
|
||||
},
|
||||
});
|
||||
|
||||
const db = getDb();
|
||||
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();
|
||||
let upserted = 0;
|
||||
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; // never observed anywhere
|
||||
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,
|
||||
);
|
||||
upserted++;
|
||||
}
|
||||
})();
|
||||
|
||||
job.rowsUpserted = upserted;
|
||||
job.status = 'done';
|
||||
job.finishedAt = new Date().toISOString();
|
||||
setMeta('market_last_refresh', job.finishedAt);
|
||||
} catch (e) {
|
||||
job.status = 'error';
|
||||
job.finishedAt = new Date().toISOString();
|
||||
job.error = e instanceof AodpUnreachableError ? e.message : String(e);
|
||||
}
|
||||
})();
|
||||
|
||||
return { started: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reads (always merge manual overrides over API data)
|
||||
|
||||
export interface MergedPrice {
|
||||
item: string;
|
||||
city: string;
|
||||
quality: number;
|
||||
sellPrice: number | null;
|
||||
buyPrice: number | null;
|
||||
sellSource: 'api' | 'manual' | null;
|
||||
buySource: 'api' | 'manual' | null;
|
||||
sellAgeHours: number | null; // null for manual overrides / missing
|
||||
buyAgeHours: number | null;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
function ageHours(iso: string | null): number | null {
|
||||
if (!iso) return null;
|
||||
const t = Date.parse(iso);
|
||||
if (!Number.isFinite(t)) return null;
|
||||
return Math.max(0, (Date.now() - t) / 3600000);
|
||||
}
|
||||
|
||||
export function getPricesForItem(item: string): MergedPrice[] {
|
||||
const db = getDb();
|
||||
const api = db.prepare('SELECT * FROM market_prices WHERE item = ?').all(item) as any[];
|
||||
const overrides = db.prepare('SELECT * FROM price_overrides WHERE item = ?').all(item) as any[];
|
||||
|
||||
const byKey = new Map<string, MergedPrice>();
|
||||
for (const r of api) {
|
||||
byKey.set(`${r.city}|${r.quality}`, {
|
||||
item,
|
||||
city: r.city,
|
||||
quality: r.quality,
|
||||
sellPrice: r.sell_price_min,
|
||||
buyPrice: r.buy_price_max,
|
||||
sellSource: r.sell_price_min != null ? 'api' : null,
|
||||
buySource: r.buy_price_max != null ? 'api' : null,
|
||||
sellAgeHours: ageHours(r.sell_min_date),
|
||||
buyAgeHours: ageHours(r.buy_max_date),
|
||||
note: null,
|
||||
});
|
||||
}
|
||||
for (const o of overrides) {
|
||||
const key = `${o.city}|${o.quality}`;
|
||||
const existing = byKey.get(key) ?? {
|
||||
item,
|
||||
city: o.city,
|
||||
quality: o.quality,
|
||||
sellPrice: null,
|
||||
buyPrice: null,
|
||||
sellSource: null,
|
||||
buySource: null,
|
||||
sellAgeHours: null,
|
||||
buyAgeHours: null,
|
||||
note: null,
|
||||
};
|
||||
if (o.sell_price != null) {
|
||||
existing.sellPrice = o.sell_price;
|
||||
existing.sellSource = 'manual';
|
||||
existing.sellAgeHours = ageHours(o.updated_at);
|
||||
}
|
||||
if (o.buy_price != null) {
|
||||
existing.buyPrice = o.buy_price;
|
||||
existing.buySource = 'manual';
|
||||
existing.buyAgeHours = ageHours(o.updated_at);
|
||||
}
|
||||
existing.note = o.note ?? existing.note;
|
||||
byKey.set(key, existing);
|
||||
}
|
||||
return [...byKey.values()].sort((a, b) => a.city.localeCompare(b.city) || a.quality - b.quality);
|
||||
}
|
||||
|
||||
export function setOverride(o: {
|
||||
item: string;
|
||||
city: string;
|
||||
quality?: number;
|
||||
sellPrice?: number | null;
|
||||
buyPrice?: number | null;
|
||||
note?: string | null;
|
||||
}): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO price_overrides (item, city, quality, sell_price, buy_price, note, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(item, city, quality) DO UPDATE SET
|
||||
sell_price = excluded.sell_price,
|
||||
buy_price = excluded.buy_price,
|
||||
note = excluded.note,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(o.item, o.city, o.quality ?? 1, o.sellPrice ?? null, o.buyPrice ?? null, o.note ?? null, new Date().toISOString());
|
||||
}
|
||||
|
||||
export function deleteOverride(item: string, city: string, quality = 1): void {
|
||||
getDb().prepare('DELETE FROM price_overrides WHERE item = ? AND city = ? AND quality = ?').run(item, city, quality);
|
||||
}
|
||||
|
||||
export function marketSummary() {
|
||||
const db = getDb();
|
||||
const n = (sql: string) => (db.prepare(sql).get() as { n: number }).n;
|
||||
return {
|
||||
server: getAlbionServer(),
|
||||
lastRefresh: getMeta('market_last_refresh'),
|
||||
pricedRows: n('SELECT COUNT(*) AS n FROM market_prices'),
|
||||
pricedItems: n('SELECT COUNT(DISTINCT item) AS n FROM market_prices'),
|
||||
overrides: n('SELECT COUNT(*) AS n FROM price_overrides'),
|
||||
job: getRefreshJob(),
|
||||
};
|
||||
}
|
||||
90
server/src/routes/market.ts
Normal file
90
server/src/routes/market.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import type { FastifyInstance } from 'fastify';
|
||||
import { getDb, getMeta, setMeta } from '../db/index.js';
|
||||
import { CITIES, SERVER_HOSTS } from '../market/client.js';
|
||||
import {
|
||||
deleteOverride,
|
||||
getPricesForItem,
|
||||
getRefreshJob,
|
||||
marketSummary,
|
||||
setAlbionServer,
|
||||
setOverride,
|
||||
startRefresh,
|
||||
} from '../market/service.js';
|
||||
|
||||
export async function marketRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get('/api/market/summary', async () => marketSummary());
|
||||
|
||||
app.get('/api/market/cities', async () => ({ cities: CITIES }));
|
||||
|
||||
app.get('/api/market/refresh/status', async () => getRefreshJob());
|
||||
|
||||
app.post('/api/market/refresh', async (req, reply) => {
|
||||
const body = (req.body ?? {}) as {
|
||||
minTier?: number;
|
||||
maxTier?: number;
|
||||
cities?: string[];
|
||||
items?: string[];
|
||||
};
|
||||
const result = startRefresh(body);
|
||||
if (!result.started) return reply.code(409).send({ error: result.reason });
|
||||
return { started: true };
|
||||
});
|
||||
|
||||
app.get('/api/market/prices/:item', async (req) => {
|
||||
const { item } = req.params as { item: string };
|
||||
return { prices: getPricesForItem(item) };
|
||||
});
|
||||
|
||||
app.put('/api/market/override', async (req, reply) => {
|
||||
const body = req.body as {
|
||||
item?: string;
|
||||
city?: string;
|
||||
quality?: number;
|
||||
sellPrice?: number | null;
|
||||
buyPrice?: number | null;
|
||||
note?: string | null;
|
||||
};
|
||||
if (!body?.item || !body?.city) return reply.code(400).send({ error: 'item and city are required' });
|
||||
setOverride({
|
||||
item: body.item,
|
||||
city: body.city,
|
||||
quality: body.quality,
|
||||
sellPrice: body.sellPrice,
|
||||
buyPrice: body.buyPrice,
|
||||
note: body.note,
|
||||
});
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.delete('/api/market/override', async (req, reply) => {
|
||||
const { item, city, quality } = req.query as { item?: string; city?: string; quality?: string };
|
||||
if (!item || !city) return reply.code(400).send({ error: 'item and city are required' });
|
||||
deleteOverride(item, city, quality ? Number(quality) : 1);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/settings', async () => ({
|
||||
albionServer: marketSummary().server,
|
||||
availableServers: Object.keys(SERVER_HOSTS),
|
||||
proxyUrl: getMeta('proxy_url') ?? '',
|
||||
}));
|
||||
|
||||
app.put('/api/settings', async (req, reply) => {
|
||||
const { albionServer, proxyUrl } = (req.body ?? {}) as { albionServer?: string; proxyUrl?: string };
|
||||
if (albionServer) {
|
||||
try {
|
||||
setAlbionServer(albionServer);
|
||||
} catch (e) {
|
||||
return reply.code(400).send({ error: String(e) });
|
||||
}
|
||||
}
|
||||
if (proxyUrl !== undefined) {
|
||||
if (proxyUrl === '') {
|
||||
getDb().prepare("DELETE FROM meta WHERE key = 'proxy_url'").run();
|
||||
} else {
|
||||
setMeta('proxy_url', proxyUrl);
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import { useState } from 'react';
|
||||
import ItemsPage from './pages/ItemsPage';
|
||||
import GameDataPage from './pages/GameDataPage';
|
||||
import MarketPage from './pages/MarketPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
|
||||
const PAGES = [
|
||||
{ id: 'market', label: 'Market Data', component: MarketPage },
|
||||
{ id: 'items', label: 'Item Browser', component: ItemsPage },
|
||||
{ id: 'gamedata', label: 'Game Data', component: GameDataPage },
|
||||
{ id: 'settings', label: 'Settings', component: SettingsPage },
|
||||
] as const;
|
||||
|
||||
export default function App() {
|
||||
|
|
|
|||
254
web/src/pages/MarketPage.tsx
Normal file
254
web/src/pages/MarketPage.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface Job {
|
||||
status: 'idle' | 'running' | 'done' | 'error';
|
||||
totalChunks: number;
|
||||
doneChunks: number;
|
||||
itemCount: number;
|
||||
rowsUpserted: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
server: string;
|
||||
lastRefresh: string | null;
|
||||
pricedRows: number;
|
||||
pricedItems: number;
|
||||
overrides: number;
|
||||
job: Job;
|
||||
}
|
||||
|
||||
interface Price {
|
||||
item: string;
|
||||
city: string;
|
||||
quality: number;
|
||||
sellPrice: number | null;
|
||||
buyPrice: number | null;
|
||||
sellSource: 'api' | 'manual' | null;
|
||||
buySource: 'api' | 'manual' | null;
|
||||
sellAgeHours: number | null;
|
||||
buyAgeHours: number | null;
|
||||
}
|
||||
|
||||
function AgeBadge({ hours, source }: { hours: number | null; source: string | null }) {
|
||||
if (source === 'manual') return <span className="tag" style={{ color: '#7fb3ff' }}>manual</span>;
|
||||
if (hours == null) return <span className="tag">—</span>;
|
||||
const label = hours < 1 ? `${Math.round(hours * 60)}m` : hours < 48 ? `${Math.round(hours)}h` : `${Math.round(hours / 24)}d`;
|
||||
const color = hours < 6 ? '#7fd18a' : hours < 24 ? '#e0c069' : '#e08869';
|
||||
return <span className="tag" style={{ color }}>{label} old</span>;
|
||||
}
|
||||
|
||||
const fmt = (n: number | null) => (n == null ? '—' : n.toLocaleString('en-US'));
|
||||
|
||||
export default function MarketPage() {
|
||||
const [summary, setSummary] = useState<Summary | null>(null);
|
||||
const [minTier, setMinTier] = useState(2);
|
||||
const [maxTier, setMaxTier] = useState(8);
|
||||
const [q, setQ] = useState('');
|
||||
const [results, setResults] = useState<{ unique_name: string; name_en: string | null }[]>([]);
|
||||
const [selected, setSelected] = useState<{ unique_name: string; name_en: string | null } | null>(null);
|
||||
const [prices, setPrices] = useState<Price[]>([]);
|
||||
const [editing, setEditing] = useState<string | null>(null); // "city|quality"
|
||||
const [editSell, setEditSell] = useState('');
|
||||
const [editBuy, setEditBuy] = useState('');
|
||||
const pollRef = useRef<number | null>(null);
|
||||
|
||||
const loadSummary = useCallback(() => {
|
||||
fetch('/api/market/summary').then((r) => r.json()).then(setSummary);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSummary();
|
||||
}, [loadSummary]);
|
||||
|
||||
// poll while a refresh runs
|
||||
useEffect(() => {
|
||||
if (summary?.job.status === 'running' && pollRef.current == null) {
|
||||
pollRef.current = window.setInterval(loadSummary, 2000);
|
||||
}
|
||||
if (summary?.job.status !== 'running' && pollRef.current != null) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
return () => {
|
||||
if (pollRef.current != null) clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
};
|
||||
}, [summary?.job.status, loadSummary]);
|
||||
|
||||
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 loadPrices = useCallback((item: string) => {
|
||||
fetch(`/api/market/prices/${encodeURIComponent(item)}`)
|
||||
.then((r) => r.json())
|
||||
.then((d) => setPrices(d.prices ?? []));
|
||||
}, []);
|
||||
|
||||
const select = (it: { unique_name: string; name_en: string | null }) => {
|
||||
setSelected(it);
|
||||
setResults([]);
|
||||
setQ('');
|
||||
loadPrices(it.unique_name);
|
||||
};
|
||||
|
||||
const startRefresh = async () => {
|
||||
await fetch('/api/market/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ minTier, maxTier }),
|
||||
});
|
||||
loadSummary();
|
||||
};
|
||||
|
||||
const saveOverride = async (p: Price) => {
|
||||
await fetch('/api/market/override', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
item: p.item,
|
||||
city: p.city,
|
||||
quality: p.quality,
|
||||
sellPrice: editSell === '' ? null : Number(editSell),
|
||||
buyPrice: editBuy === '' ? null : Number(editBuy),
|
||||
}),
|
||||
});
|
||||
setEditing(null);
|
||||
loadPrices(p.item);
|
||||
loadSummary();
|
||||
};
|
||||
|
||||
const clearOverride = async (p: Price) => {
|
||||
await fetch(`/api/market/override?item=${encodeURIComponent(p.item)}&city=${encodeURIComponent(p.city)}&quality=${p.quality}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
setEditing(null);
|
||||
loadPrices(p.item);
|
||||
loadSummary();
|
||||
};
|
||||
|
||||
const job = summary?.job;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Market Data</h2>
|
||||
|
||||
<div className="card">
|
||||
<p>
|
||||
Server: <strong>{summary?.server ?? '…'}</strong> · {summary?.pricedItems ?? 0} items priced (
|
||||
{summary?.pricedRows ?? 0} rows) · {summary?.overrides ?? 0} manual overrides · last refresh:{' '}
|
||||
{summary?.lastRefresh ? new Date(summary.lastRefresh).toLocaleString() : 'never'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<label>
|
||||
Tier <select value={minTier} onChange={(e) => setMinTier(Number(e.target.value))}>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
to <select value={maxTier} onChange={(e) => setMaxTier(Number(e.target.value))}>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<button className="primary" disabled={job?.status === 'running'} onClick={startRefresh}>
|
||||
{job?.status === 'running' ? 'Refreshing…' : 'Download prices'}
|
||||
</button>
|
||||
{job?.status === 'running' && (
|
||||
<span className="muted">
|
||||
{job.doneChunks}/{job.totalChunks} batches ({job.itemCount} items)
|
||||
</span>
|
||||
)}
|
||||
{job?.status === 'done' && <span className="muted">Last run stored {job.rowsUpserted} price rows.</span>}
|
||||
</div>
|
||||
{job?.status === 'error' && (
|
||||
<p style={{ color: '#e08869' }}>
|
||||
{job.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Price lookup</h3>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search an item to see its prices…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
{results.length > 0 && (
|
||||
<table style={{ marginTop: 10 }}>
|
||||
<tbody>
|
||||
{results.slice(0, 10).map((r) => (
|
||||
<tr key={r.unique_name} className="clickable" onClick={() => select(r)}>
|
||||
<td>{r.name_en ?? r.unique_name}</td>
|
||||
<td className="muted">{r.unique_name}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<>
|
||||
<h4>
|
||||
{selected.name_en ?? selected.unique_name} <span className="tag">{selected.unique_name}</span>
|
||||
</h4>
|
||||
{prices.length === 0 && (
|
||||
<p className="muted">No price data yet — run a price download above, or add a manual price.</p>
|
||||
)}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>City</th><th>Q</th><th>Sell (min)</th><th></th><th>Buy (max)</th><th></th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{prices.map((p) => {
|
||||
const key = `${p.city}|${p.quality}`;
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>{p.city}</td>
|
||||
<td>{p.quality}</td>
|
||||
<td>{fmt(p.sellPrice)}</td>
|
||||
<td><AgeBadge hours={p.sellAgeHours} source={p.sellSource} /></td>
|
||||
<td>{fmt(p.buyPrice)}</td>
|
||||
<td><AgeBadge hours={p.buyAgeHours} source={p.buySource} /></td>
|
||||
<td>
|
||||
{editing === key ? (
|
||||
<span style={{ display: 'inline-flex', gap: 6 }}>
|
||||
<input type="number" placeholder="sell" value={editSell} style={{ width: 90 }}
|
||||
onChange={(e) => setEditSell(e.target.value)} />
|
||||
<input type="number" placeholder="buy" value={editBuy} style={{ width: 90 }}
|
||||
onChange={(e) => setEditBuy(e.target.value)} />
|
||||
<button className="primary" onClick={() => saveOverride(p)}>Save</button>
|
||||
<button onClick={() => clearOverride(p)}>Clear</button>
|
||||
<button onClick={() => setEditing(null)}>✕</button>
|
||||
</span>
|
||||
) : (
|
||||
<button onClick={() => {
|
||||
setEditing(key);
|
||||
setEditSell(p.sellSource === 'manual' && p.sellPrice != null ? String(p.sellPrice) : '');
|
||||
setEditBuy(p.buySource === 'manual' && p.buyPrice != null ? String(p.buyPrice) : '');
|
||||
}}>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
69
web/src/pages/SettingsPage.tsx
Normal file
69
web/src/pages/SettingsPage.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Settings {
|
||||
albionServer: string;
|
||||
availableServers: string[];
|
||||
proxyUrl: string;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [proxyUrl, setProxyUrl] = useState('');
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then((s: Settings) => {
|
||||
setSettings(s);
|
||||
setProxyUrl(s.proxyUrl);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const save = async (patch: Partial<{ albionServer: string; proxyUrl: string }>) => {
|
||||
await fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
const s = await fetch('/api/settings').then((r) => r.json());
|
||||
setSettings(s);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
};
|
||||
|
||||
if (!settings) return <p className="muted">Loading…</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Settings</h2>
|
||||
<div className="card">
|
||||
<h3>Albion server</h3>
|
||||
<p className="muted">Which game server's market prices to download.</p>
|
||||
<select value={settings.albionServer} onChange={(e) => save({ albionServer: e.target.value })}>
|
||||
{settings.availableServers.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="card">
|
||||
<h3>Proxy for market data (optional)</h3>
|
||||
<p className="muted">
|
||||
The market data API (albion-online-data.com) is blocked in some countries. If your VPN covers the whole
|
||||
system, leave this empty. If it is browser-only, enter an HTTP proxy URL here (e.g.
|
||||
http://127.0.0.1:8080) and market downloads will go through it.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="http://host:port (empty = direct connection)"
|
||||
value={proxyUrl}
|
||||
onChange={(e) => setProxyUrl(e.target.value)}
|
||||
/>
|
||||
<button className="primary" onClick={() => save({ proxyUrl })}>Save</button>
|
||||
</div>
|
||||
{saved && <p style={{ color: '#7fd18a' }}>Saved.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -74,6 +74,17 @@ button.primary {
|
|||
}
|
||||
button.primary:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
button:not(.primary) {
|
||||
background: #2b323e;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
button:not(.primary):hover { background: #333c4b; }
|
||||
|
||||
table { border-collapse: collapse; width: 100%; }
|
||||
th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 600; font-size: 13px; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue