// SPOT PRICE FUNCTIONS
// =============================================================================
/**
* Saves spot history to localStorage.
* Writes the content of `spotHistory` to the `SPOT_HISTORY_KEY`.
* @returns {void}
*/
const saveSpotHistory = () => {
try {
saveDataSync(SPOT_HISTORY_KEY, spotHistory);
} catch (error) {
console.error('Error saving spot history:', error);
}
};
/**
* Loads spot history from localStorage
*/
const loadSpotHistory = () => {
try {
const data = loadDataSync(SPOT_HISTORY_KEY, []);
// Ensure data is an array
spotHistory = Array.isArray(data) ? data : [];
} catch (error) {
console.error('Error loading spot history:', error);
spotHistory = [];
}
};
/**
* One-time migration: re-tag old StakTrakr hourly backfill entries.
* Before v3.30.02, backfill entries were stored with source:"api" (same as
* live syncs). Identifies them by the heuristic: provider is "StakTrakr",
* source is "api", and timestamp lands exactly on the hour (:00:00).
* Regular syncs never produce on-the-hour timestamps.
*/
const migrateHourlySource = () => {
const FLAG = "migration_hourlySource";
try {
if (localStorage.getItem(FLAG)) return;
loadSpotHistory();
let changed = 0;
spotHistory.forEach(e => {
if (
e.provider === "StakTrakr" &&
e.source === "api" &&
typeof e.timestamp === "string" &&
e.timestamp.endsWith(":00:00")
) {
e.source = "api-hourly";
changed++;
}
});
if (changed > 0) {
saveSpotHistory();
console.log(`[Migration] Re-tagged ${changed} StakTrakr entries as api-hourly`);
}
localStorage.setItem(FLAG, "1");
} catch (err) {
console.warn("Hourly source migration failed:", err);
}
};
// Run migration on script load
migrateHourlySource();
/**
* Removes spot history entries older than the specified number of days
*
* @param {number} days - Number of days to retain
*/
const purgeSpotHistory = (days = 180) => {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
spotHistory = spotHistory.filter(
(entry) => new Date(entry.timestamp) >= cutoff,
);
};
/**
* Stores last cache refresh and API sync timestamps
*
* @param {string} source - Source of spot price ('api' or 'cached')
* @param {string|null} provider - Provider name if available
* @param {string} timestamp - ISO timestamp of the event
*/
const updateLastTimestamps = (source, provider, timestamp) => {
const apiEntry = {
provider: provider || "API",
timestamp,
};
if (source === "api") {
saveData(LAST_API_SYNC_KEY, apiEntry);
saveData(LAST_CACHE_REFRESH_KEY, apiEntry);
} else if (source === "cached") {
const cacheEntry = {
provider: provider ? `${provider} (cached)` : "Cached",
timestamp,
};
saveData(LAST_CACHE_REFRESH_KEY, cacheEntry);
}
};
/**
* Records a new spot price entry in history
*
* @param {number} newSpot - New spot price value
* @param {string} source - Source of spot price ('manual', 'api', etc.)
* @param {string} metal - Metal type ('Silver', 'Gold', 'Platinum', or 'Palladium')
* @param {string|null} provider - Provider name if source is API-based
* @param {string|null} timestamp - Optional ISO timestamp for historical entries
*/
const recordSpot = (
newSpot,
source,
metal,
provider = null,
timestamp = null,
) => {
purgeSpotHistory();
const entryTimestamp = timestamp
? new Date(timestamp).toISOString().replace("T", " ").slice(0, 19)
: new Date().toISOString().replace("T", " ").slice(0, 19);
// Historical backfill (explicit timestamp): full-array dedup by timestamp+metal.
// Real-time entries (no explicit timestamp): fast O(1) tail check.
const isDuplicate = timestamp
? spotHistory.some(
(e) => e.timestamp === entryTimestamp && e.metal === metal,
)
: spotHistory.length > 0 &&
spotHistory[spotHistory.length - 1].spot === newSpot &&
spotHistory[spotHistory.length - 1].metal === metal;
if (!isDuplicate) {
spotHistory.push({
spot: newSpot,
metal,
source,
provider,
timestamp: entryTimestamp,
});
}
if (source === "api" || source === "cached") {
updateLastTimestamps(source, provider, entryTimestamp);
}
saveSpotHistory();
};
/**
* Returns recent spot prices for a given metal from spotHistory.
* Used by card-view sparklines (STAK-118).
* @param {string} metal - Metal key ('silver', 'gold', etc.)
* @param {number} [points=30] - Number of data points to return
* @param {boolean} [withTimestamps=false] - If true, returns {ts, spot} objects
* @returns {Array.<number>|Array.<{ts:number,spot:number}>|null} Array of spot prices, or null if insufficient data
*/
const getSpotHistoryForMetal = (metal, points = 30, withTimestamps = false) => {
const metalName = Object.values(METALS).find(m => m.key === metal)?.name || metal;
const entries = spotHistory.filter(e => e.metal === metalName);
if (entries.length < 2) return null;
const recent = entries.slice(-points);
if (withTimestamps) return recent.map(e => ({ ts: new Date(e.timestamp).getTime(), spot: e.spot }));
return recent.map(e => e.spot);
};
/**
* Updates spot card color based on price movement compared to last history entry
*
* @param {string} metalKey - Metal key ('silver', 'gold', etc.)
* @param {number} newPrice - Newly set spot price
*/
const updateSpotCardColor = (metalKey, newPrice) => {
const metalConfig = Object.values(METALS).find((m) => m.key === metalKey);
if (!metalConfig) return;
const el = elements.spotPriceDisplay[metalKey];
if (!el) return;
// Find the most recent API/manual entry with a DIFFERENT price.
// This ensures the direction indicator persists across page refreshes —
// cached reads reload the same price, so comparing against them (or against
// the most recent same-price API entry) would always show "unchanged".
const lastEntry = [...spotHistory]
.reverse()
.find((e) => e.metal === metalConfig.name && e.source !== "cached" && e.spot !== newPrice);
let arrow = "";
const formatted = typeof formatCurrency === "function"
? formatCurrency(newPrice)
: newPrice.toFixed(2);
if (!lastEntry) {
el.classList.remove("spot-up", "spot-down", "spot-unchanged");
el.textContent = formatted;
return;
}
if (newPrice > lastEntry.spot) {
el.classList.add("spot-up");
el.classList.remove("spot-down", "spot-unchanged");
arrow = "\u25B2"; // Up arrow
} else if (newPrice < lastEntry.spot) {
el.classList.add("spot-down");
el.classList.remove("spot-up", "spot-unchanged");
arrow = "\u25BC"; // Down arrow
} else {
el.classList.add("spot-unchanged");
el.classList.remove("spot-up", "spot-down");
arrow = "=";
}
el.textContent = `${arrow} ${formatted}`.trim();
};
/**
* Fetches and displays current spot prices from localStorage or defaults
*/
const fetchSpotPrice = () => {
// Load spot prices for all metals
Object.values(METALS).forEach((metalConfig) => {
const storedSpot = localStorage.getItem(metalConfig.localStorageKey);
if (storedSpot) {
spotPrices[metalConfig.key] = parseFloat(storedSpot);
if (
elements.spotPriceDisplay[metalConfig.key] &&
elements.spotPriceDisplay[metalConfig.key].textContent !== undefined
) {
elements.spotPriceDisplay[metalConfig.key].textContent = formatCurrency(
spotPrices[metalConfig.key],
);
}
} else {
// Use default price if no stored price
const defaultPrice = metalConfig.defaultPrice;
spotPrices[metalConfig.key] = defaultPrice;
if (
elements.spotPriceDisplay[metalConfig.key] &&
elements.spotPriceDisplay[metalConfig.key].textContent !== undefined
) {
elements.spotPriceDisplay[metalConfig.key].textContent = formatCurrency(
spotPrices[metalConfig.key],
);
}
// Don't record default prices in history automatically
}
// Update timestamp display
const timestampElement = document.getElementById(
`spotTimestamp${metalConfig.name}`,
);
if (timestampElement) {
updateSpotTimestamp(metalConfig.name);
}
// Update card color based on price movement
updateSpotCardColor(metalConfig.key, spotPrices[metalConfig.key]);
});
updateSummary();
// Goldback estimation hook — fire after all spots loaded (STACK-52)
if (typeof onGoldSpotPriceChanged === 'function') onGoldSpotPriceChanged();
};
/**
* Updates spot price for specified metal from user input
*
* @param {string} metalKey - Key of metal to update ('silver', 'gold', 'platinum', 'palladium')
*/
const updateManualSpot = (metalKey) => {
const metalConfig = Object.values(METALS).find((m) => m.key === metalKey);
if (!metalConfig) return;
const input = elements.userSpotPriceInput[metalKey];
const value = input.value;
if (!value) return;
const num = parseFloat(value);
if (isNaN(num) || num <= 0)
return alert(`Invalid ${metalConfig.name.toLowerCase()} spot price.`);
localStorage.setItem(metalConfig.localStorageKey, num);
spotPrices[metalKey] = num;
if (
elements.spotPriceDisplay[metalKey] &&
elements.spotPriceDisplay[metalKey].textContent !== undefined
) {
elements.spotPriceDisplay[metalKey].textContent = formatCurrency(
spotPrices[metalKey],
);
}
updateSpotCardColor(metalKey, num);
recordSpot(num, "manual", metalConfig.name);
// Update timestamp display
const timestampElement = document.getElementById(
`spotTimestamp${metalConfig.name}`,
);
if (timestampElement) {
updateSpotTimestamp(metalConfig.name);
}
updateSummary();
// Snapshot item prices after manual spot change (STACK-43)
if (typeof recordAllItemPriceSnapshots === 'function') recordAllItemPriceSnapshots();
// Goldback estimation hook (STACK-52)
if (metalKey === 'gold' && typeof onGoldSpotPriceChanged === 'function') onGoldSpotPriceChanged();
// Clear the input and hide the manual input section if available
input.value = "";
if (typeof hideManualInput === "function") {
hideManualInput(metalConfig.name);
}
};
/**
* Resets spot price for specified metal to default or API cached value
*
* @param {string} metalKey - Key of metal to reset ('silver', 'gold', 'platinum', 'palladium')
*/
const resetSpot = (metalKey) => {
const metalConfig = Object.values(METALS).find((m) => m.key === metalKey);
if (!metalConfig) return;
let resetPrice = metalConfig.defaultPrice;
let source = "default";
let providerName = null;
// If we have cached API data, use that instead
if (
typeof apiCache !== "undefined" &&
apiCache &&
apiCache.data &&
apiCache.data[metalKey]
) {
resetPrice = apiCache.data[metalKey];
source = "api";
providerName = API_PROVIDERS[apiCache.provider]?.name || null;
}
// Update price
localStorage.setItem(metalConfig.localStorageKey, resetPrice.toString());
spotPrices[metalKey] = resetPrice;
// Update display
if (
elements.spotPriceDisplay[metalKey] &&
elements.spotPriceDisplay[metalKey].textContent !== undefined
) {
elements.spotPriceDisplay[metalKey].textContent = formatCurrency(
spotPrices[metalKey],
);
}
updateSpotCardColor(metalKey, resetPrice);
// Record in history
recordSpot(resetPrice, source, metalConfig.name, providerName);
// Update timestamp display
const timestampElement = document.getElementById(
`spotTimestamp${metalConfig.name}`,
);
if (timestampElement) {
updateSpotTimestamp(metalConfig.name);
}
// Update summary
updateSummary();
// Snapshot item prices after spot reset (STACK-43)
if (typeof recordAllItemPriceSnapshots === 'function') recordAllItemPriceSnapshots();
// Goldback estimation hook (STACK-52)
if (metalKey === 'gold' && typeof onGoldSpotPriceChanged === 'function') onGoldSpotPriceChanged();
// Hide manual input if shown and function is available
if (typeof hideManualInput === "function") {
hideManualInput(metalConfig.name);
}
};
/**
* Alternative reset function that works with metal name instead of key
* This provides compatibility with the API.js resetSpotPrice function
*
* @param {string} metalName - Name of metal to reset ('Silver', 'Gold', etc.)
*/
const resetSpotByName = (metalName) => {
const metalConfig = Object.values(METALS).find((m) => m.name === metalName);
if (metalConfig) {
resetSpot(metalConfig.key);
}
};
// =============================================================================
// SPARKLINE FUNCTIONS
// =============================================================================
/**
* Returns the theme-aware color for a metal sparkline by reading the
* CSS custom property (--silver, --gold, etc.) from the active theme.
* Falls back to hardcoded defaults if getComputedStyle is unavailable.
* @param {string} metalKey - 'silver', 'gold', 'platinum', 'palladium'
* @returns {string} CSS color string
*/
const getMetalColor = (metalKey) => {
const prop = getComputedStyle(document.documentElement).getPropertyValue(`--${metalKey}`).trim();
if (prop) return prop;
const fallback = { silver: "#d1d5db", gold: "#fbbf24", platinum: "#f3f4f6", palladium: "#d8b4fe" };
return fallback[metalKey] || "#6366f1";
};
/**
* Loads saved trend range preferences from localStorage
* @returns {Object} Map of metal key → days (default 30)
*/
const loadTrendRanges = () => {
try {
const stored = loadDataSync(SPOT_TREND_RANGE_KEY, null);
return stored && typeof stored === "object" ? stored : {};
} catch (e) {
return {};
}
};
/**
* Saves a single metal's trend range preference
* @param {string} metalKey - Metal key
* @param {number} days - Number of days
*/
const saveTrendRange = (metalKey, days) => {
const ranges = loadTrendRanges();
ranges[metalKey] = days;
saveDataSync(SPOT_TREND_RANGE_KEY, ranges);
};
// =============================================================================
// HISTORICAL DATA — YEAR-FILE FETCH & CACHE (STACK-69)
// =============================================================================
/** @type {Map<number, Array>} Cached year-file entries keyed by year */
const historicalDataCache = new Map();
/** @type {Map<number, Promise<Array>>} In-flight fetch promises to deduplicate concurrent requests */
const historicalFetchPromises = new Map();
/**
* Calculates which year files are needed for a given lookback period.
* @param {number} days - Number of days to look back
* @returns {number[]} Array of year numbers to fetch
*/
const getRequiredYears = (days) => {
const now = new Date();
const cutoff = new Date(now);
cutoff.setDate(cutoff.getDate() - days);
const startYear = cutoff.getFullYear();
const endYear = now.getFullYear();
const years = [];
for (let y = startYear; y <= endYear; y++) {
years.push(y);
}
return years;
};
/** @constant {string} Remote base URL for historical data (file:// fallback) */
const HISTORICAL_DATA_REMOTE = 'https://staktrakr.com/data/';
/**
* Loads a local JSON file via XMLHttpRequest (sync-free).
* Broader file:// compatibility than fetch() — works in Firefox/Safari
* and Chrome with --allow-file-access-from-files.
* @param {string} url - Relative or absolute URL to JSON file
* @returns {Promise<any>} Parsed JSON
*/
const xhrLoadJSON = (url) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.responseType = 'json';
xhr.onload = () => {
if (xhr.status === 200 || (xhr.status === 0 && xhr.response)) {
resolve(xhr.response);
} else {
reject(new Error(`XHR ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error('XHR network error'));
xhr.send();
});
/**
* Fetches a single year file from data/spot-history-{year}.json.
* Three-tier loading: local fetch → local XHR → remote staktrakr.com.
* Caches the result (or empty array on failure) to avoid retries.
* Deduplicates concurrent fetches for the same year.
* @param {number} year - Year to fetch
* @returns {Promise<Array>} Array of spot history entries
*/
const fetchYearFile = (year) => {
// Already cached — return immediately
if (historicalDataCache.has(year)) {
return Promise.resolve(historicalDataCache.get(year));
}
// Already in-flight — return shared promise
if (historicalFetchPromises.has(year)) {
return historicalFetchPromises.get(year);
}
const filename = `spot-history-${year}.json`;
const localUrl = `data/${filename}`;
const remoteUrl = `${HISTORICAL_DATA_REMOTE}${filename}`;
const promise = fetch(localUrl)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.catch(() => xhrLoadJSON(localUrl)) // Fallback 1: XHR for file://
.catch(() => fetch(remoteUrl) // Fallback 2: remote staktrakr.com
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
)
.then((entries) => {
// Validate: must be an array of objects with spot/metal/timestamp
if (!Array.isArray(entries)) {
historicalDataCache.set(year, []);
return [];
}
const valid = entries.filter(
(e) => e && typeof e.spot === "number" && e.metal && e.timestamp,
);
historicalDataCache.set(year, valid);
return valid;
})
.catch(() => {
// All three methods failed — cache empty to avoid retries
historicalDataCache.set(year, []);
return [];
})
.finally(() => {
historicalFetchPromises.delete(year);
});
historicalFetchPromises.set(year, promise);
return promise;
};
/**
* Fetches needed year files, merges with live spotHistory, filters to
* metal + range, deduplicates by day (live data wins over seed).
* @param {string} metalName - Metal name ('Silver', 'Gold', etc.)
* @param {number} days - Number of days to look back
* @returns {Promise<{ labels: string[], data: number[] }>} Arrays for Chart.js
*/
const getHistoricalSparklineData = async (metalName, days) => {
const years = getRequiredYears(days);
const yearArrays = await Promise.all(years.map(fetchYearFile));
// Merge all historical entries
const allHistorical = yearArrays.flat();
// Cutoff date
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
// Combine: historical + live spotHistory
const combined = [...allHistorical, ...spotHistory]
.filter((e) => e.metal === metalName && new Date(e.timestamp) >= cutoff)
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
// Deduplicate by day — live data (non-seed) wins over seed
const byDay = new Map();
combined.forEach((e) => {
const day = e.timestamp.slice(0, 10);
const existing = byDay.get(day);
if (!existing || existing.source === "seed") {
byDay.set(day, e);
}
});
const sorted = [...byDay.values()].sort(
(a, b) => new Date(a.timestamp) - new Date(b.timestamp),
);
return {
labels: sorted.map((e) => e.timestamp.slice(0, 10)),
data: sorted.map((e) => e.spot),
};
};
// =============================================================================
/**
* Extracts sparkline data from spotHistory for a given metal and date range
* @param {string} metalName - Metal name ('Silver', 'Gold', etc.)
* @param {number} days - Number of days to look back
* @param {boolean} [intraday=false] - If true, keep all entries (no per-day dedup)
* and use midnight cutoff instead of current-time offset (STACK-66)
* @returns {{ labels: string[], data: number[] }} Arrays for Chart.js
*/
const getSparklineData = (metalName, days, intraday = false) => {
const cutoff = new Date();
if (intraday) {
// Use midnight of N days ago for clean calendar-day boundaries
cutoff.setDate(cutoff.getDate() - days);
cutoff.setHours(0, 0, 0, 0);
} else {
cutoff.setDate(cutoff.getDate() - days);
}
const entries = spotHistory
.filter((e) => e.metal === metalName && new Date(e.timestamp) >= cutoff)
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
if (intraday) {
// Return individual entries for hourly resolution
return {
labels: entries.map((e) => e.timestamp.slice(11, 16)), // "HH:MM"
data: entries.map((e) => e.spot),
};
}
// Track first and last entry per day for open/close comparison (STACK-92)
const byDay = new Map();
entries.forEach((e) => {
const day = e.timestamp.slice(0, 10);
if (!byDay.has(day)) {
byDay.set(day, { first: e, last: e });
} else {
byDay.get(day).last = e;
}
});
const sorted = [...byDay.values()].sort(
(a, b) => new Date(a.last.timestamp) - new Date(b.last.timestamp),
);
return {
labels: sorted.map((pair) => pair.last.timestamp.slice(0, 10)),
data: sorted.map((pair) => pair.last.spot), // close prices (backward-compatible)
openData: sorted.map((pair) => pair.first.spot), // open prices (STACK-92)
};
};
/**
* Renders or updates a sparkline chart for a single metal card.
* For ranges >180 days, fetches historical year files asynchronously.
* @param {string} metalKey - Metal key ('silver', 'gold', etc.)
*/
const updateSparkline = async (metalKey) => {
const metalConfig = Object.values(METALS).find((m) => m.key === metalKey);
if (!metalConfig) return;
const canvasId = `sparkline${metalConfig.name}`;
const canvas = document.getElementById(canvasId);
if (!canvas || !canvas.getContext) return;
// Destroy existing chart instance early to avoid stale visuals during async fetch
if (sparklineInstances[metalKey]) {
sparklineInstances[metalKey].destroy();
sparklineInstances[metalKey] = null;
}
// Determine range from dropdown or saved preference
const rangeSelect = document.getElementById(`spotRange${metalConfig.name}`);
const days = rangeSelect ? parseInt(rangeSelect.value, 10) : 90;
// 1-day view: try 1-day intraday window first, widen to 3 if too few points
const isIntraday = (days === 1);
let effectiveDays = isIntraday ? 1 : days;
let labels, data;
if (effectiveDays > 180) {
// Historical range — async fetch year files (STACK-69)
if (rangeSelect) rangeSelect.disabled = true;
try {
({ labels, data } = await getHistoricalSparklineData(metalConfig.name, effectiveDays));
} finally {
if (rangeSelect) rangeSelect.disabled = false;
}
} else {
({ labels, data } = getSparklineData(metalConfig.name, effectiveDays, isIntraday));
}
// Adaptive fallback: if 1-day window has too few points, widen to 3 days
if (isIntraday && data.length < 2) {
({ labels, data } = getSparklineData(metalConfig.name, 3, true));
}
// Need at least 2 data points for a meaningful line
if (data.length < 2) {
updateSpotChangePercent(metalKey, data);
return;
}
const ctx = canvas.getContext("2d");
const color = getMetalColor(metalKey);
// Create gradient fill
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.clientHeight || 80);
gradient.addColorStop(0, color);
gradient.addColorStop(1, "transparent");
sparklineInstances[metalKey] = new Chart(ctx, {
type: "line",
data: {
datasets: [
{
data: data.map((val, i) => ({ x: i, y: val })),
borderColor: color,
backgroundColor: gradient,
fill: true,
borderWidth: 1.5,
pointRadius: 0,
tension: isIntraday ? 0 : 0.3,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
layout: { padding: { top: 6, right: 0, bottom: 0, left: 0 } },
plugins: {
legend: { display: false },
tooltip: { enabled: false },
},
scales: {
x: {
type: "linear",
display: false,
min: 0,
max: data.length - 1,
},
y: { display: false, beginAtZero: false, grace: '50%' },
},
animation: { duration: 400 },
interaction: { enabled: false },
},
});
updateSpotChangePercent(metalKey, data);
};
/**
* Computes 24h % change using the user's selected comparison mode (STACK-92).
* Modes: close-close (default), open-open, open-close.
* Graceful degradation: when only 1 entry/day exists, first === last so all modes match.
* @param {string} metalName - 'Silver', 'Gold', etc.
* @returns {{ pct: number, valid: boolean }}
*/
const get24hChange = (metalName) => {
const mode = localStorage.getItem(SPOT_COMPARE_MODE_KEY) || 'close-close';
const { data: closeData, openData } = getSparklineData(metalName, 3, false);
if (closeData.length < 2) return { pct: 0, valid: false };
const yesterday = closeData.length - 2;
const today = closeData.length - 1;
let oldPrice, newPrice;
switch (mode) {
case 'open-open':
oldPrice = openData[yesterday];
newPrice = openData[today];
break;
case 'open-close':
oldPrice = openData[yesterday];
newPrice = closeData[today];
break;
default: // 'close-close'
oldPrice = closeData[yesterday];
newPrice = closeData[today];
break;
}
if (!oldPrice || oldPrice === 0) return { pct: 0, valid: false };
return { pct: ((newPrice - oldPrice) / oldPrice) * 100, valid: true };
};
/**
* Computes and displays % change on a spot card based on the selected sparkline period.
* Compares oldest vs newest data point in the selected range.
*
* @param {string} metalKey - Metal key ('silver', 'gold', etc.)
* @param {number[]|null} [precomputedData=null] - Pre-fetched data array from updateSparkline
* (avoids redundant re-fetch for historical ranges). When null, uses sync getSparklineData().
*/
const updateSpotChangePercent = (metalKey, precomputedData = null) => {
const metalConfig = Object.values(METALS).find((m) => m.key === metalKey);
if (!metalConfig) return;
const el = document.getElementById(`spotChange${metalConfig.name}`);
if (!el) return;
const rangeSelectEl0 = document.getElementById(`spotRange${metalConfig.name}`);
const selectedDays0 = rangeSelectEl0 ? parseInt(rangeSelectEl0.value, 10) : 90;
let data;
if (selectedDays0 === 1) {
// 1-day view: use get24hChange() which respects the user's comparison mode (STACK-92)
({ data } = getSparklineData(metalConfig.name, 3, false));
} else if (precomputedData) {
data = precomputedData;
} else {
({ data } = getSparklineData(metalConfig.name, selectedDays0));
}
if (data.length < 2) {
el.textContent = "";
return;
}
// For 1d view, use get24hChange() for mode-aware comparison (STACK-92).
// For all other views, compare oldest→newest across the full selected range.
let pctChange;
if (selectedDays0 === 1) {
const change24h = get24hChange(metalConfig.name);
pctChange = change24h.valid ? change24h.pct : 0;
} else {
const oldest = data[0];
const newest = data[data.length - 1];
pctChange = ((newest - oldest) / oldest) * 100;
}
const sign = pctChange > 0 ? "+" : "";
const rangeClass = pctChange > 0 ? "spot-change-up" : pctChange < 0 ? "spot-change-down" : "";
// Build DOM: range % (colored) + optional 24h % (independently colored)
el.className = "spot-card-change";
el.textContent = "";
const rangeSpan = document.createElement("span");
rangeSpan.className = rangeClass;
rangeSpan.textContent = `${sign}${pctChange.toFixed(2)}%`;
el.appendChild(rangeSpan);
// Append secondary % indicator in parentheses (STACK-69, STACK-92)
// >1d views: show 24h change (mode-aware) | 1d view: show 90d change for context
if (selectedDays0 > 1) {
const change24h = get24hChange(metalConfig.name);
if (change24h.valid) {
const dayPct = change24h.pct;
const daySign = dayPct > 0 ? "+" : "";
const dayClass = dayPct > 0 ? "spot-change-up" : dayPct < 0 ? "spot-change-down" : "";
const daySpan = document.createElement("span");
daySpan.className = dayClass;
daySpan.textContent = ` (${daySign}${dayPct.toFixed(2)}% 24h)`;
el.appendChild(daySpan);
}
} else {
// 1d view: show 90d context
const { data: ctx90 } = getSparklineData(metalConfig.name, 90);
if (ctx90.length >= 2) {
const ctxOld = ctx90[0];
const ctxNew = ctx90[ctx90.length - 1];
const ctxPct = ((ctxNew - ctxOld) / ctxOld) * 100;
const ctxSign = ctxPct > 0 ? "+" : "";
const ctxClass = ctxPct > 0 ? "spot-change-up" : ctxPct < 0 ? "spot-change-down" : "";
const ctxSpan = document.createElement("span");
ctxSpan.className = ctxClass;
ctxSpan.textContent = ` (${ctxSign}${ctxPct.toFixed(2)}% 90d)`;
el.appendChild(ctxSpan);
}
}
// Override the arrow direction on the price display to match the period-based
// trend. updateSpotCardColor() compares against the last different price in ALL
// history, but the user expects the arrow to reflect the selected period.
const priceEl = elements.spotPriceDisplay[metalKey];
if (priceEl) {
const currentPrice = spotPrices[metalKey];
const formatted =
typeof formatCurrency === "function"
? formatCurrency(currentPrice)
: currentPrice.toFixed(2);
if (pctChange > 0) {
priceEl.classList.add("spot-up");
priceEl.classList.remove("spot-down", "spot-unchanged");
priceEl.textContent = `\u25B2 ${formatted}`;
} else if (pctChange < 0) {
priceEl.classList.add("spot-down");
priceEl.classList.remove("spot-up", "spot-unchanged");
priceEl.textContent = `\u25BC ${formatted}`;
} else {
priceEl.classList.add("spot-unchanged");
priceEl.classList.remove("spot-up", "spot-down");
priceEl.textContent = `= ${formatted}`;
}
}
};
/**
* Refreshes sparklines on all 4 metal cards concurrently
*/
const updateAllSparklines = async () => {
await Promise.all(Object.values(METALS).map((mc) => updateSparkline(mc.key)));
};
/**
* Destroys all sparkline Chart.js instances (cleanup)
*/
const destroySparklines = () => {
Object.keys(sparklineInstances).forEach((key) => {
if (sparklineInstances[key]) {
sparklineInstances[key].destroy();
sparklineInstances[key] = null;
}
});
};
/**
* Opens an inline input on a spot card price for manual editing (shift+click)
* @param {HTMLElement} valueEl - The .spot-card-value element that was clicked
* @param {string} metalKey - Metal key ('silver', 'gold', etc.)
*/
const startSpotInlineEdit = (valueEl, metalKey) => {
if (valueEl.querySelector(".spot-inline-input")) return; // already editing
const metalConfig = Object.values(METALS).find((m) => m.key === metalKey);
if (!metalConfig) return;
const currentPrice = spotPrices[metalKey] || 0;
const originalHTML = valueEl.innerHTML;
const input = document.createElement("input");
input.type = "number";
input.className = "spot-inline-input";
input.value = currentPrice > 0 ? currentPrice.toFixed(2) : "";
input.step = "0.01";
input.min = "0";
valueEl.textContent = "";
valueEl.appendChild(input);
input.focus();
input.select();
const cancel = () => {
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
valueEl.innerHTML = originalHTML;
};
const save = () => {
const num = parseFloat(input.value);
if (isNaN(num) || num <= 0) {
cancel();
return;
}
localStorage.setItem(metalConfig.localStorageKey, num);
spotPrices[metalKey] = num;
valueEl.textContent = formatCurrency(num);
updateSpotCardColor(metalKey, num);
recordSpot(num, "manual", metalConfig.name);
updateSpotTimestamp(metalConfig.name);
updateSummary();
updateSparkline(metalKey);
// Snapshot item prices after inline spot edit (STACK-43)
if (typeof recordAllItemPriceSnapshots === 'function') recordAllItemPriceSnapshots();
// Goldback estimation hook (STACK-52)
if (metalKey === 'gold' && typeof onGoldSpotPriceChanged === 'function') onGoldSpotPriceChanged();
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
save();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
});
input.addEventListener("blur", cancel);
};
// =============================================================================
// SPOT HISTORY — SETTINGS LOG TABLE
// =============================================================================
/** @type {string} Current sort column for settings spot history table */
let settingsSpotSortColumn = '';
/** @type {boolean} Sort ascending for settings spot history table */
let settingsSpotSortAsc = true;
/**
* Renders the spot price history table in the Settings > Activity Log > Metals sub-tab.
* Reads from the global spotHistory array and sorts by timestamp descending by default.
*/
const renderSpotHistoryTable = () => {
const table = document.getElementById('settingsSpotHistoryTable');
if (!table) return;
loadSpotHistory();
let data = [...spotHistory];
// Sort
if (settingsSpotSortColumn) {
data.sort((a, b) => {
const valA = a[settingsSpotSortColumn];
const valB = b[settingsSpotSortColumn];
if (valA < valB) return settingsSpotSortAsc ? -1 : 1;
if (valA > valB) return settingsSpotSortAsc ? 1 : -1;
return 0;
});
} else {
data.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
}
const tbody = table.querySelector('tbody');
if (!tbody) return;
if (data.length === 0) {
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml
tbody.innerHTML = '<tr class="settings-log-empty"><td colspan="5">No spot price history recorded yet.</td></tr>';
return;
}
const rows = data.map(e => {
const ts = e.timestamp ? (typeof formatTimestamp === 'function' ? formatTimestamp(e.timestamp) : new Date(e.timestamp).toLocaleString()) : '';
const metal = e.metal || '';
const price = typeof formatCurrency === 'function' ? formatCurrency(e.spot) : `$${Number(e.spot).toFixed(2)}`;
const source = e.source || '';
const provider = e.source === 'seed' ? 'Seed' : (e.provider || '');
return `<tr><td>${ts}</td><td>${escapeHtml(metal)}</td><td>${price}</td><td>${escapeHtml(source)}</td><td>${escapeHtml(provider)}</td></tr>`;
});
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml
tbody.innerHTML = rows.join('');
// Sortable headers
table.querySelectorAll('th').forEach(th => {
th.style.cursor = 'pointer';
th.onclick = () => {
const cols = ['timestamp', 'metal', 'spot', 'source', 'provider'];
const idx = Array.from(th.parentNode.children).indexOf(th);
const col = cols[idx];
if (settingsSpotSortColumn === col) {
settingsSpotSortAsc = !settingsSpotSortAsc;
} else {
settingsSpotSortColumn = col;
settingsSpotSortAsc = true;
}
renderSpotHistoryTable();
};
});
};
/** @type {string} Current sort column for settings LBMA reference table */
let settingsLbmaSortColumn = '';
/** @type {boolean} Sort ascending for settings LBMA reference table */
let settingsLbmaSortAsc = true;
/** @type {Array<Object>|null} Cached LBMA reference entries */
let settingsLbmaReferenceCache = null;
/** @type {Promise<Array<Object>>|null} In-flight LBMA load promise */
let settingsLbmaLoadPromise = null;
/** @type {boolean} Prevent duplicate LBMA control binding */
let settingsLbmaControlsBound = false;
/**
* Returns the seed years configured for bundled LBMA reference data.
* Falls back to current year when the seed config is unavailable.
*
* @returns {number[]} Sorted list of years
*/
const getLbmaReferenceYears = () => {
if (typeof SEED_DATA_YEARS !== 'undefined' && Array.isArray(SEED_DATA_YEARS) && SEED_DATA_YEARS.length > 0) {
return [...new Set(
SEED_DATA_YEARS
.map((y) => parseInt(y, 10))
.filter((y) => Number.isFinite(y))
)].sort((a, b) => a - b);
}
return [new Date().getFullYear()];
};
/**
* Loads and caches LBMA reference entries from yearly seed files.
* Uses fetchYearFile() for file:// and HTTP compatibility.
*
* @returns {Promise<Array<Object>>} Flattened LBMA reference entries
*/
const loadLbmaReferenceEntries = async () => {
if (Array.isArray(settingsLbmaReferenceCache)) return settingsLbmaReferenceCache;
if (settingsLbmaLoadPromise) return settingsLbmaLoadPromise;
settingsLbmaLoadPromise = Promise.all(getLbmaReferenceYears().map(fetchYearFile))
.then((yearArrays) => {
let entries = yearArrays
.flat()
.filter((e) => e && typeof e.spot === 'number' && e.metal && e.timestamp)
.filter((e) => e.source === 'seed' || String(e.provider || '').toUpperCase() === 'LBMA');
// Final fallback for unusual file:// cases where year files fail entirely.
if (entries.length === 0 && typeof getEmbeddedSeedData === 'function') {
entries = getEmbeddedSeedData().filter(
(e) => e && typeof e.spot === 'number' && e.metal && e.timestamp
);
}
settingsLbmaReferenceCache = entries.map((e) => ({
...e,
provider: e.provider || 'LBMA',
}));
return settingsLbmaReferenceCache;
})
.catch(() => {
settingsLbmaReferenceCache = [];
return settingsLbmaReferenceCache;
})
.finally(() => {
settingsLbmaLoadPromise = null;
});
return settingsLbmaLoadPromise;
};
/**
* Binds LBMA history filter controls once.
*/
const bindLbmaHistoryControls = () => {
if (settingsLbmaControlsBound) return;
const metalFilter = document.getElementById('settingsLbmaMetalFilter');
const startDate = document.getElementById('settingsLbmaStartDate');
const endDate = document.getElementById('settingsLbmaEndDate');
const resetBtn = document.getElementById('settingsLbmaResetBtn');
if (!metalFilter || !startDate || !endDate || !resetBtn) return;
const rerender = () => {
renderLbmaHistoryTable();
};
metalFilter.addEventListener('change', rerender);
startDate.addEventListener('change', rerender);
endDate.addEventListener('change', rerender);
resetBtn.addEventListener('click', () => {
metalFilter.value = 'all';
startDate.value = '';
endDate.value = '';
renderLbmaHistoryTable();
});
settingsLbmaControlsBound = true;
};
/**
* Renders the Settings > Activity Log > LBMA History reference table.
* Data source is bundled year files (seed reference data), not user spotHistory.
*/
const renderLbmaHistoryTable = async () => {
const table = document.getElementById('settingsLbmaHistoryTable');
if (!table) return;
bindLbmaHistoryControls();
const tbody = table.querySelector('tbody');
if (!tbody) return;
const resultCountEl = document.getElementById('settingsLbmaResultCount');
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml
tbody.innerHTML = '<tr class="settings-log-empty"><td colspan="4">Loading LBMA reference history…</td></tr>';
const allEntries = await loadLbmaReferenceEntries();
const metalFilter = (document.getElementById('settingsLbmaMetalFilter')?.value || 'all').toLowerCase();
const startDate = document.getElementById('settingsLbmaStartDate')?.value || '';
const endDate = document.getElementById('settingsLbmaEndDate')?.value || '';
let data = [...allEntries];
if (metalFilter !== 'all') {
data = data.filter((e) => String(e.metal || '').toLowerCase() === metalFilter);
}
if (startDate) {
data = data.filter((e) => String(e.timestamp || '').slice(0, 10) >= startDate);
}
if (endDate) {
data = data.filter((e) => String(e.timestamp || '').slice(0, 10) <= endDate);
}
if (settingsLbmaSortColumn) {
data.sort((a, b) => {
const getSortVal = (entry) => {
if (settingsLbmaSortColumn === 'spot') return Number(entry.spot) || 0;
if (settingsLbmaSortColumn === 'metal') return String(entry.metal || '').toLowerCase();
if (settingsLbmaSortColumn === 'provider') return String(entry.provider || '').toLowerCase();
return String(entry.timestamp || '');
};
const valA = getSortVal(a);
const valB = getSortVal(b);
if (valA < valB) return settingsLbmaSortAsc ? -1 : 1;
if (valA > valB) return settingsLbmaSortAsc ? 1 : -1;
return 0;
});
} else {
data.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
}
if (resultCountEl) {
resultCountEl.textContent = `${data.length.toLocaleString()} rows`;
}
if (data.length === 0) {
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml
tbody.innerHTML = '<tr class="settings-log-empty"><td colspan="4">No LBMA reference rows match the current filters.</td></tr>';
} else {
const rows = data.map((entry) => {
const rawDate = String(entry.timestamp || '').slice(0, 10);
const dateLabel = rawDate
? (typeof formatDisplayDate === 'function' ? formatDisplayDate(rawDate) : rawDate)
: '';
const metal = escapeHtml(entry.metal || '');
const spot = typeof formatCurrency === 'function'
? formatCurrency(entry.spot)
: `$${Number(entry.spot).toFixed(2)}`;
const provider = escapeHtml(entry.provider || 'LBMA');
return `<tr><td>${dateLabel}</td><td>${metal}</td><td>${spot}</td><td>${provider}</td></tr>`;
});
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml
tbody.innerHTML = rows.join('');
}
table.querySelectorAll('th').forEach((th, idx) => {
th.style.cursor = 'pointer';
th.onclick = () => {
const cols = ['date', 'metal', 'spot', 'provider'];
const nextCol = cols[idx];
if (settingsLbmaSortColumn === nextCol) {
settingsLbmaSortAsc = !settingsLbmaSortAsc;
} else {
settingsLbmaSortColumn = nextCol;
settingsLbmaSortAsc = true;
}
renderLbmaHistoryTable();
};
});
};
/**
* Clears all spot price history after user confirmation.
*/
const clearSpotHistory = () => {
if (!confirm('Clear all spot price history? This cannot be undone.')) return;
spotHistory = [];
saveSpotHistory();
// Reset rendered flag so it re-renders fresh
const panel = document.getElementById('logPanel_metals');
if (panel) delete panel.dataset.rendered;
renderSpotHistoryTable();
};
// =============================================================================
// ---------------------------------------------------------------------------
// Seed bundle loader (file:// protocol support)
// ---------------------------------------------------------------------------
// data/spot-history-bundle.js calls this function on load.
// Compact format: { year: { metal: [[MM-DD, price], ...] } }
// Expands into full historicalDataCache entries so fetchYearFile() finds
// cached data immediately without network requests.
/**
* Loads the spot history seed bundle into the cache.
* @param {Object} bundle - The spot history seed bundle.
*/
window._loadSpotSeedBundle = function(bundle) {
let loaded = 0;
for (const yearStr of Object.keys(bundle)) {
const year = parseInt(yearStr, 10);
if (historicalDataCache.has(year) && historicalDataCache.get(year).length > 0) continue;
const metals = bundle[yearStr];
const entries = [];
for (const metal of Object.keys(metals)) {
for (const pair of metals[metal]) {
entries.push({
spot: pair[1],
metal: metal,
source: 'seed',
provider: 'LBMA',
timestamp: yearStr + '-' + pair[0] + ' 12:00:00'
});
}
}
historicalDataCache.set(year, entries);
loaded += entries.length;
}
if (loaded > 0) {
console.log('[SpotSeed] Loaded ' + loaded + ' entries from bundle (' + Object.keys(bundle).length + ' years)');
}
};
// Ensure global availability
window.fetchSpotPrice = fetchSpotPrice;
window.updateSpotCardColor = updateSpotCardColor;
window.updateSparkline = updateSparkline;
window.updateAllSparklines = updateAllSparklines;
window.destroySparklines = destroySparklines;
window.updateSpotChangePercent = updateSpotChangePercent;
window.startSpotInlineEdit = startSpotInlineEdit;
window.getMetalColor = getMetalColor;
window.loadTrendRanges = loadTrendRanges;
window.saveTrendRange = saveTrendRange;
window.renderSpotHistoryTable = renderSpotHistoryTable;
window.renderLbmaHistoryTable = renderLbmaHistoryTable;
window.clearSpotHistory = clearSpotHistory;
window.getHistoricalSparklineData = getHistoricalSparklineData;
window.getRequiredYears = getRequiredYears;
window.fetchYearFile = fetchYearFile;
window.historicalDataCache = historicalDataCache;