Backend caching + AODP refetch cooldown

- In-memory TTL cache (server/src/cache.ts) with a get/set/clear surface
  that a Redis backend can replace later for multi-replica setups.
- Cache the expensive opportunities ranking (5 min), item detail/search
  (1 h), and per-item market prices (10 min). All keyed by request; each
  read endpoint takes a refresh/force bypass.
- Invalidate precisely on writes: price upserts, overrides, profile
  activate/update, economy config, and game-data reparse (full clear).
- AODP anti-spam: a price download now skips items priced within a
  configurable cooldown (default 15 min, Settings page) unless "force"
  is checked; the job reports how many fresh items it skipped.
- No client-side caching (per request); the browser still calls the API,
  but repeats are served from the server cache without recompute/AODP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Заид Омар Медхат 2026-08-23 05:09:38 +05:00
parent b8207f5b15
commit c4683ff5b0
10 changed files with 206 additions and 92 deletions

53
server/src/cache.ts Normal file
View file

@ -0,0 +1,53 @@
/**
* Process-local TTL cache for expensive/repeated reads (opportunities ranking,
* item lookups, market summaries). Keeps tab-switches and repeated requests cheap
* without recomputing or re-querying.
*
* Single-instance in-memory is enough for now. The surface (get/set/clear) is
* deliberately tiny so a Redis-backed implementation can replace it later if the
* app is ever scaled to multiple replicas that must share a cache — set
* REDIS_URL and swap the internals; callers don't change.
*/
interface Entry {
at: number;
value: unknown;
}
const store = new Map<string, Entry>();
export function cacheGet<T>(key: string, ttlMs: number): T | undefined {
const e = store.get(key);
if (!e) return undefined;
if (Date.now() - e.at > ttlMs) {
store.delete(key);
return undefined;
}
return e.value as T;
}
export function cacheSet(key: string, value: unknown): void {
store.set(key, { at: Date.now(), value });
}
/** Clear entries whose key starts with prefix (or everything if omitted). */
export function cacheClear(prefix?: string): void {
if (!prefix) {
store.clear();
return;
}
for (const k of [...store.keys()]) if (k.startsWith(prefix)) store.delete(k);
}
/**
* Get-or-compute. Returns the cached value when fresh; otherwise runs producer,
* stores, and returns it. `force` bypasses the read (still repopulates).
*/
export async function cached<T>(key: string, ttlMs: number, producer: () => Promise<T>, force = false): Promise<T> {
if (!force) {
const hit = cacheGet<T>(key, ttlMs);
if (hit !== undefined) return hit;
}
const value = await producer();
cacheSet(key, value);
return value;
}

View file

@ -1,4 +1,5 @@
import { get, query, transaction } from '../db/index.js'; import { get, query, transaction } from '../db/index.js';
import { cacheClear } from '../cache.js';
/** /**
* Character profile: the user's destiny board progress, entered manually * Character profile: the user's destiny board progress, entered manually
@ -82,6 +83,7 @@ export async function updateProfile(
new Date().toISOString(), new Date().toISOString(),
id, id,
]); ]);
cacheClear('opp:'); // premium/spec levels feed every ranking
return rowToProfile(await get('SELECT * FROM profiles WHERE id = ?', [id])); return rowToProfile(await get('SELECT * FROM profiles WHERE id = ?', [id]));
} }
@ -90,6 +92,7 @@ export async function activateProfile(id: number): Promise<void> {
await tx.query('UPDATE profiles SET is_active = 0'); await tx.query('UPDATE profiles SET is_active = 0');
await tx.query('UPDATE profiles SET is_active = 1 WHERE id = ?', [id]); await tx.query('UPDATE profiles SET is_active = 1 WHERE id = ?', [id]);
}); });
cacheClear('opp:'); // a different active profile changes every ranking
} }
export async function deleteProfile(id: number): Promise<void> { export async function deleteProfile(id: number): Promise<void> {

View file

@ -1,5 +1,6 @@
import fs from 'node:fs'; import fs from 'node:fs';
import { setMeta, transaction, type Tx } from '../db/index.js'; import { setMeta, transaction, type Tx } from '../db/index.js';
import { cacheClear } from '../cache.js';
import { gamedataFilePath } from './download.js'; import { gamedataFilePath } from './download.js';
/** /**
@ -245,5 +246,6 @@ export async function parseGameData(): Promise<ParseStats> {
}; };
await setMeta('gamedata_parsed_at', new Date().toISOString()); await setMeta('gamedata_parsed_at', new Date().toISOString());
await setMeta('gamedata_stats', JSON.stringify(stats)); await setMeta('gamedata_stats', JSON.stringify(stats));
cacheClear(); // items/recipes changed → drop every cached read
return stats; return stats;
} }

View file

@ -1,4 +1,5 @@
import { get, getMeta, query, setMeta, transaction } from '../db/index.js'; import { get, getMeta, query, setMeta, transaction } from '../db/index.js';
import { cacheClear } from '../cache.js';
import { import {
AodpUnreachableError, AodpUnreachableError,
CITIES, CITIES,
@ -7,6 +8,12 @@ import {
SERVER_HOSTS, SERVER_HOSTS,
} from './client.js'; } from './client.js';
/** Prices changed → opportunities and market reads are stale. */
function invalidatePriceCaches(): void {
cacheClear('opp:');
cacheClear('market:');
}
export async function getAlbionServer(): Promise<string> { export async function getAlbionServer(): Promise<string> {
const s = await getMeta('albion_server'); const s = await getMeta('albion_server');
return s && s in SERVER_HOSTS ? s : 'europe'; return s && s in SERVER_HOSTS ? s : 'europe';
@ -26,6 +33,7 @@ export interface RefreshJob {
doneChunks: number; doneChunks: number;
itemCount: number; itemCount: number;
rowsUpserted: number; rowsUpserted: number;
skippedFresh?: number; // items skipped because their prices were still within the cooldown
error?: string; error?: string;
startedAt?: string; startedAt?: string;
finishedAt?: string; finishedAt?: string;
@ -42,6 +50,24 @@ export interface RefreshOptions {
maxTier?: number; maxTier?: number;
cities?: string[]; cities?: string[];
items?: string[]; // explicit list overrides tier-based selection items?: string[]; // explicit list overrides tier-based selection
force?: boolean; // ignore the refetch cooldown
}
/** Minutes an item's prices are considered fresh — within this, a refresh skips it to spare the AODP API. */
async function priceCooldownMinutes(): Promise<number> {
const v = Number(await getMeta('price_cooldown_min'));
return Number.isFinite(v) && v > 0 ? v : 15;
}
/** Items whose most recent price fetch (any city) is within the cooldown window. */
async function recentlyFetchedItems(cooldownMin: number): Promise<Set<string>> {
// fetched_at is stored as ISO-8601 UTC, which orders chronologically as text.
const thresholdIso = new Date(Date.now() - cooldownMin * 60_000).toISOString();
const rows = await query<{ item: string }>(
'SELECT item FROM market_prices GROUP BY item HAVING MAX(fetched_at) > ?',
[thresholdIso],
);
return new Set(rows.map((r) => r.item));
} }
/** Items worth pricing: everything that appears in a recipe (as output or input). */ /** Items worth pricing: everything that appears in a recipe (as output or input). */
@ -91,6 +117,7 @@ export async function upsertPriceRows(rows: unknown[][], mode: 'overwrite' | 'co
await tx.query(`INSERT INTO market_prices (${PRICE_COLUMNS.join(', ')}) VALUES ${groups.join(', ')}${tail}`, params); await tx.query(`INSERT INTO market_prices (${PRICE_COLUMNS.join(', ')}) VALUES ${groups.join(', ')}${tail}`, params);
} }
}); });
invalidatePriceCaches();
return rows.length; return rows.length;
} }
@ -108,7 +135,7 @@ export function startRefresh(opts: RefreshOptions): { started: boolean; reason?:
void (async () => { void (async () => {
try { try {
const items = let items =
opts.items && opts.items.length > 0 opts.items && opts.items.length > 0
? opts.items ? opts.items
: await selectRecipeRelevantItems(opts.minTier ?? 2, opts.maxTier ?? 8); : await selectRecipeRelevantItems(opts.minTier ?? 2, opts.maxTier ?? 8);
@ -118,6 +145,25 @@ export function startRefresh(opts: RefreshOptions): { started: boolean; reason?:
job.finishedAt = new Date().toISOString(); job.finishedAt = new Date().toISOString();
return; return;
} }
// Skip items priced recently, so repeated downloads don't spam AODP.
const requested = items.length;
let skipped = 0;
if (!opts.force) {
const cooldown = await priceCooldownMinutes();
const fresh = await recentlyFetchedItems(cooldown);
const filtered = items.filter((i) => !fresh.has(i));
skipped = items.length - filtered.length;
items = filtered;
}
job.skippedFresh = skipped;
if (items.length === 0) {
job.status = 'done';
job.itemCount = requested;
job.rowsUpserted = 0;
job.finishedAt = new Date().toISOString();
return; // everything was fresh — nothing to fetch
}
job.itemCount = items.length; job.itemCount = items.length;
job.totalChunks = Math.ceil(items.length / 100); job.totalChunks = Math.ceil(items.length / 100);
@ -254,10 +300,12 @@ export async function setOverride(o: {
updated_at = excluded.updated_at`, updated_at = excluded.updated_at`,
[o.item, o.city, o.quality ?? 1, o.sellPrice ?? null, o.buyPrice ?? null, o.note ?? null, new Date().toISOString()], [o.item, o.city, o.quality ?? 1, o.sellPrice ?? null, o.buyPrice ?? null, o.note ?? null, new Date().toISOString()],
); );
invalidatePriceCaches(); // manual override changes prices used everywhere
} }
export async function deleteOverride(item: string, city: string, quality = 1): Promise<void> { export async function deleteOverride(item: string, city: string, quality = 1): Promise<void> {
await query('DELETE FROM price_overrides WHERE item = ? AND city = ? AND quality = ?', [item, city, quality]); await query('DELETE FROM price_overrides WHERE item = ? AND city = ? AND quality = ?', [item, city, quality]);
invalidatePriceCaches();
} }
export async function marketSummary() { export async function marketSummary() {

View file

@ -1,20 +1,36 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { cached, cacheClear } from '../cache.js';
import { getEconomyConfig, setEconomyConfig, type EconomyConfig } from '../economy/config.js'; import { getEconomyConfig, setEconomyConfig, type EconomyConfig } from '../economy/config.js';
import { rankOpportunities, type RankRequest } from '../economy/service.js'; import { rankOpportunities, type RankRequest } from '../economy/service.js';
const OPP_TTL_MS = 5 * 60 * 1000; // opportunities are recomputed at most every 5 min per param-set
export async function economyRoutes(app: FastifyInstance): Promise<void> { export async function economyRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/economy/config', async () => getEconomyConfig()); app.get('/api/economy/config', async () => getEconomyConfig());
app.put('/api/economy/config', async (req) => { app.put('/api/economy/config', async (req) => {
await setEconomyConfig((req.body ?? {}) as Partial<EconomyConfig>); await setEconomyConfig((req.body ?? {}) as Partial<EconomyConfig>);
cacheClear('opp:'); // config affects every ranking
return { ok: true, config: await getEconomyConfig() }; return { ok: true, config: await getEconomyConfig() };
}); });
app.post('/api/economy/opportunities', async (req, reply) => { app.post('/api/economy/opportunities', async (req, reply) => {
const body = (req.body ?? {}) as RankRequest; const body = (req.body ?? {}) as RankRequest & { refresh?: boolean };
if (!body.city) return reply.code(400).send({ error: 'city is required' }); if (!body.city) return reply.code(400).send({ error: 'city is required' });
const { refresh, ...params } = body;
const key = `opp:${JSON.stringify(params)}`;
const started = Date.now(); const started = Date.now();
const result = await rankOpportunities(body); let cacheHit = true;
return { ...result, tookMs: Date.now() - started }; const result = await cached(
key,
OPP_TTL_MS,
async () => {
cacheHit = false;
return rankOpportunities(params);
},
refresh === true,
);
return { ...result, tookMs: Date.now() - started, cached: cacheHit };
}); });
} }

View file

@ -1,47 +1,58 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { get, query } from '../db/index.js'; import { get, query } from '../db/index.js';
import { cached } from '../cache.js';
// Item/recipe data only changes when game data is re-parsed (which calls
// cacheClear()), so these can cache for a long time.
const ITEM_TTL_MS = 60 * 60 * 1000;
export async function itemRoutes(app: FastifyInstance): Promise<void> { export async function itemRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/items/search', async (req) => { app.get('/api/items/search', async (req) => {
const { q = '' } = req.query as { q?: string }; const { q = '' } = req.query as { q?: string };
const queryStr = q.trim(); const queryStr = q.trim();
if (!queryStr) return { results: [] }; if (!queryStr) return { results: [] };
const results = await query( const results = await cached(`item:search:${queryStr.toLowerCase()}`, ITEM_TTL_MS, () =>
`SELECT unique_name, name_en, tier, enchantment, shop_category, shop_subcategory query(
FROM items `SELECT unique_name, name_en, tier, enchantment, shop_category, shop_subcategory
WHERE name_en ILIKE ? OR unique_name ILIKE ? FROM items
ORDER BY tier, enchantment, unique_name WHERE name_en ILIKE ? OR unique_name ILIKE ?
LIMIT 50`, ORDER BY tier, enchantment, unique_name
[`%${queryStr}%`, `%${queryStr}%`], LIMIT 50`,
[`%${queryStr}%`, `%${queryStr}%`],
),
); );
return { results }; return { results };
}); });
app.get('/api/items/:uniqueName', async (req, reply) => { app.get('/api/items/:uniqueName', async (req, reply) => {
const { uniqueName } = req.params as { uniqueName: string }; const { uniqueName } = req.params as { uniqueName: string };
const item = await get('SELECT * FROM items WHERE unique_name = ?', [uniqueName]); const data = await cached(`item:detail:${uniqueName}`, ITEM_TTL_MS, async () => {
if (!item) return reply.code(404).send({ error: 'item not found' }); const item = await get('SELECT * FROM items WHERE unique_name = ?', [uniqueName]);
if (!item) return null;
const recipes = (await query('SELECT * FROM recipes WHERE output_item = ? ORDER BY source, variant', [uniqueName])) as any[]; const recipes = (await query('SELECT * FROM recipes WHERE output_item = ? ORDER BY source, variant', [uniqueName])) as any[];
for (const r of recipes) { for (const r of recipes) {
r.inputs = await query( r.inputs = await query(
`SELECT ri.input_item, ri.count, ri.max_return_amount, i.name_en `SELECT ri.input_item, ri.count, ri.max_return_amount, i.name_en
FROM recipe_inputs ri LEFT JOIN items i ON i.unique_name = ri.input_item FROM recipe_inputs ri LEFT JOIN items i ON i.unique_name = ri.input_item
WHERE ri.recipe_id = ?`, WHERE ri.recipe_id = ?`,
[r.id], [r.id],
);
}
const usedIn = await query(
`SELECT DISTINCT r.output_item, i.name_en
FROM recipe_inputs ri
JOIN recipes r ON r.id = ri.recipe_id
LEFT JOIN items i ON i.unique_name = r.output_item
WHERE ri.input_item = ?
LIMIT 100`,
[uniqueName],
); );
} return { item, recipes, usedIn };
});
const usedIn = await query( if (!data) return reply.code(404).send({ error: 'item not found' });
`SELECT DISTINCT r.output_item, i.name_en return data;
FROM recipe_inputs ri
JOIN recipes r ON r.id = ri.recipe_id
LEFT JOIN items i ON i.unique_name = r.output_item
WHERE ri.input_item = ?
LIMIT 100`,
[uniqueName],
);
return { item, recipes, usedIn };
}); });
} }

View file

@ -1,5 +1,6 @@
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { getMeta, query, setMeta } from '../db/index.js'; import { getMeta, query, setMeta } from '../db/index.js';
import { cached } from '../cache.js';
import { CITIES, SERVER_HOSTS } from '../market/client.js'; import { CITIES, SERVER_HOSTS } from '../market/client.js';
import { import {
deleteOverride, deleteOverride,
@ -25,6 +26,7 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
maxTier?: number; maxTier?: number;
cities?: string[]; cities?: string[];
items?: string[]; items?: string[];
force?: boolean;
}; };
const result = startRefresh(body); const result = startRefresh(body);
if (!result.started) return reply.code(409).send({ error: result.reason }); if (!result.started) return reply.code(409).send({ error: result.reason });
@ -33,7 +35,10 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/market/prices/:item', async (req) => { app.get('/api/market/prices/:item', async (req) => {
const { item } = req.params as { item: string }; const { item } = req.params as { item: string };
return { prices: await getPricesForItem(item) }; const refresh = (req.query as { refresh?: string }).refresh === 'true';
// cache is busted whenever prices/overrides change, so a long TTL is safe
const prices = await cached(`market:prices:${item}`, 10 * 60 * 1000, () => getPricesForItem(item), refresh);
return { prices };
}); });
app.put('/api/market/override', async (req, reply) => { app.put('/api/market/override', async (req, reply) => {
@ -69,13 +74,15 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
availableServers: Object.keys(SERVER_HOSTS), availableServers: Object.keys(SERVER_HOSTS),
proxyUrl: (await getMeta('proxy_url')) ?? '', proxyUrl: (await getMeta('proxy_url')) ?? '',
gamePath: (await getMeta('game_path')) ?? '', gamePath: (await getMeta('game_path')) ?? '',
priceCooldownMin: Number((await getMeta('price_cooldown_min')) ?? 15),
})); }));
app.put('/api/settings', async (req, reply) => { app.put('/api/settings', async (req, reply) => {
const { albionServer, proxyUrl, gamePath } = (req.body ?? {}) as { const { albionServer, proxyUrl, gamePath, priceCooldownMin } = (req.body ?? {}) as {
albionServer?: string; albionServer?: string;
proxyUrl?: string; proxyUrl?: string;
gamePath?: string; gamePath?: string;
priceCooldownMin?: number;
}; };
if (albionServer) { if (albionServer) {
try { try {
@ -92,6 +99,9 @@ export async function marketRoutes(app: FastifyInstance): Promise<void> {
if (gamePath === '') await query("DELETE FROM meta WHERE key = 'game_path'"); if (gamePath === '') await query("DELETE FROM meta WHERE key = 'game_path'");
else await setMeta('game_path', gamePath); else await setMeta('game_path', gamePath);
} }
if (priceCooldownMin !== undefined && Number.isFinite(priceCooldownMin) && priceCooldownMin > 0) {
await setMeta('price_cooldown_min', String(Math.round(priceCooldownMin)));
}
return { ok: true }; return { ok: true };
}); });
} }

View file

@ -1,55 +0,0 @@
/**
* Tiny in-memory client cache so switching tabs doesn't refetch data that's
* still fresh. Keyed by an arbitrary string; entries expire after a TTL. Pages
* call cachedJson() on mount (instant when warm) and pass force:true from a
* manual Refresh button to bypass and repopulate.
*
* Lives at module scope, so it survives component unmount/remount (tab switches)
* but resets on a full page reload.
*/
interface Entry {
at: number;
data: unknown;
}
const store = new Map<string, Entry>();
const DEFAULT_TTL = 5 * 60 * 1000; // 5 minutes
export interface CachedOptions {
ttl?: number;
force?: boolean;
init?: RequestInit;
}
export async function cachedJson<T = any>(key: string, url: string, opts: CachedOptions = {}): Promise<T> {
const ttl = opts.ttl ?? DEFAULT_TTL;
const hit = store.get(key);
if (!opts.force && hit && Date.now() - hit.at < ttl) {
return hit.data as T;
}
const res = await fetch(url, opts.init);
if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`);
const data = (await res.json()) as T;
store.set(key, { at: Date.now(), data });
return data;
}
/** Peek a cached value without fetching (for instant first paint). */
export function peek<T = any>(key: string): T | undefined {
return store.get(key)?.data as T | undefined;
}
/** How old the cached entry is, in ms (undefined if absent). */
export function ageMs(key: string): number | undefined {
const hit = store.get(key);
return hit ? Date.now() - hit.at : undefined;
}
/** Drop cache entries whose key starts with prefix (or everything if omitted). */
export function invalidate(prefix?: string): void {
if (!prefix) {
store.clear();
return;
}
for (const k of [...store.keys()]) if (k.startsWith(prefix)) store.delete(k);
}

View file

@ -6,6 +6,7 @@ interface Job {
doneChunks: number; doneChunks: number;
itemCount: number; itemCount: number;
rowsUpserted: number; rowsUpserted: number;
skippedFresh?: number;
error?: string; error?: string;
} }
@ -44,6 +45,7 @@ export default function MarketPage() {
const [summary, setSummary] = useState<Summary | null>(null); const [summary, setSummary] = useState<Summary | null>(null);
const [minTier, setMinTier] = useState(2); const [minTier, setMinTier] = useState(2);
const [maxTier, setMaxTier] = useState(8); const [maxTier, setMaxTier] = useState(8);
const [force, setForce] = useState(false);
const [q, setQ] = useState(''); const [q, setQ] = useState('');
const [results, setResults] = useState<{ unique_name: string; name_en: string | null }[]>([]); 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 [selected, setSelected] = useState<{ unique_name: string; name_en: string | null } | null>(null);
@ -103,7 +105,7 @@ export default function MarketPage() {
await fetch('/api/market/refresh', { await fetch('/api/market/refresh', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ minTier, maxTier }), body: JSON.stringify({ minTier, maxTier, force }),
}); });
loadSummary(); loadSummary();
}; };
@ -157,6 +159,9 @@ export default function MarketPage() {
{[1, 2, 3, 4, 5, 6, 7, 8].map((t) => <option key={t} value={t}>{t}</option>)} {[1, 2, 3, 4, 5, 6, 7, 8].map((t) => <option key={t} value={t}>{t}</option>)}
</select> </select>
</label> </label>
<label title="Ignore the refetch cooldown and re-download everything">
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} /> force
</label>
<button className="primary" disabled={job?.status === 'running'} onClick={startRefresh}> <button className="primary" disabled={job?.status === 'running'} onClick={startRefresh}>
{job?.status === 'running' ? 'Refreshing…' : 'Download prices'} {job?.status === 'running' ? 'Refreshing…' : 'Download prices'}
</button> </button>
@ -165,7 +170,12 @@ export default function MarketPage() {
{job.doneChunks}/{job.totalChunks} batches ({job.itemCount} items) {job.doneChunks}/{job.totalChunks} batches ({job.itemCount} items)
</span> </span>
)} )}
{job?.status === 'done' && <span className="muted">Last run stored {job.rowsUpserted} price rows.</span>} {job?.status === 'done' && (
<span className="muted">
Last run stored {job.rowsUpserted} price rows
{job.skippedFresh ? `, skipped ${job.skippedFresh} still-fresh items` : ''}.
</span>
)}
</div> </div>
{job?.status === 'error' && ( {job?.status === 'error' && (
<p style={{ color: '#e08869' }}> <p style={{ color: '#e08869' }}>

View file

@ -5,12 +5,14 @@ interface Settings {
availableServers: string[]; availableServers: string[];
proxyUrl: string; proxyUrl: string;
gamePath: string; gamePath: string;
priceCooldownMin: number;
} }
export default function SettingsPage() { export default function SettingsPage() {
const [settings, setSettings] = useState<Settings | null>(null); const [settings, setSettings] = useState<Settings | null>(null);
const [proxyUrl, setProxyUrl] = useState(''); const [proxyUrl, setProxyUrl] = useState('');
const [gamePath, setGamePath] = useState(''); const [gamePath, setGamePath] = useState('');
const [cooldown, setCooldown] = useState(15);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
useEffect(() => { useEffect(() => {
@ -20,10 +22,11 @@ export default function SettingsPage() {
setSettings(s); setSettings(s);
setProxyUrl(s.proxyUrl); setProxyUrl(s.proxyUrl);
setGamePath(s.gamePath); setGamePath(s.gamePath);
setCooldown(s.priceCooldownMin);
}); });
}, []); }, []);
const save = async (patch: Partial<{ albionServer: string; proxyUrl: string; gamePath: string }>) => { const save = async (patch: Partial<{ albionServer: string; proxyUrl: string; gamePath: string; priceCooldownMin: number }>) => {
await fetch('/api/settings', { await fetch('/api/settings', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -78,6 +81,19 @@ export default function SettingsPage() {
<button className="primary" onClick={() => save({ gamePath })}>Save</button> <button className="primary" onClick={() => save({ gamePath })}>Save</button>
</div> </div>
</div> </div>
<div className="card">
<h3>Price refetch cooldown</h3>
<p className="muted">
A price download skips items whose prices were fetched within this many minutes, to avoid spamming the
market API. Use the "force" checkbox on the Market Data page to override it when you truly need fresh data.
</p>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="number" min={1} value={cooldown} style={{ width: 90 }}
onChange={(e) => setCooldown(Number(e.target.value))} />
<span className="muted">minutes</span>
<button className="primary" onClick={() => save({ priceCooldownMin: cooldown })}>Save</button>
</div>
</div>
</div> </div>
); );
} }