// Minimal LZString subset placeholder providing UTF16 compression helpers.
// Original implementation removed due to parse issues; these functions act as no-ops
// but maintain the same API for compression helpers used elsewhere.
const LZString = {
compressToUTF16: (input) => input,
decompressFromUTF16: (input) => input
};
// UTILITY FUNCTIONS
/**
* Escape HTML special characters to prevent XSS when interpolating into innerHTML.
* @param {*} str - Value to escape (coerced to string)
* @returns {string} Escaped HTML-safe string
*/
const escapeHtml = (str) =>
String(str ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
/**
* Logs messages to console when DEBUG flag is enabled
*
* @param {...any} args - Values to log when debugging
*/
const debugLog = (...args) => {
if (DEBUG) {
console.log(...args);
}
};
/**
* Generates a UUID v4 string for stable item identification.
* Uses crypto.randomUUID() where available, with a Math.random() RFC 4122 v4 fallback
* for environments (e.g. file:// protocol) that lack crypto.randomUUID.
*
* @returns {string} A UUID v4 string (e.g. "550e8400-e29b-41d4-a716-446655440000")
*/
const generateUUID = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// RFC 4122 v4 fallback
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
};
/**
* Gets the active branding name considering domain overrides
*
* @returns {string} Active branding name
*/
let brandingWarned = false;
const getBrandingName = () => {
if (
!BRANDING_DOMAIN_OVERRIDE &&
!brandingWarned &&
typeof window !== "undefined" &&
window.location &&
window.location.hostname
) {
console.warn(
`No branding mapping found for domain: ${window.location.hostname}`
);
brandingWarned = true;
}
return BRANDING_DOMAIN_OVERRIDE || BRANDING_TITLE;
};
/**
* Returns full application title with version when no branding is configured
*
* @param {string} [baseTitle='StakTrakr'] - Base application title
* @returns {string} Full title with version or branding name
*/
const getAppTitle = (baseTitle = "StakTrakr") => {
const brand = getBrandingName();
return brand && brand.trim() ? brand : `${baseTitle} ${getVersionString()}`;
};
/**
* Determines active domain for footer copyright
*
* @returns {string} Domain name to display
*/
const getFooterDomain = () => {
const host = window.location.hostname.toLowerCase();
if (host.includes("staktrakr.com")) return "staktrakr.com";
if (host.includes("stackrtrackr.com")) return "stackrtrackr.com";
if (host.includes("stackertrackr.com")) return "stackertrackr.com";
return "staktrakr.com";
};
/**
* Performance monitoring utility
*
* @param {Function} fn - Function to monitor
* @param {string} name - Name for logging
* @param {...any} args - Arguments to pass to function
* @returns {any} Result of function execution
*/
const monitorPerformance = (fn, name, ...args) => {
const startTime = performance.now();
const result = fn(...args);
const endTime = performance.now();
const duration = endTime - startTime;
if (duration > 100) {
console.warn(`Performance warning: ${name} took ${duration.toFixed(2)}ms`);
} else {
debugLog(`Performance: ${name} took ${duration.toFixed(2)}ms`);
}
return result;
};
/**
* Creates a debounced function that delays invoking `func` until after `wait`
* milliseconds have elapsed since the last time the debounced function was
* invoked. The debounced function comes with a `cancel` method to cancel
* delayed `func` invocations and a `flush` method to immediately invoke them.
*
* @param {Function} func The function to debounce.
* @param {number} wait The number of milliseconds to delay.
* @returns {Function} Returns the new debounced function.
*/
const debounce = (func, wait) => {
let timeout;
let result;
const later = (context, args) => {
timeout = null;
if (args) {
result = func.apply(context, args);
}
};
const debounced = function(...args) {
const context = this;
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => later(context, args), wait);
return result;
};
debounced.cancel = () => {
clearTimeout(timeout);
timeout = null;
};
debounced.flush = () => {
if (timeout) {
debounced.cancel();
later(this, []);
}
};
return debounced;
};
/**
* Checks if a file exceeds the local upload size limit
*
* @param {File} file - File to validate
* @returns {boolean} True if file is within allowed size
*/
const checkFileSize = (file) => {
const limit = cloudBackupEnabled ? Infinity : MAX_LOCAL_FILE_SIZE;
return file.size <= limit;
};
/**
* Refreshes composition dropdown options in add/edit modals
*/
const refreshCompositionOptions = () => {
const priority = ["Gold", "Silver", "Platinum", "Palladium", "Alloy"];
const sorted = [...compositionOptions].sort((a, b) => {
const ai = priority.indexOf(a);
const bi = priority.indexOf(b);
if (ai !== -1 || bi !== -1) {
return (ai === -1 ? Infinity : ai) - (bi === -1 ? Infinity : bi);
}
return a.localeCompare(b);
});
[elements.itemMetal].forEach((sel) => {
if (!sel) return;
const current = sel.value;
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
sel.innerHTML = sorted
.map((opt) => `<option value="${opt}">${opt}</option>`)
.join("");
if (sorted.includes(current)) sel.value = current;
});
};
/**
* Adds a composition option and updates dropdowns
*
* @param {string} value - Composition to add
*/
const addCompositionOption = (value) => {
if (!value) return;
compositionOptions.add(value);
refreshCompositionOptions();
};
/**
* Extracts up to the first two words from a composition string
* while removing parenthetical content and numeric values.
*
* @param {string} composition - Raw composition description
* @returns {string} First two cleaned words joined by a space
*/
const getCompositionFirstWords = (composition = "") => {
return composition
.replace(/\([^)]*\)/g, "") // remove parentheses and their contents
.replace(/\d+(\.\d+)?%?/g, "") // remove numbers and percentages
.trim()
.split(/\s+/)
.slice(0, 2)
.join(" ");
};
/**
* Determines display-friendly composition text.
*
* Returns "Alloy" when the first word isn't one of the primary metals
* (Gold, Silver, Platinum, Palladium).
*
* @param {string} composition - Raw composition description
* @returns {string} Display text for the composition
*/
const getDisplayComposition = (composition = "") => {
const firstWords = getCompositionFirstWords(composition);
const first = firstWords.split(/\s+/)[0] || "";
const metals = ["gold", "silver", "platinum", "palladium"];
return metals.includes(first.toLowerCase()) ? firstWords : "Alloy";
};
/**
* Builds two-line HTML showing source and last cache refresh or API sync info for a metal
*
* @param {string} metalName - Metal name ('Silver', 'Gold', 'Platinum', 'Palladium')
* @param {string} [mode="cache"] - "cache" or "api" to select timestamp
* @returns {string} HTML string with source line and time line
*/
const getLastUpdateTime = (metalName, mode = "cache") => {
if (!spotHistory || spotHistory.length === 0) return "";
const metalEntries = spotHistory.filter((entry) => entry.metal === metalName);
if (metalEntries.length === 0) return "";
const latestEntry = metalEntries[metalEntries.length - 1];
if (latestEntry.source === "manual") {
return `Manual<br>Time entered ${formatTimestamp(latestEntry.timestamp)}`;
}
if (latestEntry.source === "seed") {
const dateText = latestEntry.timestamp.slice(0, 10);
return `Seed \u00b7 ${dateText}<br>Shift+click price to set`;
}
if (latestEntry.source === "default") return "";
const info = loadDataSync(
mode === "api" ? LAST_API_SYNC_KEY : LAST_CACHE_REFRESH_KEY,
null,
);
if (!info || !info.timestamp) return "";
const label = mode === "api" ? "Last API Sync" : "Last Cache Refresh";
const sourceLine = info.provider || "";
const timeLine = `${label} ${formatTimestamp(info.timestamp)}`;
if (!sourceLine && !timeLine) return "";
return `${sourceLine}<br>${timeLine}`;
};
/**
* Updates spot timestamp element with toggle between cache refresh and API sync times
*
* @param {string} metalName - Metal name ('Silver', 'Gold', 'Platinum', 'Palladium')
*/
const updateSpotTimestamp = (metalName) => {
const el = document.getElementById(`spotTimestamp${metalName}`);
if (!el) return;
const cacheHtml = getLastUpdateTime(metalName, "cache");
const apiHtml = getLastUpdateTime(metalName, "api");
// If no price data at all, show shift+click hint for discoverability
if (!cacheHtml && !apiHtml) {
el.innerHTML = "Shift+click price to set";
el.onclick = null;
return;
}
el.dataset.mode = "cache";
el.dataset.cache = cacheHtml;
el.dataset.api = apiHtml;
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
el.innerHTML = cacheHtml;
el.onclick = () => {
if (el.dataset.mode === "cache") {
el.dataset.mode = "api";
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
el.innerHTML = apiHtml;
} else {
el.dataset.mode = "cache";
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml, javascript.browser.security.insecure-document-method.insecure-document-method
el.innerHTML = cacheHtml;
}
};
};
// =============================================================================
/**
* Pads a number with leading zeros to ensure two-digit format
*
* @param {number} n - Number to pad
* @returns {string} Two-digit string representation
* @example pad2(5) returns "05", pad2(12) returns "12"
*/
const pad2 = (n) => n.toString().padStart(2, "0");
/**
* Returns current date as ISO string (YYYY-MM-DD)
*
* @returns {string} Current date in ISO format
*/
const todayStr = () => {
const d = new Date();
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
};
/**
* Returns current month key in YYYY-MM format
*
* @returns {string} Current month identifier
*/
const currentMonthKey = () => {
const d = new Date();
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
};
/**
* Formats a date/timestamp for display using the user's timezone preference (STACK-63).
* When timezone is "auto" (default), uses the browser's local timezone — identical to previous behavior.
*
* @param {Date|string|number} date - Date object, ISO string, or epoch ms
* @param {Intl.DateTimeFormatOptions} [options] - Override individual format options
* @returns {string} Formatted date+time string
*/
const formatTimestamp = (date, options = {}) => {
let d;
if (date instanceof Date) {
d = date;
} else if (typeof date === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}(:\d{2})?$/.test(date)) {
// Bare UTC timestamp stored by recordSpot (e.g. "2026-02-15 01:58:32")
// These are toISOString() values with T/Z stripped — re-attach Z so Date parses as UTC
d = new Date(date.replace(' ', 'T') + 'Z');
} else {
d = new Date(date);
}
if (isNaN(d.getTime())) return '—';
const tz = localStorage.getItem(TIMEZONE_KEY) || 'auto';
const resolvedTz = tz === 'auto' ? undefined : tz;
const defaults = {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
...(resolvedTz ? { timeZone: resolvedTz } : {})
};
try {
return d.toLocaleString(undefined, { ...defaults, ...options });
} catch (err) {
if (err instanceof RangeError) {
// Invalid IANA timezone in localStorage — fall back to auto and clear bad value
try { localStorage.removeItem(TIMEZONE_KEY); } catch (_) { /* ignore */ }
const safeDefaults = { ...defaults };
delete safeDefaults.timeZone;
return d.toLocaleString(undefined, { ...safeDefaults, ...options });
}
throw err;
}
};
/**
* Formats a date for display (date only, no time) using the user's timezone preference.
*
* @param {Date|string|number} date - Date object, ISO string, or epoch ms
* @returns {string} Formatted date string
*/
const formatDateOnly = (date) => {
return formatTimestamp(date, { hour: undefined, minute: undefined });
};
/**
* Formats a date for display (time only, no date) using the user's timezone preference.
*
* @param {Date|string|number} date - Date object, ISO string, or epoch ms
* @returns {string} Formatted time string
*/
const formatTimeOnly = (date) => {
return formatTimestamp(date, {
year: undefined, month: undefined, day: undefined,
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
};
/**
* Parses various date formats into standard YYYY-MM-DD format
*
* Handles:
* - ISO format (YYYY-MM-DD)
* - US format (MM/DD/YYYY)
* - European format (DD/MM/YYYY)
* - Year-first format (YYYY/MM/DD)
*
* Uses intelligent parsing to distinguish between US and European formats
* based on date values and context clues.
*
* @param {string} dateStr - Date string in any supported format
* @returns {string} Date in YYYY-MM-DD format, or 'Unknown' if parsing fails
*/
function parseDate(dateStr) {
if (!dateStr) return '—';
// Clean the input string
const cleanDateStr = dateStr.trim();
// Try ISO format (YYYY-MM-DD) first - most reliable
if (/^\d{4}-\d{2}-\d{2}$/.test(cleanDateStr)) {
const date = new Date(cleanDateStr);
if (!isNaN(date) && date.toString() !== "Invalid Date") {
return cleanDateStr;
}
}
// Try YYYY/MM/DD format (unambiguous)
const ymdMatch = cleanDateStr.match(
/^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/,
);
if (ymdMatch) {
const year = parseInt(ymdMatch[1], 10);
const month = parseInt(ymdMatch[2], 10) - 1;
const day = parseInt(ymdMatch[3], 10);
if (month >= 0 && month <= 11 && day >= 1 && day <= 31) {
const date = new Date(year, month, day);
if (!isNaN(date) && date.toString() !== "Invalid Date") {
return date.toISOString().split("T")[0];
}
}
}
// Handle ambiguous MM/DD/YYYY vs DD/MM/YYYY formats
const ambiguousMatch = cleanDateStr.match(
/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/,
);
if (ambiguousMatch) {
const first = parseInt(ambiguousMatch[1], 10);
const second = parseInt(ambiguousMatch[2], 10);
const year = parseInt(ambiguousMatch[3], 10);
// If first number > 12, it must be DD/MM/YYYY (European)
if (first > 12 && second <= 12) {
const date = new Date(year, second - 1, first);
if (!isNaN(date) && date.toString() !== "Invalid Date") {
return date.toISOString().split("T")[0];
}
}
// If second number > 12, it must be MM/DD/YYYY (US)
else if (second > 12 && first <= 12) {
const date = new Date(year, first - 1, second);
if (!isNaN(date) && date.toString() !== "Invalid Date") {
return date.toISOString().split("T")[0];
}
}
// Both numbers <= 12, ambiguous - default to US format (MM/DD/YYYY)
else if (first <= 12 && second <= 12) {
// Try US format first
let date = new Date(year, first - 1, second);
if (!isNaN(date) && date.toString() !== "Invalid Date") {
return date.toISOString().split("T")[0];
}
// Fallback to European format
date = new Date(year, second - 1, first);
if (!isNaN(date) && date.toString() !== "Invalid Date") {
return date.toISOString().split("T")[0];
}
}
}
// Try parsing as a general date string (fallback)
try {
const date = new Date(cleanDateStr);
if (!isNaN(date) && date.toString() !== "Invalid Date") {
return date.toISOString().split("T")[0];
}
} catch (e) {
// Continue to fallback
}
// If all parsing fails, return '—'
console.warn(`Could not parse date: "${dateStr}", returning '—'`);
return '—';
}
/**
* Formats a date string into compact MM/DD/YY format
*
* @param {string} dateStr - Date in YYYY-MM-DD format
* @returns {string} Formatted date (e.g., "1/1/69")
*/
const formatDisplayDate = (dateStr) => {
if (!dateStr || dateStr === '—' || dateStr === 'Unknown') return '—';
const parts = dateStr.split('-');
if (parts.length !== 3) return '—';
const year = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10);
const day = parseInt(parts[2], 10);
if (isNaN(year) || isNaN(month) || isNaN(day) || month < 1 || month > 12) return '—';
const yy = String(year).slice(-2);
return `${month}/${day}/${yy}`;
};
/**
* Formats a number as a currency string using the default currency
*
* @param {number|string} value - Number to format
* @param {string} [currency=DEFAULT_CURRENCY] - ISO currency code
* @returns {string} Formatted currency string (e.g., "$1,234.56")
*/
const formatCurrency = (value, currency = (typeof displayCurrency !== 'undefined' ? displayCurrency : DEFAULT_CURRENCY)) => {
const num = parseFloat(value);
if (isNaN(num)) return "";
// Convert internal USD value to target currency (STACK-50)
const rate = (typeof getExchangeRate === 'function') ? getExchangeRate(currency) : 1;
const converted = num * rate;
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency,
}).format(converted);
} catch (e) {
// Fallback for environments without Intl support
return `${currency} ${converted.toFixed(2)}`;
}
};
/**
* Loads the display currency preference from localStorage (STACK-50)
*/
const loadDisplayCurrency = () => {
try {
const saved = loadDataSync(DISPLAY_CURRENCY_KEY, DEFAULT_CURRENCY);
if (saved && typeof saved === 'string') {
displayCurrency = saved;
}
} catch (e) { displayCurrency = DEFAULT_CURRENCY; }
};
/**
* Saves the display currency preference to localStorage (STACK-50)
* @param {string} code - ISO 4217 currency code
*/
const saveDisplayCurrency = (code) => {
displayCurrency = code;
saveDataSync(DISPLAY_CURRENCY_KEY, code);
};
/**
* Extracts the currency symbol from Intl.NumberFormat for the given currency code (STACK-50)
* @param {string} [currency] - ISO 4217 code; defaults to displayCurrency
* @returns {string} Currency symbol (e.g. "$", "€", "£", "₽")
*/
const getCurrencySymbol = (currency) => {
const code = currency || (typeof displayCurrency !== 'undefined' ? displayCurrency : 'USD');
try {
const parts = new Intl.NumberFormat('en', { style: 'currency', currency: code }).formatToParts(0);
const sym = parts.find(p => p.type === 'currency');
return sym ? sym.value : code;
} catch (e) { return code; }
};
/**
* Updates the add/edit modal's currency symbols and placeholders (STACK-50)
* Sets the CSS custom property --currency-symbol on .currency-input wrappers
* and updates input placeholders with the current currency code.
*/
const updateModalCurrencyUI = () => {
const symbol = getCurrencySymbol();
// Scale padding based on symbol width: 1 char → 2rem, 2 → 2.5rem, 3+ → 3.25rem
const padding = symbol.length <= 1 ? '2rem' : symbol.length <= 2 ? '2.5rem' : '3.25rem';
document.querySelectorAll('.currency-input').forEach(el => {
el.style.setProperty('--currency-symbol', `"${symbol}"`);
el.style.setProperty('--currency-padding', padding);
});
const priceInput = document.getElementById('itemPrice');
if (priceInput) priceInput.placeholder = displayCurrency || 'USD';
const marketInput = document.getElementById('itemMarketValue');
if (marketInput) marketInput.placeholder = `${displayCurrency || 'USD'} — defaults to melt value`;
};
/**
* Returns the exchange rate for a target currency (STACK-50).
* 1 USD = getExchangeRate(code) × target currency.
* Falls back: cached exchangeRates → FALLBACK_EXCHANGE_RATES → 1.
*
* @param {string} [targetCurrency] - ISO 4217 code; defaults to displayCurrency
* @returns {number} Exchange rate multiplier
*/
const getExchangeRate = (targetCurrency) => {
const target = targetCurrency || displayCurrency;
if (target === 'USD') return 1;
if (exchangeRates[target]) return exchangeRates[target];
if (typeof FALLBACK_EXCHANGE_RATES !== 'undefined' && FALLBACK_EXCHANGE_RATES[target]) {
return FALLBACK_EXCHANGE_RATES[target];
}
return 1;
};
/**
* Loads cached exchange rates from localStorage (STACK-50).
* Called on startup before any rendering.
*/
const loadExchangeRates = () => {
try {
const saved = loadDataSync(EXCHANGE_RATES_KEY, null);
if (saved && typeof saved === 'object') {
exchangeRates = saved;
}
} catch (e) { exchangeRates = {}; }
};
/**
* Saves exchange rates to localStorage (STACK-50).
* @param {Object<string, number>} rates - Exchange rates keyed by currency code
*/
const saveExchangeRates = (rates) => {
exchangeRates = rates;
saveDataSync(EXCHANGE_RATES_KEY, rates);
};
/**
* Fetches latest exchange rates from the free API and caches them (STACK-50).
* Non-blocking — if fetch fails, existing cached/fallback rates are used.
* @returns {Promise<boolean>} Whether the fetch succeeded
*/
const fetchExchangeRates = async () => {
try {
// Safe: URL from hardcoded constant EXCHANGE_RATE_API_URL or fallback literal
const url = typeof EXCHANGE_RATE_API_URL !== 'undefined'
? EXCHANGE_RATE_API_URL
: 'https://open.er-api.com/v6/latest/USD';
const response = await fetch(url, { method: 'GET', mode: 'cors' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data && data.rates && typeof data.rates === 'object') {
saveExchangeRates(data.rates);
return true;
}
} catch (e) {
console.warn('Exchange rate fetch failed, using cached/fallback rates:', e.message);
}
return false;
};
/**
* Formats a profit/loss value with color coding
*
* @param {number} value - Profit/loss value
* @returns {string} HTML string with appropriate color styling
*/
const formatLossProfit = (value, percent) => {
const formatted = formatCurrency(value);
const pctHtml =
percent !== undefined && percent !== 0
? `<span class="gain-loss-pct">${percent > 0 ? "+" : ""}${percent.toFixed(1)}%</span>`
: "";
if (value > 0) {
return `<span style="color: var(--success);">${pctHtml}${formatted}</span>`;
} else if (value < 0) {
return `<span style="color: var(--danger);">${pctHtml}${formatted}</span>`;
}
return pctHtml + formatted;
};
/**
* Sanitizes text input for safe HTML display
* Prevents XSS attacks by encoding HTML special characters
*
* @param {string} text - Text to sanitize
* @returns {string} Sanitized text safe for HTML insertion
*/
const sanitizeHtml = (text) => {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text.toString();
return div.innerHTML;
};
/**
* Parses a weight string that may contain fractions
* Supports: "0.5", "1/1000", "1 1/2" (mixed numbers)
*
* @param {string} str - Weight string to parse
* @returns {number} Parsed decimal value, or NaN if invalid
*/
const parseFraction = (str) => {
if (typeof str !== 'string') return parseFloat(str);
str = str.trim();
if (!str) return NaN;
// Mixed number: "1 1/2"
const mixedMatch = str.match(/^(\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/);
if (mixedMatch) {
const whole = parseFloat(mixedMatch[1]);
const num = parseFloat(mixedMatch[2]);
const denom = parseFloat(mixedMatch[3]);
if (denom === 0) return NaN;
return whole + (num / denom);
}
// Simple fraction: "1/1000"
const fracMatch = str.match(/^(\d+(?:\.\d+)?)\/(\d+(?:\.\d+)?)$/);
if (fracMatch) {
const num = parseFloat(fracMatch[1]);
const denom = parseFloat(fracMatch[2]);
if (denom === 0) return NaN;
return num / denom;
}
// Plain number
return parseFloat(str);
};
/**
* Converts grams to troy ounces
*
* @param {number} grams - Weight in grams
* @returns {number} Weight in troy ounces
*/
const gramsToOzt = (grams) => grams / 31.1035;
/**
* Converts troy ounces to grams
*
* @param {number} ozt - Weight in troy ounces
* @returns {number} Weight in grams
*/
const oztToGrams = (ozt) => ozt * 31.1035;
/**
* Formats a weight in troy ounces to either grams or ounces.
* If weightUnit is 'gb', displays as Goldback denomination (no gram auto-conversion).
*
* @param {number} ozt - Weight in troy ounces (or Goldback denomination if weightUnit='gb')
* @param {string} [weightUnit] - Optional weight unit: 'oz', 'g', or 'gb'
* @returns {string} Formatted weight string with unit
*/
const formatWeight = (ozt, weightUnit) => {
if (weightUnit === 'gb') {
const w = parseFloat(ozt);
return `${(w % 1 === 0) ? w : w.toFixed(1)} gb`;
}
const weight = parseFloat(ozt);
if (weightUnit === 'g') {
return `${oztToGrams(weight).toFixed(2)} g`;
}
if (weight < 1) {
return `${oztToGrams(weight).toFixed(2)} g`;
}
return `${weight.toFixed(2)} oz`;
};
/**
* Converts amount from specified currency to USD using static rates
*
* @param {number} amount - Monetary amount
* @param {string} [currency="USD"] - Currency code of amount
* @returns {number} Amount converted to USD
*/
const convertToUsd = (amount, currency = "USD") => {
const rates = { USD: 1, EUR: 1.08, GBP: 1.27, CAD: 0.74 };
const rate = rates[currency.toUpperCase()] || 1;
return amount * rate;
};
/**
* Detects currency code from a value string containing symbols or codes
*
* @param {string} str - Value containing currency information
* @returns {string|null} Detected currency code or null if not found
*/
const detectCurrency = (str = "") => {
const s = str.toUpperCase();
if (/[€]|EUR/.test(s)) return "EUR";
if (/[£]|GBP/.test(s)) return "GBP";
if (/CAD|C\$|CA\$/.test(s)) return "CAD";
if (/USD|US\$/.test(s)) return "USD";
return null;
};
/**
* Removes all non-alphanumeric characters from a string, preserving spaces.
*
* @param {string} str - Input string
* @returns {string} Cleaned string containing only letters, numbers, and spaces
*/
const stripNonAlphanumeric = (str = "", { allowHyphen = false, allowSlash = false } = {}) =>
str
.toString()
.replace(
allowHyphen && allowSlash
? /[^a-zA-Z0-9 \\/-]/g
: allowHyphen
? /[^a-zA-Z0-9 -]/g
: allowSlash
? /[^a-zA-Z0-9 \\/]/g
: /[^a-zA-Z0-9 ]/g,
""
);
/**
* Cleans a string by stripping HTML tags and control characters while
* preserving punctuation. Normalizes whitespace and removes diacritics.
*
* @param {string} str - Input string
* @returns {string} Cleaned string
*/
const cleanString = (str = "") =>
str
.toString()
.replace(/<[^>]*>/g, "")
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.replace(/[\u0000-\u001F\u007F]/g, "")
.replace(/\s+/g, " ")
.trim();
/**
* Sanitizes all string properties of an object by stripping non-alphanumeric characters.
*
* @param {Object} obj - Object whose string fields will be sanitized
* @returns {Object} New object with sanitized string fields
*/
const sanitizeObjectFields = (obj) => {
const cleaned = { ...obj };
for (const key of Object.keys(cleaned)) {
if (typeof cleaned[key] === "string" && key !== 'notes') {
// URL fields must not be sanitized — they contain :, /, . characters
if (key === 'obverseImageUrl' || key === 'reverseImageUrl') continue;
const allowHyphen = key === 'date';
cleaned[key] =
(key === 'name' || key === 'purchaseLocation' || key === 'year' || key === 'grade' || key === 'gradingAuthority' || key === 'certNumber')
? cleanString(cleaned[key])
: stripNonAlphanumeric(cleaned[key], { allowHyphen });
}
}
return cleaned;
};
/**
* Allowed inventory item types
* @constant {string[]}
*/
const VALID_TYPES = ["Coin", "Bar", "Round", "Note", "Aurum", "Set", "Other"];
/**
* Normalizes item type to one of the predefined options
*
* @param {string} [type=""] - Raw type string
* @returns {string} Normalized type value
*/
const normalizeType = (type = "") => {
const t = type.toString().trim().toLowerCase();
const match = VALID_TYPES.find(v => v.toLowerCase() === t);
return match || "Other";
};
/**
* Maps Numista type strings to internal StakTrakr categories
*
* @param {string} type - Numista type string
* @returns {string} Mapped internal type
*/
const mapNumistaType = (type = "") => {
const t = type.toLowerCase();
if (t.includes("aurum")) return "Aurum";
if (t.includes("note")) return "Note";
if (t.includes("bar") || t.includes("ingot")) return "Bar";
if (t.includes("round") || t.includes("token") || t.includes("medal")) return "Round";
if (t.includes("coin")) return "Coin";
return "Other";
};
/**
* Determines metal type from Numista composition string
*
* @param {string} composition - Composition description
* @returns {string} Recognized metal or 'Alloy' if not silver/gold/platinum/palladium
*/
const parseNumistaMetal = (composition = "") => {
const c = composition.trim().toLowerCase();
if (c.startsWith("silver")) return "Silver";
if (c.startsWith("gold")) return "Gold";
if (c.startsWith("platinum")) return "Platinum";
if (c.startsWith("palladium")) return "Palladium";
if (c.startsWith("paper")) return "Paper";
return "Alloy";
};
/**
* Save data to localStorage with optional compression
* @param {string} key - Storage key
* @param {any} data - Data to store
*/
const saveData = async (key, data) => {
try {
const raw = JSON.stringify(data);
const out = __compressIfNeeded(raw);
localStorage.setItem(key, out);
} catch(e) {
console.error('saveData failed', e);
}
};
/**
* Load data from localStorage with optional decompression
* @param {string} key - Storage key
* @param {any} [defaultValue=[]] - Default value if no data found
* @returns {any} Parsed data or default value
*/
const loadData = async (key, defaultValue = []) => {
try {
const raw = localStorage.getItem(key);
if(raw == null) return defaultValue;
const str = __decompressIfNeeded(raw);
return JSON.parse(str);
} catch(e) {
console.warn(`loadData failed for ${key}, returning default:`, e);
return defaultValue;
}
};
// Synchronous versions for backward compatibility where async isn't supported
const saveDataSync = (key, data) => { try { const raw = JSON.stringify(data); const out = __compressIfNeeded(raw); localStorage.setItem(key, out); } catch(e) { console.error('saveDataSync failed', e); } };
const loadDataSync = (key, defaultValue = []) => { try { const raw = localStorage.getItem(key); if(raw == null) return defaultValue; const str = __decompressIfNeeded(raw); return JSON.parse(str); } catch(e) { return defaultValue; } };
/**
* Removes unknown localStorage keys to maintain a clean storage state
*
* Iterates over all localStorage entries and deletes any keys not present in
* ALLOWED_STORAGE_KEYS.
*/
const cleanupStorage = () => {
if (typeof localStorage === 'undefined') return;
const allowed = new Set(ALLOWED_STORAGE_KEYS);
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (!allowed.has(key)) {
localStorage.removeItem(key);
}
}
};
/**
* Sorts inventory by date (newest first)
*
* @param {Array} [data=inventory] - Data to sort
* @returns {Array} Sorted inventory data
*/
const sortInventoryByDateNewestFirst = (data = inventory) => {
return [...data].sort((a, b) => {
// Handle unknown dates (—, empty, or Unknown) - they should sort to the bottom (oldest)
const isUnknownA = !a.date || a.date.trim() === '' || a.date.trim() === '—' || a.date.trim() === 'Unknown';
const isUnknownB = !b.date || b.date.trim() === '' || b.date.trim() === '—' || b.date.trim() === 'Unknown';
if (isUnknownA && isUnknownB) return 0; // Both unknown, equal
if (isUnknownA) return 1; // A is unknown, put it after B (older)
if (isUnknownB) return -1; // B is unknown, put it after A (older)
// Both have dates, compare normally
const dateA = new Date(a.date);
const dateB = new Date(b.date);
const timeA = isNaN(dateA) ? 0 : dateA.getTime();
const timeB = isNaN(dateB) ? 0 : dateB.getTime();
return timeB - timeA; // Descending order (newest first)
});
};
/**
* Validates inventory item data
*
* @param {Object} item - Inventory item to validate
* @returns {Object} Validation result with isValid flag and errors array
*/
const validateInventoryItem = (item) => {
const errors = [];
// Required fields
if (
!item.name ||
typeof item.name !== "string" ||
item.name.trim().length === 0
) {
errors.push("Name is required");
} else if (item.name.length > 100) {
errors.push("Name must be 100 characters or less");
}
if (
!item.metal ||
!["Silver", "Gold", "Platinum", "Palladium"].includes(item.metal)
) {
errors.push("Valid metal type is required");
}
// Numeric validations
if (
!item.qty ||
!Number.isInteger(Number(item.qty)) ||
Number(item.qty) < 1
) {
errors.push("Quantity must be a positive integer");
}
if (!item.weight || isNaN(Number(item.weight)) || Number(item.weight) <= 0) {
errors.push("Weight must be a positive number");
}
if (item.price === undefined || item.price === null || isNaN(Number(item.price))) {
errors.push("Price must be a number");
} else if (Number(item.price) < 0) {
errors.push("Price cannot be negative");
}
// Optional field validations
if (item.storageLocation && item.storageLocation.length > 50) {
errors.push("Storage location must be 50 characters or less");
}
if (item.purchaseLocation && item.purchaseLocation.length > 100) {
errors.push("Purchase location must be 100 characters or less");
}
return {
isValid: errors.length === 0,
errors,
};
};
/**
* Sanitizes imported inventory data, coercing invalid fields to safe defaults.
*
* String fields default to an empty string and numeric fields become null when
* parsing fails. This allows imports to proceed even when some fields are
* malformed.
*
* @param {Object} item - Raw item data from an import process
* @returns {Object} Sanitized item
*/
const sanitizeImportedItem = (item) => {
const sanitized = { ...item };
// Ensure metal and composition are strings
if (typeof sanitized.metal !== 'string') {
sanitized.metal = '';
}
if (typeof sanitized.composition !== 'string') {
sanitized.composition = sanitized.metal;
}
// Ensure price always has a numeric value
const parsedPrice = parseFloat(sanitized.price);
sanitized.price = isNaN(parsedPrice) ? 0 : parsedPrice;
// Default purity to 1.0 (pure/fine) when missing or invalid
const parsedPurity = parseFloat(sanitized.purity);
sanitized.purity = (isNaN(parsedPurity) || parsedPurity <= 0 || parsedPurity > 1)
? 1.0 : parsedPurity;
// Ensure other numeric fields parse correctly
const numFields = ['qty', 'weight', 'spotPriceAtPurchase'];
for (const field of numFields) {
if (sanitized[field] !== undefined) {
const parsed = parseFloat(sanitized[field]);
sanitized[field] = isNaN(parsed) ? null : parsed;
}
}
// Normalize and sanitize string fields
const basicFields = ['name', 'type', 'purchaseLocation', 'storageLocation'];
const cleanMultilineString = (str = '') =>
str
.toString()
.replace(/<[^>]*>/g, '')
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '')
.replace(/\r\n?/g, '\n')
.replace(/[ \t]+/g, ' ')
.replace(/ *\n */g, '\n')
.trim();
for (const field of basicFields) {
sanitized[field] = cleanString(sanitized[field]);
}
sanitized.notes = cleanMultilineString(sanitized.notes);
sanitized.type = normalizeType(sanitized.type);
// Reset premium calculations if price or weight are missing
if (!sanitized.price || !sanitized.weight) {
sanitized.premiumPerOz = 0;
sanitized.totalPremium = 0;
}
// Ensure every item has a stable UUID
if (!sanitized.uuid) sanitized.uuid = generateUUID();
return sanitizeObjectFields(sanitized);
};
/**
* Computes the melt value for an inventory item.
* Centralises the formula: weight × qty × spot × purity.
*
* @param {Object} item - Inventory item (needs weight, qty, purity)
* @param {number} spot - Current spot price for the item's metal
* @returns {number} Qty-adjusted melt value
*/
const computeMeltValue = (item, spot) => {
const weight = parseFloat(item.weight) || 0;
const qty = Number(item.qty) || 1;
const purity = parseFloat(item.purity) || 1.0;
const weightOz = (item.weightUnit === 'gb') ? weight * GB_TO_OZT : weight;
return weightOz * qty * spot * purity;
};
/**
* Returns the per-unit Goldback denomination retail price, or null.
* Checks: weightUnit is 'gb', Goldback pricing is enabled, and a price exists.
*
* @param {Object} item - Inventory item
* @returns {number|null} Per-unit denomination price, or null
*/
const getGoldbackRetailPrice = (item) => {
if (item.weightUnit !== 'gb') return null;
if (typeof isGoldbackPricingActive !== 'function' || !isGoldbackPricingActive()) return null;
if (typeof getGoldbackDenominationPrice !== 'function') return null;
return getGoldbackDenominationPrice(parseFloat(item.weight));
};
/**
* Calculates qty-adjusted retail value using the portfolio hierarchy:
* Goldback denomination price → manual market value → melt value.
*
* @param {Object} item - Inventory item
* @param {number} currentSpot - Current spot price for the item's metal
* @returns {{
* qty: number,
* marketValue: number,
* meltValue: number,
* gbDenomPrice: number|null,
* isManualRetail: boolean,
* retailTotal: number
* }}
*/
const calculateRetailPrice = (item, currentSpot) => {
const qty = Number(item?.qty) || 1;
const marketValue = parseFloat(item?.marketValue) || 0;
const meltValue = computeMeltValue(item, Number(currentSpot) || 0);
const gbDenomPrice = (typeof getGoldbackRetailPrice === 'function') ? getGoldbackRetailPrice(item) : null;
const isManualRetail = !gbDenomPrice && marketValue > 0;
const retailTotal = gbDenomPrice ? gbDenomPrice * qty
: isManualRetail ? marketValue * qty
: meltValue;
return {
qty,
marketValue,
meltValue,
gbDenomPrice,
isManualRetail,
retailTotal,
};
};
/**
* Computes normalized valuation values for an inventory item.
* Centralizes purchase, melt, retail, and gain/loss calculations.
*
* @param {Object} item - Inventory item
* @param {number} currentSpot - Current spot price for the item's metal
* @returns {{
* qty: number,
* purchasePrice: number,
* purchaseTotal: number,
* marketValue: number,
* meltValue: number,
* gbDenomPrice: number|null,
* isManualRetail: boolean,
* retailTotal: number,
* hasRetailSignal: boolean,
* gainLoss: number|null
* }}
*/
const computeItemValuation = (item, currentSpot) => {
const normalizedSpot = Number(currentSpot) || 0;
const {
qty,
marketValue,
meltValue,
gbDenomPrice,
isManualRetail,
retailTotal,
} = calculateRetailPrice(item, normalizedSpot);
const purchasePrice = typeof item?.price === 'number' ? item.price : parseFloat(item?.price) || 0;
const purchaseTotal = purchasePrice * qty;
const hasRetailSignal = normalizedSpot > 0 || isManualRetail || !!gbDenomPrice;
const gainLoss = hasRetailSignal ? retailTotal - purchaseTotal : null;
return {
qty,
purchasePrice,
purchaseTotal,
marketValue,
meltValue,
gbDenomPrice,
isManualRetail,
retailTotal,
hasRetailSignal,
gainLoss,
};
};
/**
* Handles errors with user-friendly messaging
*
* @param {Error|string} error - Error to handle
* @param {string} context - Context where error occurred
*/
const handleError = (error, context = "") => {
const errorMessage =
error instanceof Error ? error.message : error.toString();
console.error(`Error in ${context}:`, error);
// Show user-friendly message
const userMessage = getUserFriendlyMessage(errorMessage);
alert(`Error: ${userMessage}`);
};
/**
* Converts technical error messages to user-friendly ones
*
* @param {string} errorMessage - Technical error message
* @returns {string} User-friendly error message
*/
const getUserFriendlyMessage = (errorMessage) => {
if (errorMessage.includes("localStorage")) {
return "Unable to save data. Please check your browser settings.";
}
if (errorMessage.includes("parse") || errorMessage.includes("JSON")) {
return "The file format is not supported or corrupted.";
}
if (errorMessage.includes("network") || errorMessage.includes("fetch")) {
return "Network connection issue. Please check your internet connection.";
}
// Default fallback
return errorMessage || "An unexpected error occurred.";
};
/**
* Downloads a file with the specified content and filename
*
* @param {string} filename - Name of the file to download
* @param {string} content - Content of the file
* @param {string} mimeType - MIME type of the file (default: text/plain)
*/
const downloadFile = (filename, content, mimeType = "text/plain") => {
try {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.style.display = "none";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the object URL after a short delay
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (error) {
console.error("Error downloading file:", error);
handleError(error, "file download");
}
};
// =============================================================================
/**
* Updates footer with localStorage usage statistics
* and visual usage indicator
*/
const updateStorageStats = async () => {
try {
// localStorage: 5MB limit in bytes
const lsLimit = 5 * 1024 * 1024;
let lsUsed = 0;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
// localStorage stores strings in UTF-16 (~2 bytes per character)
lsUsed += (key.length + (value ? value.length : 0)) * 2;
}
// IndexedDB: fetch from imageCache if available
let idbUsed = 0;
let idbLimit = 50 * 1024 * 1024; // 50MB default
if (window.imageCache?.isAvailable()) {
try {
const idbStats = await imageCache.getStorageUsage();
idbUsed = idbStats.totalBytes || 0;
idbLimit = idbStats.limitBytes || idbLimit;
} catch { /* ignore */ }
}
// Combined total for display
const combinedLimit = lsLimit + idbLimit;
const combinedUsed = lsUsed + idbUsed;
const el = document.getElementById("storageUsage");
if (el) {
const lsKB = (lsUsed / 1024).toFixed(1);
const idbKB = idbUsed > 0 ? (idbUsed / 1024).toFixed(1) : '0';
const totalMB = (combinedUsed / (1024 * 1024)).toFixed(2);
const limitMB = (combinedLimit / (1024 * 1024)).toFixed(0);
// Show legend dots + breakdown
el.innerHTML = `<span class="storage-dot storage-dot--ls"></span>LS ${lsKB} KB`
+ ` <span class="storage-dot storage-dot--idb"></span>IDB ${idbKB} KB`
+ ` <span style="color:var(--text-muted); margin-left:4px;">(${totalMB} MB / ${limitMB} MB)</span>`;
}
// Multi-color bar: widths as % of combined limit
const lsBar = document.getElementById("storageBarLs");
const idbBar = document.getElementById("storageBarIdb");
if (lsBar) lsBar.style.width = `${(lsUsed / combinedLimit) * 100}%`;
if (idbBar) idbBar.style.width = `${(idbUsed / combinedLimit) * 100}%`;
// Update tooltips with details
if (lsBar) lsBar.title = `localStorage: ${(lsUsed / 1024).toFixed(1)} KB / ${(lsLimit / (1024 * 1024)).toFixed(0)} MB`;
if (idbBar) idbBar.title = `IndexedDB Images: ${(idbUsed / 1024).toFixed(1)} KB / ${(idbLimit / (1024 * 1024)).toFixed(0)} MB`;
} catch (err) {
const el = document.getElementById("storageUsage");
if (el) el.textContent = "Storage info unavailable";
console.warn("Could not calculate storage", err);
}
};
/**
* Shows storage report options with view and download actions
*/
const downloadStorageReport = () => {
let modal = document.getElementById('storageOptionsModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'storageOptionsModal';
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h2>Storage Report</h2>
<button aria-label="Close modal" class="modal-close" id="storageOptionsClose">×</button>
</div>
<div class="modal-body">
<div class="options-buttons">
<button class="btn" id="viewStorageReportBtn">👁️ View Report</button>
<button class="btn secondary" id="downloadStorageZipBtn">📦 Download ZIP</button>
</div>
</div>
</div>`;
document.body.appendChild(modal);
const closeModal = () => {
modal.style.display = 'none';
document.body.style.overflow = '';
};
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
modal.querySelector('#storageOptionsClose').addEventListener('click', closeModal);
modal.querySelector('#viewStorageReportBtn').addEventListener('click', () => {
closeModal();
openStorageReportPopup();
});
modal.querySelector('#downloadStorageZipBtn').addEventListener('click', async () => {
closeModal();
try {
const zipContent = await generateStorageReportTar();
const timestamp = new Date().toISOString().split('T')[0];
downloadFile(`storage-report-${timestamp}.zip`, zipContent, 'application/zip');
} catch (error) {
console.error('Error creating ZIP file:', error);
alert('Error creating compressed report. Please try again.');
}
});
}
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
};
/**
* Displays the storage report HTML inside a modal iframe
*/
const openStorageReportPopup = async () => {
// Fetch IndexedDB stats before generating report
let idbStats = null;
if (window.imageCache?.isAvailable()) {
try { idbStats = await imageCache.getStorageUsage(); } catch { /* ignore */ }
}
const htmlContent = generateStorageReportHTML(idbStats);
const modal = document.getElementById('storageReportModal');
const iframe = document.getElementById('storageReportFrame');
if (!modal || !iframe) {
alert('Storage report modal not found.');
return;
}
iframe.srcdoc = htmlContent;
const closeBtn = document.getElementById('storageReportCloseBtn');
const closeModal = () => {
modal.style.display = 'none';
document.body.style.overflow = '';
};
if (!modal.dataset.initialized) {
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
if (closeBtn) {
closeBtn.addEventListener('click', closeModal);
}
modal.dataset.initialized = 'true';
}
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
};
/**
* Globally close a modal by id and clear body overflow safely.
* @param {string} id
*/
const closeModalById = (id) => {
try {
const modal = document.getElementById(id);
if (modal) modal.style.display = 'none';
} catch (e) {
/* ignore */
}
try { if (document && document.body) document.body.style.overflow = ''; } catch (e) {}
};
/**
* Opens a modal by id and sets body overflow to hidden.
* Also initializes a click-outside-to-close handler once.
* @param {string} id
*/
const openModalById = (id) => {
try {
const modal = document.getElementById(id);
if (!modal) return;
// initialize click-outside handler once per modal
if (!modal.dataset.initialized) {
modal.addEventListener('click', (e) => {
if (e.target === modal) closeModalById(id);
});
modal.dataset.initialized = 'true';
}
modal.style.display = 'flex';
try { if (document && document.body) document.body.style.overflow = 'hidden'; } catch (e) {}
// focus first focusable element for a11y
try {
const focusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (focusable && focusable.focus) focusable.focus();
} catch (e) {}
} catch (e) {
/* ignore */
}
};
/**
* Generates comprehensive HTML storage report with theme support
*/
const generateStorageReportHTML = (idbStats) => {
const reportData = analyzeStorageData();
const timestamp = formatTimestamp(new Date());
const currentTheme = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
return `<!DOCTYPE html>
<html lang="en" data-theme="${currentTheme}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>StakTrakr Storage Report</title>
<style>
${getStorageReportCSS()}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.9.1/chart.min.js"></script>
</head>
<body>
<div class="report-container">
<header class="report-header">
<div class="header-content">
<h1>📊 StakTrakr Storage Report</h1>
<div class="header-controls">
<button onclick="toggleTheme()" class="theme-toggle-btn">🌓</button>
<button onclick="window.close(); return false;" class="close-btn" aria-label="Close report">×</button>
</div>
</div>
<div class="report-meta">
<span>Generated: ${timestamp}</span>
<span>Version: ${APP_VERSION}</span>
<span>Theme: ${currentTheme}</span>
</div>
</header>
<div class="print-controls">
<button onclick="window.print()" class="print-btn">🖨️ Print Report</button>
</div>
<section class="storage-summary">
<h2>Storage Overview</h2>
<div class="summary-grid">
<div class="summary-item">
<span class="summary-label">localStorage Used:</span>
<span class="summary-value">${reportData.totalSize.toFixed(2)} KB / 5,120 KB</span>
</div>
<div class="summary-item">
<span class="summary-label">localStorage Items:</span>
<span class="summary-value">${reportData.items.length}</span>
</div>
<div class="summary-item">
<span class="summary-label">Largest Item:</span>
<span class="summary-value">${reportData.largestItem ? getStorageItemDisplayName(reportData.largestItem.key) : 'None'} ${reportData.largestItem ? '(' + reportData.largestItem.size.toFixed(2) + ' KB)' : ''}</span>
</div>
${idbStats ? `
<div class="summary-item">
<span class="summary-label">IndexedDB (Images):</span>
<span class="summary-value">${(idbStats.totalBytes / 1024).toFixed(1)} KB / ${(idbStats.limitBytes / (1024 * 1024)).toFixed(0)} MB (${idbStats.count} cached)</span>
</div>
<div class="summary-item">
<span class="summary-label">Combined Total:</span>
<span class="summary-value">${((reportData.totalSize * 1024 + idbStats.totalBytes) / 1024).toFixed(1)} KB</span>
</div>` : ''}
</div>
</section>
<section class="storage-visualization">
<h2>Storage Distribution</h2>
<div class="chart-section">
<div class="chart-container">
<canvas id="storageChart" width="400" height="400"></canvas>
</div>
<div class="chart-legend">
<h3>Click on chart or items below for details</h3>
<div class="legend-items">
${reportData.items.slice(0,5).map((item, index) => `
<div class="legend-item" onclick="showItemDetail('${item.key}')" data-index="${index}">
<span class="legend-color" style="background-color: ${getChartColor(index)}"></span>
<span class="legend-label">${getStorageItemDisplayName(item.key)}</span>
<span class="legend-value">${item.size.toFixed(1)} KB (${item.percentage.toFixed(1)}%)</span>
</div>
`).join('')}
</div>
</div>
</div>
</section>
<section class="storage-breakdown">
<h2>Storage Items Details</h2>
<div class="items-grid">
${reportData.items.map(item => `
<div class="storage-item" onclick="showItemDetail('${item.key}')">
<div class="item-header">
<h3>${getStorageItemDisplayName(item.key)}</h3>
<div class="item-meta">
<span class="item-size">${item.size.toFixed(2)} KB</span>
<span class="item-percentage">${item.percentage.toFixed(1)}%</span>
</div>
</div>
<div class="item-description">
${getStorageItemDescription(item.key)}
</div>
<div class="item-details">
<span class="detail-item">Type: ${item.type}</span>
<span class="detail-item">Records: ${item.recordCount}</span>
</div>
</div>
`).join('')}
</div>
</section>
<footer class="report-footer">
<p>Generated by StakTrakr v${APP_VERSION} • ${new Date().getFullYear()}</p>
<p>This report contains a snapshot of your local browser storage data.</p>
</footer>
</div>
<!-- Modal for item details -->
<div id="itemDetailModal" class="storage-modal" style="display: none;">
<div class="modal-content-large">
<div class="modal-header">
<h3 id="modalTitle">Item Details</h3>
<button class="modal-close" onclick="closeItemDetail()">×</button>
</div>
<div class="modal-body" id="modalContent">
<!-- Content populated by JavaScript -->
</div>
</div>
</div>
<script>
${getStorageReportJS()}
// Initialize chart when page loads
window.addEventListener('DOMContentLoaded', function() {
initializeStorageChart(${JSON.stringify(reportData)});
});
</script>
</body>
</html>`;
};
/**
* Gets chart color for given index
*/
const getChartColor = (index) => {
const colors = [
'#007bff', '#28a745', '#ffc107', '#dc3545', '#6f42c1',
'#fd7e14', '#20c997', '#e83e8c', '#6c757d', '#17a2b8'
];
return colors[index % colors.length];
};
/**
* Analyzes localStorage data and calculates memory usage
*/
const analyzeStorageData = () => {
const items = [];
let totalSize = 0;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
let value = localStorage.getItem(key);
// Sanitize sensitive data
if (key === API_KEY_STORAGE_KEY) {
try {
const config = JSON.parse(value || '{}');
if (config?.keys) {
value = JSON.stringify({ ...config, keys: {} });
}
} catch (err) {
console.warn('Could not sanitize API config for report', err);
}
}
// Calculate size (localStorage stores UTF-16, ~2 bytes per character)
const size = ((key.length + (value ? value.length : 0)) * 2) / 1024; // KB
totalSize += size;
// Determine data type and record count
const analysis = analyzeStorageItem(key, value);
items.push({
key,
size,
value,
type: analysis.type,
recordCount: analysis.recordCount,
parsedData: analysis.parsedData
});
}
// Calculate percentages and sort by size
items.forEach(item => {
item.percentage = (item.size / totalSize) * 100;
});
items.sort((a, b) => b.size - a.size);
return {
items,
totalSize,
largestItem: items[0] || { name: 'None', size: 0 }
};
};
/**
* Analyzes a storage item to determine its type and content
*/
const analyzeStorageItem = (key, value) => {
let type = 'String';
let recordCount = 1;
let parsedData = null;
try {
parsedData = JSON.parse(value);
if (Array.isArray(parsedData)) {
type = 'Array';
recordCount = parsedData.length;
} else if (typeof parsedData === 'object' && parsedData !== null) {
type = 'Object';
recordCount = Object.keys(parsedData).length;
} else {
type = 'JSON Value';
}
} catch (e) {
// Not JSON, treat as string
type = 'String';
recordCount = 1;
}
return { type, recordCount, parsedData };
};
/**
* Gets display name for storage keys
*/
const getStorageItemDisplayName = (key) => {
const names = {
'precious-metals-inventory': 'Inventory Data',
'spot-price-history': 'Spot Price History',
'api-config': 'Metals API Configuration',
'api-cache': 'API Cache',
'spotPriceSilver': 'Silver Spot Price',
'spotPriceGold': 'Gold Spot Price',
'spotPricePlatinum': 'Platinum Spot Price',
'spotPricePalladium': 'Palladium Spot Price',
'theme': 'Theme Setting',
'disclaimer-accepted': 'Disclaimer Acceptance'
};
return names[key] || key;
};
/**
* Gets description for storage items
*/
const getStorageItemDescription = (key) => {
const descriptions = {
'precious-metals-inventory': 'Your complete inventory of precious metals items with all details',
'spot-price-history': 'Historical spot price data from API providers and manual entries',
'api-config': 'Metals API provider configurations and usage statistics',
'api-cache': 'Cached spot price data to reduce API calls',
'spotPriceSilver': 'Current spot price setting for silver',
'spotPriceGold': 'Current spot price setting for gold',
'spotPricePlatinum': 'Current spot price setting for platinum',
'spotPricePalladium': 'Current spot price setting for palladium',
'theme': 'User interface theme preference (dark/light/system)',
'disclaimer-accepted': 'Record of user accepting the application disclaimer'
};
return descriptions[key] || 'Application data stored in browser localStorage';
};
/**
* Creates modal HTML for detailed item view
*/
const createStorageItemModal = (item) => {
const modalId = `modal-${item.key}`;
return `
<div id="${modalId}" class="storage-modal" style="display: none;">
<div class="modal-content-large">
<div class="modal-header">
<h3>${getStorageItemDisplayName(item.key)} Details</h3>
<button class="modal-close" onclick="toggleModal('${item.key}')">×</button>
</div>
<div class="modal-body">
<div class="modal-stats">
<div class="stat-item">
<span class="stat-label">Size:</span>
<span class="stat-value">${item.size.toFixed(2)} KB</span>
</div>
<div class="stat-item">
<span class="stat-label">Type:</span>
<span class="stat-value">${item.type}</span>
</div>
<div class="stat-item">
<span class="stat-label">Records:</span>
<span class="stat-value">${item.recordCount}</span>
</div>
</div>
${generateItemDataTable(item)}
</div>
</div>
</div>
`;
};
/**
* Generates data table for storage item
*/
const generateItemDataTable = (item) => {
if (!item.parsedData) {
return `<div class="data-preview"><strong>Raw Data:</strong><pre>${item.value}</pre></div>`;
}
if (Array.isArray(item.parsedData)) {
if (item.parsedData.length === 0) {
return '<p class="no-data">No records found</p>';
}
// For inventory data, create a proper table
if (item.key === 'precious-metals-inventory') {
const headers = Object.keys(item.parsedData[0] || {});
return `
<div class="data-table-container">
<table class="data-table">
<thead>
<tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr>
</thead>
<tbody>
${item.parsedData.slice(0, 50).map(record =>
`<tr>${headers.map(h => `<td>${sanitizeHtml(record[h]?.toString() || '')}</td>`).join('')}</tr>`
).join('')}
</tbody>
</table>
${item.parsedData.length > 50 ? `<p class="truncated">Showing first 50 of ${item.parsedData.length} records</p>` : ''}
</div>
`;
}
// For other arrays, show a summary
return `
<div class="array-summary">
<p><strong>Array with ${item.parsedData.length} items</strong></p>
<div class="data-preview"><pre>${JSON.stringify(item.parsedData.slice(0, 3), null, 2)}${item.parsedData.length > 3 ? '\n...and ' + (item.parsedData.length - 3) + ' more items' : ''}</pre></div>
</div>
`;
}
if (typeof item.parsedData === 'object') {
const keys = Object.keys(item.parsedData);
return `
<div class="object-summary">
<p><strong>Object with ${keys.length} properties</strong></p>
<div class="data-preview"><pre>${JSON.stringify(item.parsedData, null, 2)}</pre></div>
</div>
`;
}
return `<div class="data-preview"><pre>${JSON.stringify(item.parsedData, null, 2)}</pre></div>`;
};
/**
* Gets enhanced CSS styles for the storage report with theme support
*/
const getStorageReportCSS = () => {
return `
:root {
--primary: #007bff;
--success: #28a745;
--warning: #ffc107;
--danger: #dc3545;
--info: #17a2b8;
--light: #f8f9fa;
--dark: #343a40;
--bg-primary: #f9fafb;
--bg-secondary: #f8f9fa;
--text-primary: #333333;
--text-secondary: #666666;
--border: #dee2e6;
}
[data-theme="dark"] {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--text-primary: #f8fafc;
--text-secondary: #cccccc;
--border: #404040;
--light: #2d2d2d;
--dark: #f8f9fa;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
line-height: 1.6;
color: var(--text-primary);
background: var(--bg-secondary);
transition: all 0.3s ease;
}
.storage-report-modal-content {
width: 95vw;
max-width: 1200px;
height: 90vh;
max-height: 900px;
}
.storage-report-controls {
display: flex;
align-items: center;
gap: 0.5rem;
}
.theme-btn {
background: none;
border: 1px solid var(--border);
padding: 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
}
.theme-btn:hover {
background: var(--bg-secondary);
transform: scale(1.05);
}
.storage-report-body {
padding: 1rem;
overflow-y: auto;
height: calc(90vh - 80px);
}
.storage-report-header {
margin-bottom: 1.5rem;
}
.storage-summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem;
text-align: center;
transition: all 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.stat-label {
display: block;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: var(--primary);
}
.storage-report-actions {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.storage-detail-modal .modal-content {
width: 90%;
max-width: 800px;
max-height: 80%;
}
.detail-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.detail-stat {
display: flex;
justify-content: space-between;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 0.25rem;
}
.inventory-table-container {
margin-top: 1rem;
}
.inventory-detail-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.inventory-detail-table th,
.inventory-detail-table td {
border: 1px solid var(--border);
padding: 0.5rem;
text-align: left;
}
.inventory-detail-table th {
background: var(--bg-secondary);
font-weight: 600;
position: sticky;
top: 0;
}
.data-preview {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 0.25rem;
padding: 1rem;
margin-top: 1rem;
}
.data-preview h4 {
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.data-preview pre {
font-size: 0.75rem;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 300px;
overflow-y: auto;
color: var(--text-primary);
}
/* Dark theme for storage modals */
.storage-dark-theme {
background: var(--bg-primary);
color: var(--text-primary);
}
.storage-dark-theme .modal-content {
background: var(--bg-primary);
border: 1px solid var(--border);
}
.storage-dark-theme .modal-header {
background: var(--dark);
color: var(--text-primary);
border-bottom: 1px solid var(--border);
}
.btn {
padding: 0.5rem 1rem;
border: 1px solid var(--border);
border-radius: 0.25rem;
background: var(--bg-primary);
color: var(--text-primary);
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.875rem;
}
.btn:hover {
background: var(--bg-secondary);
transform: translateY(-1px);
}
.btn.premium {
background: var(--primary);
color: #f8fafc;
border-color: var(--primary);
}
.btn.success {
background: var(--success);
color: #f8fafc;
border-color: var(--success);
}
.btn.secondary {
background: var(--text-secondary);
color: #f8fafc;
border-color: var(--text-secondary);
}
/* Enhanced responsive design */
@media (max-width: 768px) {
.storage-report-modal-content {
width: 98vw;
height: 95vh;
}
.storage-summary-stats {
grid-template-columns: 1fr;
}
.storage-report-actions {
flex-direction: column;
}
.detail-stats {
grid-template-columns: 1fr;
}
}
.report-container {
max-width: 8.5in;
margin: 0 auto;
background: var(--bg-primary);
padding: 1in;
min-height: 11in;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.header-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.theme-toggle-btn,
.close-btn {
background: none;
border: 1px solid var(--border);
padding: 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
}
.theme-toggle-btn:hover,
.close-btn:hover {
background: var(--bg-secondary);
}
.storage-visualization {
margin-bottom: 2rem;
}
.chart-section {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
align-items: stretch;
}
@media (max-width: 768px) {
.chart-section {
grid-template-columns: 1fr;
}
}
.chart-container {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.chart-container canvas {
max-width: 100%;
height: 100% !important;
max-height: 400px;
}
.chart-legend {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.legend-items {
overflow-y: auto;
flex: 1;
max-height: 400px;
}
.chart-legend h3 {
margin-bottom: 1rem;
color: var(--text-primary);
font-size: 1rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
margin-bottom: 0.5rem;
border-radius: 0.25rem;
cursor: pointer;
transition: all 0.2s ease;
}
.legend-item:hover {
background: var(--bg-secondary);
transform: translateX(5px);
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 50%;
flex-shrink: 0;
}
.legend-label {
flex: 1;
font-weight: 500;
color: var(--text-primary);
}
.legend-value {
font-size: 0.875rem;
color: var(--text-secondary);
font-weight: 600;
}
.report-header {
text-align: center;
border-bottom: 3px solid var(--primary);
padding-bottom: 1rem;
margin-bottom: 2rem;
}
.report-header h1 {
color: var(--primary);
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.report-meta {
display: flex;
justify-content: space-between;
font-size: 0.9rem;
color: var(--text-secondary);
}
.print-controls {
text-align: center;
margin-bottom: 2rem;
}
.print-btn {
background: var(--primary);
color: #f8fafc;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.print-btn:hover {
background: var(--primary-hover);
}
.storage-summary {
margin-bottom: 2rem;
}
.storage-summary h2 {
color: var(--text-primary);
margin-bottom: 1rem;
font-size: 1.5rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.summary-item {
background: var(--bg-primary);
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
border-left: 4px solid var(--primary);
display: flex;
justify-content: space-between;
align-items: center;
}
.summary-label {
font-weight: 600;
color: var(--text-secondary);
}
.summary-value {
font-weight: 700;
color: var(--primary);
font-size: 1.1rem;
}
.storage-breakdown h2 {
color: var(--text-primary);
margin-bottom: 1rem;
font-size: 1.5rem;
}
.items-grid {
display: grid;
gap: 1rem;
}
.storage-item {
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem;
background: var(--bg-primary);
transition: box-shadow 0.2s;
}
.storage-item:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
cursor: pointer;
}
.item-header h3 {
color: var(--primary);
font-size: 1.2rem;
}
.item-meta {
display: flex;
gap: 1rem;
align-items: center;
}
.item-size {
font-weight: 600;
color: var(--success);
}
.item-percentage {
background: var(--primary);
color: #f8fafc;
padding: 0.25rem 0.5rem;
border-radius: 1rem;
font-size: 0.8rem;
}
.item-description {
color: var(--text-secondary);
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.item-details {
display: flex;
gap: 1rem;
margin-bottom: 0.5rem;
}
.detail-item {
background: var(--bg-secondary);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.view-details-btn {
background: var(--success);
color: #f8fafc;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.9rem;
transition: filter 0.2s;
}
.view-details-btn:hover {
filter: brightness(0.9);
}
.storage-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content-large {
background: #f9fafb;
border-radius: 0.5rem;
width: 90%;
max-width: 800px;
max-height: 80%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
background: var(--primary);
color: #f8fafc;
padding: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-close {
background: none;
border: none;
color: #f8fafc;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
.modal-body {
padding: 1rem;
overflow-y: auto;
flex: 1;
}
.modal-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.stat-item {
background: var(--bg-secondary);
padding: 0.75rem;
border-radius: 0.25rem;
display: flex;
justify-content: space-between;
}
.stat-label {
font-weight: 600;
color: var(--text-secondary);
}
.stat-value {
font-weight: 700;
color: var(--primary);
}
.data-table-container {
overflow-x: auto;
margin-top: 1rem;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
.data-table th,
.data-table td {
border: 1px solid var(--border);
padding: 0.5rem;
text-align: left;
}
.data-table th {
background: var(--bg-secondary);
font-weight: 600;
position: sticky;
top: 0;
}
.data-table td {
max-width: 150px;
word-wrap: break-word;
overflow-wrap: break-word;
}
.data-preview {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 0.25rem;
margin-top: 1rem;
}
.data-preview pre {
font-size: 0.8rem;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 300px;
overflow-y: auto;
}
.truncated {
text-align: center;
color: var(--text-secondary);
font-style: italic;
margin-top: 0.5rem;
}
.no-data {
text-align: center;
color: var(--text-secondary);
font-style: italic;
padding: 2rem;
}
.report-footer {
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
text-align: center;
color: var(--text-secondary);
font-size: 0.9rem;
}
@media print {
body {
background: #f9fafb;
}
.print-controls {
display: none;
}
.storage-modal {
display: none !important;
}
.view-details-btn {
display: none;
}
.report-container {
margin: 0;
padding: 0.5in;
max-width: none;
min-height: auto;
}
.storage-item {
break-inside: avoid;
margin-bottom: 0.5rem;
}
}
@media (max-width: 768px) {
.report-container {
padding: 1rem;
}
.summary-grid {
grid-template-columns: 1fr;
}
.item-meta {
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.modal-content-large {
width: 95%;
max-height: 90%;
}
}
`;
};
/**
* Gets enhanced JavaScript for the storage report with theme and chart support
*/
const getStorageReportJS = () => {
return `
let currentChart = null;
let currentReportData = null;
function getChartColor(index) {
const colors = [
'#007bff', '#28a745', '#ffc107', '#dc3545', '#6f42c1',
'#fd7e14', '#20c997', '#e83e8c', '#6c757d', '#17a2b8'
];
return colors[index % colors.length];
}
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
// Recreate chart with new theme
if (currentChart && currentReportData) {
currentChart.destroy();
initializeStorageChart(currentReportData);
}
}
function initializeStorageChart(reportData) {
currentReportData = reportData;
const currentChartItems = reportData.items.slice(0, 5);
const canvas = document.getElementById('storageChart');
if (!canvas || typeof Chart === 'undefined') {
console.warn('Chart.js not available or canvas not found');
return;
}
const ctx = canvas.getContext('2d');
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const data = {
labels: currentChartItems.map(item => getStorageItemDisplayName(item.key)),
datasets: [{
data: currentChartItems.map(item => item.size),
backgroundColor: currentChartItems.map((_, index) => getChartColor(index)),
borderColor: isDark ? '#404040' : '#f8fafc',
borderWidth: 2,
hoverBorderWidth: 3,
hoverOffset: 10
}]
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: isDark ? '#343a40' : '#f8fafc',
titleColor: isDark ? '#f8fafc' : '#000000',
bodyColor: isDark ? '#f8fafc' : '#000000',
borderColor: isDark ? '#6c757d' : '#dee2e6',
borderWidth: 1,
callbacks: {
label: (context) => {
const item = currentChartItems[context.dataIndex];
return [
\`\${context.label}: \${item.size.toFixed(2)} KB\`,
\`\${item.percentage.toFixed(1)}% of total\`,
\`\${item.recordCount} records\`
];
}
}
}
},
onClick: (event, elements) => {
if (elements.length > 0) {
const index = elements[0].index;
showItemDetail(currentChartItems[index].key);
}
},
animation: {
animateRotate: true,
animateScale: true
}
};
if (currentChart) {
currentChart.destroy();
}
currentChart = new Chart(ctx, {
type: 'pie',
data: data,
options: options
});
}
function showItemDetail(key) {
const item = currentReportData.items.find(i => i.key === key);
if (!item) return;
const modal = document.getElementById('itemDetailModal');
const title = document.getElementById('modalTitle');
const content = document.getElementById('modalContent');
if (!modal || !title || !content) return;
title.textContent = \`\${getStorageItemDisplayName(item.key)} Details\`;
content.innerHTML = generateDetailContent(item);
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function closeItemDetail() {
const modal = document.getElementById('itemDetailModal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
}
}
function generateDetailContent(item) {
let content = \`
<div class="detail-stats">
<div class="detail-stat">
<span class="stat-label">Size:</span>
<span class="stat-value">\${item.size.toFixed(2)} KB</span>
</div>
<div class="detail-stat">
<span class="stat-label">Type:</span>
<span class="stat-value">\${item.type}</span>
</div>
<div class="detail-stat">
<span class="stat-label">Records:</span>
<span class="stat-value">\${item.recordCount}</span>
</div>
<div class="detail-stat">
<span class="stat-label">Percentage:</span>
<span class="stat-value">\${item.percentage.toFixed(1)}%</span>
</div>
</div>
\`;
if (item.parsedData && Array.isArray(item.parsedData) && item.parsedData.length > 0) {
if (item.key === 'precious-metals-inventory') {
content += generateInventoryTable(item.parsedData);
} else {
content += \`<div class="data-preview"><h4>Sample Data:</h4><pre>\${JSON.stringify(item.parsedData.slice(0, 3), null, 2)}\${item.parsedData.length > 3 ? '\\n...and ' + (item.parsedData.length - 3) + ' more items' : ''}</pre></div>\`;
}
} else if (item.parsedData) {
content += \`<div class="data-preview"><h4>Data:</h4><pre>\${JSON.stringify(item.parsedData, null, 2)}</pre></div>\`;
} else {
content += \`<div class="data-preview"><h4>Raw Data:</h4><pre>\${item.value}</pre></div>\`;
}
return content;
}
function generateInventoryTable(data) {
if (!data || data.length === 0) return '<p>No inventory data found</p>';
const headers = Object.keys(data[0]);
const displayLimit = 20;
return \`
<div class="inventory-table-container">
<h4>Inventory Data (showing first \${Math.min(displayLimit, data.length)} of \${data.length} items)</h4>
<table class="inventory-detail-table">
<thead>
<tr>\${headers.map(h => \`<th>\${h}</th>\`).join('')}</tr>
</thead>
<tbody>
\${data.slice(0, displayLimit).map(record =>
\`<tr>\${headers.map(h => \`<td>\${String(record[h] || '')}</td>\`).join('')}</tr>\`
).join('')}
</tbody>
</table>
</div>
\`;
}
function getStorageItemDisplayName(key) {
const names = {
'precious-metals-inventory': 'Inventory Data',
'spot-price-history': 'Spot Price History',
'api-config': 'Metals API Configuration',
'api-cache': 'API Cache',
'spotPriceSilver': 'Silver Spot Price',
'spotPriceGold': 'Gold Spot Price',
'spotPricePlatinum': 'Platinum Spot Price',
'spotPricePalladium': 'Palladium Spot Price',
'theme': 'Theme Setting',
'disclaimer-accepted': 'Disclaimer Acceptance'
};
return names[key] || key;
}
// Close modal when clicking outside
document.addEventListener('click', function(e) {
if (e.target.classList.contains('storage-modal')) {
e.target.style.display = 'none';
document.body.style.overflow = '';
}
});
// Close modal with ESC key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const openModal = document.querySelector('.storage-modal[style*="flex"]');
if (openModal) {
openModal.style.display = 'none';
document.body.style.overflow = '';
}
}
});
// Export functions to global scope
window.toggleTheme = toggleTheme;
window.showItemDetail = showItemDetail;
window.closeItemDetail = closeItemDetail;
window.initializeStorageChart = initializeStorageChart;
`;
};
/**
* Generates a comprehensive ZIP file with storage report and data
*/
const generateStorageReportTar = async () => {
if (typeof JSZip === 'undefined') {
throw new Error('JSZip library not available for compressed reports');
}
const zip = new JSZip();
const timestamp = new Date().toISOString().split('T')[0];
// Add themed HTML report
const htmlContent = generateStorageReportHTML();
zip.file(`storage-report-${timestamp}.html`, htmlContent);
// Add JSON data for each storage item
const reportData = analyzeStorageData();
const jsonReport = {
metadata: {
generated: new Date().toISOString(),
version: APP_VERSION,
totalSize: reportData.totalSize,
itemCount: reportData.items.length,
theme: document.documentElement.getAttribute('data-theme') || 'light'
},
items: reportData.items.map(item => ({
key: item.key,
displayName: getStorageItemDisplayName(item.key),
description: getStorageItemDescription(item.key),
size: item.size,
percentage: item.percentage,
type: item.type,
recordCount: item.recordCount,
data: item.parsedData || item.value
}))
};
zip.file(`storage-data-${timestamp}.json`, JSON.stringify(jsonReport, null, 2));
// Add individual data files for large items
for (const item of reportData.items) {
if (item.size > 10 && item.parsedData) { // Items larger than 10KB
const filename = `${item.key}-${timestamp}.json`;
zip.file(filename, JSON.stringify(item.parsedData, null, 2));
}
}
// Add README
const readme = `StakTrakr Storage Report Archive
=================================
Generated: ${formatTimestamp(new Date())}
Version: ${APP_VERSION}
Total Storage: ${reportData.totalSize.toFixed(2)} KB
Items: ${reportData.items.length}
Files Included:
- storage-report-${timestamp}.html: Interactive HTML report
- storage-data-${timestamp}.json: Complete storage analysis
- Individual JSON files for large storage items
To view the report:
1. Open storage-report-${timestamp}.html in any web browser
2. Use the theme toggle to switch between light/dark modes
3. Click on chart segments or table items for detailed views
This archive contains a complete snapshot of your StakTrakr storage data.`;
zip.file('README.txt', readme);
// Generate the ZIP file
const content = await zip.generateAsync({ type: 'blob' });
return content;
};
/** Storage compression helpers (Phase 1C) */
const __ST_COMP_PREFIX = 'CMP1:';
function __compressIfNeeded(str){
try{
if(!str || str.length < 4096) return str;
const comp = LZString.compressToUTF16(str);
return __ST_COMP_PREFIX + comp;
}catch(e){ return str; }
}
function __decompressIfNeeded(stored){
try{
if(typeof stored !== 'string') return stored;
if(stored.startsWith(__ST_COMP_PREFIX)){
const raw = LZString.decompressFromUTF16(stored.slice(__ST_COMP_PREFIX.length));
return raw;
}
return stored;
}catch(e){ return stored; }
}
/**
* Returns black or white contrast color for a given background.
* Supports hex strings and CSS variables.
* @param {string} bg - Background color in hex or CSS var format
* @returns {string} '#000000' or '#ffffff'
*/
function getContrastColor(bg) {
if (!bg) return '#000000';
let hex = bg.trim();
if (hex.startsWith('var(')) {
const varName = hex.slice(4, -1).trim();
hex = getComputedStyle(document.documentElement)
.getPropertyValue(varName)
.trim();
}
if (hex.startsWith('#')) {
hex = hex.slice(1);
}
if (hex.length === 3) {
hex = hex.split('').map(c => c + c).join('');
}
if (hex.length !== 6) return '#000000';
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? '#000000' : '#ffffff';
}
/** Generates a storage utilization report */
function generateStorageReport(){
try{
const items = [];
for(let i=0;i<localStorage.length;i++){
const k = localStorage.key(i);
const v = localStorage.getItem(k) || '';
const sizeBytes = (k.length + v.length) * 2; // rough UTF-16 bytes
items.push({ key:k, sizeBytes, sizeKB: +(sizeBytes/1024).toFixed(2) });
}
items.sort((a,b)=>b.sizeBytes - a.sizeBytes);
const totalBytes = items.reduce((s,x)=>s+x.sizeBytes,0);
return { totalKB: +(totalBytes/1024).toFixed(2), items };
}catch(e){ return { totalKB:0, items:[] }; }
}
/**
* Opens eBay sold listings search for the given item name and metal
* @param {string} searchTerm - The item name and metal to search for
*/
function openEbaySearch(searchTerm) {
openEbaySoldSearch(searchTerm);
}
/**
* Strips search-operator characters from a search term for use in external URLs.
* Removes quotes, parentheses, and backslashes that act as search operators on eBay.
* @param {string} term - Raw search term (may contain user-entered punctuation)
* @returns {string} Cleaned term safe for external search queries
*/
function cleanSearchTerm(term) {
return term
.replace(/["'()\\]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function openEbayBuySearch(searchTerm) {
if (!searchTerm) return;
const cleanTerm = cleanSearchTerm(searchTerm);
const encodedTerm = encodeURIComponent(cleanTerm);
// eBay active listings URL — items currently for sale, sorted by best match
const ebayUrl = `https://www.ebay.com/sch/i.html?_from=R40&_nkw=${encodedTerm}&_sacat=0&LH_BIN=1&_sop=12`;
window.open(ebayUrl, `ebay_buy_${Date.now()}`, 'width=1250,height=800,scrollbars=yes,resizable=yes,toolbar=no,location=no,menubar=no,status=no');
}
function openEbaySoldSearch(searchTerm) {
if (!searchTerm) return;
const cleanTerm = cleanSearchTerm(searchTerm);
const encodedTerm = encodeURIComponent(cleanTerm);
// eBay sold listings URL — completed sales, sorted by most recent
const ebayUrl = `https://www.ebay.com/sch/i.html?_from=R40&_nkw=${encodedTerm}&_sacat=0&LH_Sold=1&LH_Complete=1&_sop=13`;
window.open(ebayUrl, `ebay_sold_${Date.now()}`, 'width=1250,height=800,scrollbars=yes,resizable=yes,toolbar=no,location=no,menubar=no,status=no');
}
if (typeof window !== 'undefined') {
window.getContrastColor = getContrastColor;
window.generateStorageReport = generateStorageReport;
window.updateSpotTimestamp = updateSpotTimestamp;
/**
* Show a brief toast notification that auto-dismisses.
* Reuses the cloud-toast CSS class + keyframes already in styles.css.
* @param {string} message - Text to display
* @param {number} [duration=3000] - Auto-dismiss time in ms
*/
window.showToast = (message, duration = 3000) => {
const toast = document.createElement('div');
toast.className = 'cloud-toast';
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.classList.add('fade-out');
toast.addEventListener('animationend', () => toast.remove());
}, duration);
};
window.cleanupStorage = cleanupStorage;
window.checkFileSize = checkFileSize;
window.MAX_LOCAL_FILE_SIZE = MAX_LOCAL_FILE_SIZE;
window.closeModalById = closeModalById;
window.openModalById = openModalById;
window.updateStorageStats = updateStorageStats;
window.downloadStorageReport = downloadStorageReport;
window.openStorageReportPopup = openStorageReportPopup;
window.debounce = debounce;
window.openEbaySearch = openEbaySearch;
window.openEbayBuySearch = openEbayBuySearch;
window.openEbaySoldSearch = openEbaySoldSearch;
window.cleanSearchTerm = cleanSearchTerm;
window.computeMeltValue = computeMeltValue;
window.calculateRetailPrice = calculateRetailPrice;
window.computeItemValuation = computeItemValuation;
// Multi-currency support (STACK-50)
window.loadDisplayCurrency = loadDisplayCurrency;
window.saveDisplayCurrency = saveDisplayCurrency;
window.getCurrencySymbol = getCurrencySymbol;
window.updateModalCurrencyUI = updateModalCurrencyUI;
window.getExchangeRate = getExchangeRate;
window.loadExchangeRates = loadExchangeRates;
window.saveExchangeRates = saveExchangeRates;
window.fetchExchangeRates = fetchExchangeRates;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
stripNonAlphanumeric,
sanitizeObjectFields,
sanitizeImportedItem,
computeMeltValue,
calculateRetailPrice,
computeItemValuation,
getContrastColor,
debounce,
generateUUID,
};
}