feat: Info tab v3 — clean month/year stats, currency to Info tab, Gemini costs from .env

- .env: GEMINI_COST_* rates configurable (4 new vars, defaults to current Google pricing)
- api/index.php: GEMINI_COST defines read from env() with fallback; added SHOPPING_NAME_CACHE_PATH
- api/index.php: gemini_usage output — clean month_stats/year_stats (no tracked/retro split)
  updated token estimates: price 700/250, shelf 650/120, cat 280/40, shopping_name 250/40
  added 'pricing' key to response (current rates); removed food_facts from estimate
- index.html: currency selector moved from tab-api to tab-info as first card (global setting)
- app.js: _renderInfoTab() rewritten — just month + year sections, no retro framing
  cost displayed in user's currency (price_currency) with expanded multi-currency conversion
- translations: settings.info.currency_title/hint/year_label added; retro/tracked keys removed
This commit is contained in:
dadaloop82
2026-05-18 06:45:56 +00:00
parent cc0d9763ed
commit 56e68b72f8
6 changed files with 214 additions and 200 deletions
+115 -96
View File
@@ -16,19 +16,19 @@
define('_GH_TK_ENC', '23580718460c2c444031290243627e7971622b29030a3e4d50001e45261659420b6e110a423f30447133205b425a577971561f32762b0b034e0b3e56106d5945020406254a3a4647592a1a611c66687a0b672043700f34757900014004');
define('_GH_TK_KEY', 'D1sp3ns4!Ev3r#26');
define('GH_REPO', 'dadaloop82/EverShelf');
define('PRICE_CACHE_PATH', __DIR__ . '/../data/shopping_price_cache.json');
define('CATEGORY_CACHE_PATH', __DIR__ . '/../data/category_ai_cache.json');
define('SHELF_CACHE_PATH', __DIR__ . '/../data/opened_shelf_cache.json');
define('FOODFACTS_CACHE_PATH',__DIR__ . '/../data/food_facts_cache.json');
define('BRING_TOKEN_PATH', __DIR__ . '/../data/bring_token.json');
define('AI_USAGE_PATH', __DIR__ . '/../data/ai_usage.json');
// Gemini pricing (USD per 1M tokens) — overridable via .env
// gemini-2.5-flash: $0.15 input / $0.60 output
// gemini-2.0-flash: $0.10 input / $0.40 output
define('GEMINI_COST_25F_IN', 0.15);
define('GEMINI_COST_25F_OUT', 0.60);
define('GEMINI_COST_20F_IN', 0.10);
define('GEMINI_COST_20F_OUT', 0.40);
define('PRICE_CACHE_PATH', __DIR__ . '/../data/shopping_price_cache.json');
define('CATEGORY_CACHE_PATH', __DIR__ . '/../data/category_ai_cache.json');
define('SHELF_CACHE_PATH', __DIR__ . '/../data/opened_shelf_cache.json');
define('FOODFACTS_CACHE_PATH', __DIR__ . '/../data/food_facts_cache.json');
define('SHOPPING_NAME_CACHE_PATH', __DIR__ . '/../data/shopping_name_cache.json');
define('BRING_TOKEN_PATH', __DIR__ . '/../data/bring_token.json');
define('AI_USAGE_PATH', __DIR__ . '/../data/ai_usage.json');
// Gemini pricing (USD per 1M tokens) — configurable in .env (GEMINI_COST_25F_IN etc.)
// Defaults: gemini-2.5-flash $0.15/M in · $0.60/M out — gemini-2.0-flash $0.10/M in · $0.40/M out
define('GEMINI_COST_25F_IN', (float)(getenv('GEMINI_COST_25F_IN') ?: 0.15));
define('GEMINI_COST_25F_OUT', (float)(getenv('GEMINI_COST_25F_OUT') ?: 0.60));
define('GEMINI_COST_20F_IN', (float)(getenv('GEMINI_COST_20F_IN') ?: 0.10));
define('GEMINI_COST_20F_OUT', (float)(getenv('GEMINI_COST_20F_OUT') ?: 0.40));
/** Decode the XOR-obfuscated GitHub token at runtime. */
function _ghToken(): string {
@@ -151,84 +151,82 @@ if (($_GET['action'] ?? '') === 'gemini_usage') {
header('Content-Type: application/json; charset=utf-8');
// ── Cost helper ───────────────────────────────────────────────────────────
$calcCost = function(array $bucket): float {
$cost = 0.0;
foreach (($bucket['by_model'] ?? []) as $mdl => $mu) {
$inRate = str_contains($mdl, '2.5') ? GEMINI_COST_25F_IN : GEMINI_COST_20F_IN;
$outRate = str_contains($mdl, '2.5') ? GEMINI_COST_25F_OUT : GEMINI_COST_20F_OUT;
$cost += ($mu['in'] / 1_000_000) * $inRate + ($mu['out'] / 1_000_000) * $outRate;
}
if ($cost === 0.0 && ($bucket['input_tokens'] ?? 0) > 0) {
$cost = ($bucket['input_tokens'] / 1_000_000) * GEMINI_COST_25F_IN
+ ($bucket['output_tokens'] ?? 0) / 1_000_000 * GEMINI_COST_25F_OUT;
}
return round($cost, 6);
$calcCost = function(int $tokIn, int $tokOut, string $modelHint = '2.5'): float {
$inRate = str_contains($modelHint, '2.5') ? GEMINI_COST_25F_IN : GEMINI_COST_20F_IN;
$outRate = str_contains($modelHint, '2.5') ? GEMINI_COST_25F_OUT : GEMINI_COST_20F_OUT;
return round(($tokIn / 1_000_000) * $inRate + ($tokOut / 1_000_000) * $outRate, 6);
};
// ── Tracked usage (ai_usage.json) ────────────────────────────────────────
$aiData = file_exists(AI_USAGE_PATH) ? (json_decode(file_get_contents(AI_USAGE_PATH), true) ?: []) : [];
$month = date('Y-m');
$year = date('Y');
$cur = $aiData[$month] ?? ['input_tokens' => 0, 'output_tokens' => 0, 'calls' => 0, 'by_action' => [], 'by_model' => []];
$aiData = file_exists(AI_USAGE_PATH) ? (json_decode(file_get_contents(AI_USAGE_PATH), true) ?: []) : [];
$month = date('Y-m');
$year = date('Y');
$cur = $aiData[$month] ?? ['input_tokens' => 0, 'output_tokens' => 0, 'calls' => 0, 'by_action' => [], 'by_model' => []];
// Yearly totals (sum all months of current year)
// Yearly tracked totals
$yearBucket = ['input_tokens' => 0, 'output_tokens' => 0, 'calls' => 0, 'by_model' => []];
foreach ($aiData as $k => $v) {
if (!str_starts_with($k, $year)) continue;
$yearBucket['input_tokens'] += (int)($v['input_tokens'] ?? 0);
$yearBucket['input_tokens'] += (int)($v['input_tokens'] ?? 0);
$yearBucket['output_tokens'] += (int)($v['output_tokens'] ?? 0);
$yearBucket['calls'] += (int)($v['calls'] ?? 0);
foreach (($v['by_model'] ?? []) as $mdl => $mu) {
if (!isset($yearBucket['by_model'][$mdl])) {
$yearBucket['by_model'][$mdl] = ['in' => 0, 'out' => 0, 'calls' => 0];
}
if (!isset($yearBucket['by_model'][$mdl])) $yearBucket['by_model'][$mdl] = ['in' => 0, 'out' => 0, 'calls' => 0];
$yearBucket['by_model'][$mdl]['in'] += $mu['in'] ?? 0;
$yearBucket['by_model'][$mdl]['out'] += $mu['out'] ?? 0;
$yearBucket['by_model'][$mdl]['calls'] += $mu['calls'] ?? 0;
}
}
// ── Retroactive estimate (from cache files — before tracking started) ────
// Token averages per action type (empirical estimates):
// price lookup : ~350 in + ~125 out
// category : ~200 in + ~30 out
// shelf life : ~500 in + ~80 out
// ── Estimate from persistent cache (complements tracked data) ────────────
// Token estimates per function (realistic averages based on actual prompt sizes):
// price_batch : system + product list prompt ~700 in, structured JSON ~250 out
// shelf_life : product + context + rules ~650 in, days + explanation ~120 out
// category : product name only ~280 in, single word ~40 out
// food_facts : full stats request ~750 in, rich JSON ~500 out
// shopping_name: simple normalization ~250 in, name string ~40 out
// Note: uncacheable calls (recipe, chat, ocr, identify, recipe_ingredient, etc.)
// will be tracked going forward via ai_usage.json
$monthStart = mktime(0, 0, 0, (int)date('m'), 1, (int)date('Y'));
$yearStart = mktime(0, 0, 0, 1, 1, (int)date('Y'));
$retroMonthCalls = 0; $retroYearCalls = 0;
$retroMonthIn = 0; $retroYearIn = 0;
$retroMonthOut = 0; $retroYearOut = 0;
$estMonthIn = 0; $estMonthOut = 0; $estMonthCalls = 0;
$estYearIn = 0; $estYearOut = 0; $estYearCalls = 0;
// Price cache
$priceCache = file_exists(PRICE_CACHE_PATH)
? (json_decode(file_get_contents(PRICE_CACHE_PATH), true) ?: []) : [];
foreach ($priceCache as $v) {
if (!is_array($v) || !isset($v['cached_at'])) continue;
if ($v['cached_at'] >= $monthStart) { $retroMonthCalls++; $retroMonthIn += 350; $retroMonthOut += 125; }
if ($v['cached_at'] >= $yearStart) { $retroYearCalls++; $retroYearIn += 350; $retroYearOut += 125; }
if ($v['cached_at'] >= $monthStart) { $estMonthCalls++; $estMonthIn += 700; $estMonthOut += 250; }
if ($v['cached_at'] >= $yearStart) { $estYearCalls++; $estYearIn += 700; $estYearOut += 250; }
}
// Shelf-life AI cache (source='ai' only)
// Shelf-life AI cache (source='ai' only — rule-based entries are free)
$shelfCache = file_exists(SHELF_CACHE_PATH)
? (json_decode(file_get_contents(SHELF_CACHE_PATH), true) ?: []) : [];
foreach ($shelfCache as $v) {
if (!is_array($v) || ($v['source'] ?? '') !== 'ai') continue;
if (($v['ts'] ?? 0) >= $monthStart) { $retroMonthCalls++; $retroMonthIn += 500; $retroMonthOut += 80; }
if (($v['ts'] ?? 0) >= $yearStart) { $retroYearCalls++; $retroYearIn += 500; $retroYearOut += 80; }
if (($v['ts'] ?? 0) >= $monthStart) { $estMonthCalls++; $estMonthIn += 650; $estMonthOut += 120; }
if (($v['ts'] ?? 0) >= $yearStart) { $estYearCalls++; $estYearIn += 650; $estYearOut += 120; }
}
// Category AI cache (no timestamps — count as year only)
// Category AI cache (no timestamps — count all as year)
$catCache = file_exists(CATEGORY_CACHE_PATH)
? (json_decode(file_get_contents(CATEGORY_CACHE_PATH), true) ?: []) : [];
$catTotal = count($catCache);
$retroYearCalls += $catTotal; $retroYearIn += $catTotal * 200; $retroYearOut += $catTotal * 30;
$estYearCalls += $catTotal; $estYearIn += $catTotal * 280; $estYearOut += $catTotal * 40;
// Shopping name cache (no timestamps — count all as year)
$nameCache = file_exists(SHOPPING_NAME_CACHE_PATH)
? (json_decode(file_get_contents(SHOPPING_NAME_CACHE_PATH), true) ?: []) : [];
$nameTotal = count($nameCache);
$estYearCalls += $nameTotal; $estYearIn += $nameTotal * 250; $estYearOut += $nameTotal * 40;
$retroMonthCostUsd = round(($retroMonthIn / 1_000_000) * GEMINI_COST_25F_IN
+ ($retroMonthOut / 1_000_000) * GEMINI_COST_25F_OUT, 6);
$retroYearCostUsd = round(($retroYearIn / 1_000_000) * GEMINI_COST_25F_IN
+ ($retroYearOut / 1_000_000) * GEMINI_COST_25F_OUT, 6);
// Only expose retro if there is actual tracked data gap (calls tracked < retro estimate)
$hasRetro = ($retroMonthCalls > $cur['calls']);
// Merge: tracked data is authoritative; estimate fills the gap before tracking started
$monthIn = max((int)$cur['input_tokens'], $estMonthIn);
$monthOut = max((int)$cur['output_tokens'], $estMonthOut);
$monthCalls = max((int)$cur['calls'], $estMonthCalls);
$yearIn = max((int)$yearBucket['input_tokens'], $estYearIn);
$yearOut = max((int)$yearBucket['output_tokens'], $estYearOut);
$yearCalls = max((int)$yearBucket['calls'], $estYearCalls);
// ── DB stats ──────────────────────────────────────────────────────────────
$dbStats = [];
@@ -257,65 +255,61 @@ if (($_GET['action'] ?? '') === 'gemini_usage') {
}
// ── Backup info ───────────────────────────────────────────────────────────
$backupDir = dirname(__DIR__) . '/data/backups';
$backupDir = dirname(__DIR__) . '/data/backups';
$backupFiles = is_dir($backupDir) ? (glob($backupDir . '/*.db') ?: []) : [];
rsort($backupFiles);
$lastBackupTs = $backupFiles ? (int)filemtime($backupFiles[0]) : 0;
$lastBackupBytes = $backupFiles ? (int)filesize($backupFiles[0]) : 0;
// ── Bring! token expiry ───────────────────────────────────────────────────
$bringToken = file_exists(BRING_TOKEN_PATH)
? (json_decode(file_get_contents(BRING_TOKEN_PATH), true) ?: []) : []; $bringExpiresTs = (int)($bringToken['expires'] ?? 0);
$bringToken = file_exists(BRING_TOKEN_PATH)
? (json_decode(file_get_contents(BRING_TOKEN_PATH), true) ?: []) : [];
$bringExpiresTs = (int)($bringToken['expires'] ?? 0);
echo json_encode([
'month' => $month,
'year' => $year,
// Tracked (ai_usage.json — since tracking start)
'tracked' => [
'calls' => (int)$cur['calls'],
'input_tokens' => (int)$cur['input_tokens'],
'output_tokens' => (int)$cur['output_tokens'],
'cost_usd' => $calcCost($cur),
'by_action' => $cur['by_action'] ?? [],
'by_model' => $cur['by_model'] ?? [],
// Current month (tracked + estimated from cache)
'month_stats' => [
'calls' => $monthCalls,
'input_tokens' => $monthIn,
'output_tokens'=> $monthOut,
'cost_usd' => $calcCost($monthIn, $monthOut),
'by_action' => $cur['by_action'] ?? [],
'by_model' => $cur['by_model'] ?? [],
],
// Yearly tracked totals
'year_tracked' => [
'calls' => (int)$yearBucket['calls'],
'input_tokens' => (int)$yearBucket['input_tokens'],
'output_tokens' => (int)$yearBucket['output_tokens'],
'cost_usd' => $calcCost($yearBucket),
// Current year (tracked + estimated from cache)
'year_stats' => [
'calls' => $yearCalls,
'input_tokens' => $yearIn,
'output_tokens'=> $yearOut,
'cost_usd' => $calcCost($yearIn, $yearOut),
],
// Retroactive estimate from cache files (before tracking started)
'retro' => $hasRetro ? [
'calls_month' => $retroMonthCalls,
'calls_year' => $retroYearCalls,
'tok_in_month' => $retroMonthIn,
'tok_out_month'=> $retroMonthOut,
'tok_in_year' => $retroYearIn,
'tok_out_year' => $retroYearOut,
'cost_month' => $retroMonthCostUsd,
'cost_year' => $retroYearCostUsd,
] : null,
// DB activity
'db' => array_merge(
array_map('intval', $dbStats),
['bytes' => file_exists(DB_PATH) ? (int)filesize(DB_PATH) : 0]
),
// Cache sizes
// Cache item counts
'caches' => [
'price' => count($priceCache),
'shelf' => count($shelfCache),
'category' => $catTotal,
'foodfacts' => count(file_exists(FOODFACTS_CACHE_PATH)
'price' => count($priceCache),
'shelf' => count($shelfCache),
'category' => $catTotal,
'names' => $nameTotal,
'foodfacts'=> count(file_exists(FOODFACTS_CACHE_PATH)
? (json_decode(file_get_contents(FOODFACTS_CACHE_PATH), true) ?: []) : []),
],
// Current Gemini pricing (from .env / defaults)
'pricing' => [
'2.5-flash' => ['in' => GEMINI_COST_25F_IN, 'out' => GEMINI_COST_25F_OUT],
'2.0-flash' => ['in' => GEMINI_COST_20F_IN, 'out' => GEMINI_COST_20F_OUT],
],
// System
'log_bytes' => $logBytes,
'log_level' => EverLog::levelName(),
@@ -324,18 +318,43 @@ if (($_GET['action'] ?? '') === 'gemini_usage') {
'last_backup_bytes' => $lastBackupBytes,
'bring_expires_ts' => $bringExpiresTs,
// History (last 12 months)
// History (last 13 months for trend)
'history' => array_map(fn($k, $v) => [
'month' => $k,
'input_tokens' => (int)($v['input_tokens'] ?? 0),
'output_tokens' => (int)($v['output_tokens'] ?? 0),
'calls' => (int)($v['calls'] ?? 0),
'cost_usd' => $calcCost($v),
'month' => $k,
'input_tokens' => (int)($v['input_tokens'] ?? 0),
'output_tokens'=> (int)($v['output_tokens'] ?? 0),
'calls' => (int)($v['calls'] ?? 0),
'cost_usd' => $calcCost((int)($v['input_tokens'] ?? 0), (int)($v['output_tokens'] ?? 0)),
], array_keys($aiData), array_values($aiData)),
], JSON_UNESCAPED_UNICODE);
exit;
}
// ── Health check — startup diagnostic (no rate-limit, no auth required) ──────
// ── Tracked usage (ai_usage.json) ────────────────────────────────────────
$aiData = file_exists(AI_USAGE_PATH) ? (json_decode(file_get_contents(AI_USAGE_PATH), true) ?: []) : [];
$month = date('Y-m');
$year = date('Y');
$cur = $aiData[$month] ?? ['input_tokens' => 0, 'output_tokens' => 0, 'calls' => 0, 'by_action' => [], 'by_model' => []];
// Yearly totals (sum all months of current year)
$yearBucket = ['input_tokens' => 0, 'output_tokens' => 0, 'calls' => 0, 'by_model' => []];
foreach ($aiData as $k => $v) {
if (!str_starts_with($k, $year)) continue;
$yearBucket['input_tokens'] += (int)($v['input_tokens'] ?? 0);
$yearBucket['output_tokens'] += (int)($v['output_tokens'] ?? 0);
$yearBucket['calls'] += (int)($v['calls'] ?? 0);
foreach (($v['by_model'] ?? []) as $mdl => $mu) {
if (!isset($yearBucket['by_model'][$mdl])) {
$yearBucket['by_model'][$mdl] = ['in' => 0, 'out' => 0, 'calls' => 0];
}
$yearBucket['by_model'][$mdl]['in'] += $mu['in'] ?? 0;
$yearBucket['by_model'][$mdl]['out'] += $mu['out'] ?? 0;
$yearBucket['by_model'][$mdl]['calls'] += $mu['calls'] ?? 0;
}
}
// ── Health check — startup diagnostic (no rate-limit, no auth required) ──────
if (($_GET['action'] ?? '') === 'health_check') {
$checks = [];
+63 -72
View File
@@ -2233,16 +2233,27 @@ async function _renderInfoTab() {
const monthLabel = new Intl.DateTimeFormat(locale, {month:'long', year:'numeric'})
.format(new Date(parseInt(yr), parseInt(mo)-1, 1));
// Cost → display currency
// Cost → user currency
const toCurr = (usd) => {
if (!usd) return '—';
const c = s.price_currency;
const v = c === 'EUR' ? usd * 0.92 : c === 'GBP' ? usd * 0.79 : usd;
const sym = c === 'EUR' ? '€' : c === 'GBP' ? '£' : '$';
return sym + v.toFixed(4);
const c = s.price_currency || 'EUR';
let v = usd, sym = '$';
if (c === 'EUR') { v = usd * 0.92; sym = ''; }
else if (c === 'GBP') { v = usd * 0.79; sym = '£'; }
else if (c === 'CHF') { v = usd * 0.90; sym = 'CHF '; }
else if (c === 'CAD') { v = usd * 1.36; sym = 'CA$'; }
else if (c === 'AUD') { v = usd * 1.54; sym = 'A$'; }
else if (c === 'BRL') { v = usd * 5.20; sym = 'R$'; }
else if (c === 'JPY') { v = usd * 155; sym = '¥'; }
else if (c === 'SEK') { v = usd * 10.4; sym = 'kr'; }
else if (c === 'NOK') { v = usd * 10.6; sym = 'kr'; }
else if (c === 'DKK') { v = usd * 6.85; sym = 'kr'; }
else if (c === 'PLN') { v = usd * 3.98; sym = 'zł'; }
const decimals = (c === 'JPY') ? 1 : 4;
return sym + v.toFixed(decimals);
};
const fmtTok = n => n >= 1_000_000 ? (n/1_000_000).toFixed(2)+'M'
: n >= 1_000 ? Math.round(n/1_000)+'K' : String(n||0);
const fmtTok = n => n >= 1_000_000 ? (n/1_000_000).toFixed(2)+'M'
: n >= 1_000 ? Math.round(n/1_000)+'K' : String(n||0);
const fmtBytes = b => b > 1048576 ? (b/1048576).toFixed(1)+' MB'
: b > 1024 ? Math.round(b/1024)+' KB' : (b||0)+' B';
const fmtDate = ts => ts ? new Intl.DateTimeFormat(locale, {day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit'}).format(new Date(ts*1000)) : '—';
@@ -2251,71 +2262,53 @@ async function _renderInfoTab() {
<div style="font-size:1.1rem;font-weight:700;color:${color||'var(--text-primary,#1e293b)'}">${val}</div>
<div style="font-size:0.7rem;color:var(--text-secondary,#64748b);margin-top:2px">${label}</div>
</div>`;
const sectionHeader = (label) =>
`<div style="font-size:0.78rem;font-weight:600;color:var(--text-secondary);margin-bottom:8px;text-transform:uppercase;letter-spacing:.04em">${label}</div>`;
// ── AI Usage card ────────────────────────────────────────────────────
if (aiEl) {
const tr = d.tracked || {};
const retro = d.retro;
const yr_t = d.year_tracked || {};
const ms = d.month_stats || {};
const ys = d.year_stats || {};
// Update card subtitle dynamically
const hintEl = aiEl.closest('.settings-card')?.querySelector('.info-ai-subtitle');
if (hintEl) hintEl.textContent = t('settings.info.ai_overview').replace('{month}', monthLabel);
if (hintEl) hintEl.textContent = t('settings.info.ai_overview');
// Tracked section
let trackedHtml = '';
if (tr.calls > 0) {
const actionRows = Object.entries(tr.by_action || {})
.sort((a,b) => b[1]-a[1]).slice(0, 8)
.map(([k,v]) => `<tr><td style="padding:3px 12px 3px 0;color:var(--text-secondary);font-size:0.82rem">${k}</td><td style="font-variant-numeric:tabular-nums;font-size:0.82rem"><strong>${v}</strong> ${t('settings.info.calls_unit')}</td></tr>`).join('');
const modelRows = Object.entries(tr.by_model || {})
.map(([m,mv]) => `<tr><td style="padding:3px 12px 3px 0;color:var(--text-secondary);font-size:0.82rem">${m}</td><td style="font-variant-numeric:tabular-nums;font-size:0.82rem"><strong>${fmtTok((mv.in||0)+(mv.out||0))}</strong></td></tr>`).join('');
trackedHtml = `
<div style="margin-bottom:12px">
<div style="font-size:0.78rem;font-weight:600;color:var(--text-secondary);margin-bottom:8px;text-transform:uppercase;letter-spacing:.04em">${t('settings.info.tracked_section')}</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
${pill(tr.calls, t('settings.info.ai_calls'))}
${pill(fmtTok((tr.input_tokens||0)+(tr.output_tokens||0)), t('settings.info.total_tokens'))}
${pill(toCurr(tr.cost_usd), t('settings.info.est_cost'), '#15803d')}
</div>
${actionRows ? `<details style="margin-top:8px"><summary style="font-size:0.82rem;cursor:pointer;color:var(--text-secondary)">${t('settings.info.by_action')}</summary><table style="margin-top:6px;border-collapse:collapse">${actionRows}</table></details>` : ''}
${modelRows ? `<details style="margin-top:4px"><summary style="font-size:0.82rem;cursor:pointer;color:var(--text-secondary)">${t('settings.info.by_model')}</summary><table style="margin-top:6px;border-collapse:collapse">${modelRows}</table></details>` : ''}
</div>`;
}
const msIn = ms.input_tokens || 0;
const msOut = ms.output_tokens || 0;
const ysIn = ys.input_tokens || 0;
const ysOut = ys.output_tokens || 0;
// Retroactive estimate section
let retroHtml = '';
if (retro && retro.calls_month > 0) {
retroHtml = `
<div style="background:var(--bg-secondary);border-radius:10px;padding:12px;margin-bottom:12px;border-left:3px solid #f59e0b">
<div style="font-size:0.78rem;font-weight:600;color:#92400e;margin-bottom:8px;text-transform:uppercase;letter-spacing:.04em">
${t('settings.info.retro_section').replace('{month}', monthLabel)}
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px">
${pill('~'+retro.calls_month, t('settings.info.ai_calls'))}
${pill('~'+fmtTok((retro.tok_in_month||0)+(retro.tok_out_month||0)), t('settings.info.total_tokens'))}
${pill('~'+toCurr(retro.cost_month), t('settings.info.est_cost'), '#92400e')}
</div>
<p style="font-size:0.75rem;color:#92400e;margin:0">${t('settings.info.retro_note')}</p>
</div>`;
}
// Month section
const actionRows = Object.entries(ms.by_action || {})
.sort((a,b) => b[1]-a[1]).slice(0, 8)
.map(([k,v]) => `<tr><td style="padding:3px 12px 3px 0;color:var(--text-secondary);font-size:0.82rem">${k}</td><td style="font-variant-numeric:tabular-nums;font-size:0.82rem"><strong>${v}</strong> ${t('settings.info.calls_unit')}</td></tr>`).join('');
const modelRows = Object.entries(ms.by_model || {})
.map(([m,mv]) => `<tr><td style="padding:3px 12px 3px 0;color:var(--text-secondary);font-size:0.82rem">${m}</td><td style="font-variant-numeric:tabular-nums;font-size:0.82rem"><strong>${fmtTok((mv.in||0)+(mv.out||0))}</strong></td></tr>`).join('');
// Yearly section
const yearTotCalls = (yr_t.calls||0) + (retro?.calls_year||0);
const yearTotTokIn = (yr_t.input_tokens||0) + (retro?.tok_in_year||0);
const yearTotTokOut = (yr_t.output_tokens||0) + (retro?.tok_out_year||0);
const yearTotCost = (yr_t.cost_usd||0) + (retro?.cost_year||0);
const yearHtml = `
<div style="background:var(--bg-secondary);border-radius:10px;padding:12px;margin-bottom:12px">
<div style="font-size:0.78rem;font-weight:600;color:var(--text-secondary);margin-bottom:8px;text-transform:uppercase;letter-spacing:.04em">${t('settings.info.year_section').replace('{year}', d.year)}</div>
const monthHtml = `
<div style="background:var(--bg-secondary);border-radius:10px;padding:12px;margin-bottom:10px">
${sectionHeader(monthLabel)}
<div style="display:flex;gap:8px;flex-wrap:wrap">
${pill('~'+yearTotCalls, t('settings.info.ai_calls'))}
${pill('~'+fmtTok(yearTotTokIn+yearTotTokOut), t('settings.info.total_tokens'))}
${pill('~'+toCurr(yearTotCost), t('settings.info.est_cost'), '#15803d')}
${pill(ms.calls || 0, t('settings.info.ai_calls'))}
${pill('~'+fmtTok(msIn+msOut), t('settings.info.total_tokens'))}
${pill('~'+toCurr(ms.cost_usd), t('settings.info.est_cost'), '#15803d')}
</div>
${actionRows ? `<details style="margin-top:8px"><summary style="font-size:0.82rem;cursor:pointer;color:var(--text-secondary)">${t('settings.info.by_action')}</summary><table style="margin-top:6px;border-collapse:collapse">${actionRows}</table></details>` : ''}
${modelRows ? `<details style="margin-top:4px"><summary style="font-size:0.82rem;cursor:pointer;color:var(--text-secondary)">${t('settings.info.by_model')}</summary><table style="margin-top:6px;border-collapse:collapse">${modelRows}</table></details>` : ''}
</div>`;
// Year section
const yearHtml = `
<div style="background:var(--bg-secondary);border-radius:10px;padding:12px;margin-bottom:10px">
${sectionHeader(t('settings.info.year_label').replace('{year}', d.year))}
<div style="display:flex;gap:8px;flex-wrap:wrap">
${pill('~'+(ys.calls || 0), t('settings.info.ai_calls'))}
${pill('~'+fmtTok(ysIn+ysOut), t('settings.info.total_tokens'))}
${pill('~'+toCurr(ys.cost_usd), t('settings.info.est_cost'), '#15803d')}
</div>
</div>`;
aiEl.innerHTML = trackedHtml + retroHtml + yearHtml
aiEl.innerHTML = monthHtml + yearHtml
+ `<p class="settings-hint" style="margin-top:4px">${t('settings.info.pricing_note')}</p>`;
}
@@ -2323,14 +2316,12 @@ async function _renderInfoTab() {
const invEl = document.getElementById('info-inv-content');
if (invEl && d.db) {
const db = d.db;
const expColor = db.expired > 0 ? '#dc2626' : '';
const soonColor = db.expiring_soon > 0 ? '#d97706' : '';
invEl.innerHTML = `
<div style="display:flex;gap:8px;flex-wrap:wrap">
${pill(db.inventory_active, t('settings.info.inv_active'))}
${pill(db.products_total, t('settings.info.inv_products'))}
${pill(db.expiring_soon, t('settings.info.inv_expiring'), soonColor)}
${pill(db.expired, t('settings.info.inv_expired'), expColor)}
${pill(db.expiring_soon, t('settings.info.inv_expiring'), db.expiring_soon > 0 ? '#d97706' : '')}
${pill(db.expired, t('settings.info.inv_expired'), db.expired > 0 ? '#dc2626' : '')}
${pill(db.finished, t('settings.info.inv_finished'))}
</div>`;
}
@@ -2341,11 +2332,11 @@ async function _renderInfoTab() {
const db = d.db;
actEl.innerHTML = `
<div style="display:flex;gap:8px;flex-wrap:wrap">
${pill(db.tx_month, t('settings.info.act_tx_month'))}
${pill(db.restock_month, t('settings.info.act_restock'))}
${pill(db.use_month, t('settings.info.act_use'))}
${pill(db.products_month, t('settings.info.act_new_products'))}
${pill(db.tx_year, t('settings.info.act_tx_year'))}
${pill(db.tx_month, t('settings.info.act_tx_month'))}
${pill(db.restock_month, t('settings.info.act_restock'))}
${pill(db.use_month, t('settings.info.act_use'))}
${pill(db.products_month, t('settings.info.act_new_products'))}
${pill(db.tx_year, t('settings.info.act_tx_year'))}
</div>`;
}
@@ -2353,18 +2344,17 @@ async function _renderInfoTab() {
if (sysEl) {
const db = d.db || {};
const nowTs = Math.floor(Date.now()/1000);
const bringDays = d.bring_expires_ts ? Math.round((d.bring_expires_ts - nowTs)/86400) : null;
const bringDays = d.bring_expires_ts ? Math.round((d.bring_expires_ts - nowTs)/86400) : null;
const bringColor = bringDays !== null && bringDays <= 3 ? '#dc2626' : '';
const bringLabel = bringDays === null ? '—'
: bringDays <= 0 ? t('settings.info.bring_expired')
: t('settings.info.bring_days').replace('{n}', bringDays);
const lvlColors = {DEBUG:'#1e40af//#dbeafe', INFO:'#15803d//#dcfce7', WARN:'#854d0e//#fef9c3', ERROR:'#991b1b//#fee2e2'};
const [lvlFg, lvlBg] = (lvlColors[d.log_level] || '#64748b//#f1f5f9').split('//');
sysEl.innerHTML = `
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px">
${pill(fmtBytes(db.bytes), t('settings.info.db_size'))}
${pill(fmtBytes(db.bytes), t('settings.info.db_size'))}
${pill(fmtBytes(d.log_bytes), t('settings.info.log_size'))}
${pill(`<span style="background:${lvlBg};color:${lvlFg};padding:2px 6px;border-radius:5px;font-size:0.78rem">${d.log_level||'INFO'}</span>`, t('settings.info.log_level'))}
</div>
@@ -2391,6 +2381,7 @@ async function _renderInfoTab() {
}
}
/**
* Populate the About section with the current app version from the server.
*/
+24 -17
View File
@@ -904,23 +904,6 @@
<option value="Japan">🇯🇵 Giappone</option>
</select>
</div>
<div class="form-group">
<label data-i18n="settings.price.currency_label">💱 Valuta</label>
<select id="setting-price-currency" class="form-input">
<option value="EUR">€ Euro (EUR)</option>
<option value="USD">$ Dollaro USA (USD)</option>
<option value="GBP">£ Sterlina (GBP)</option>
<option value="CHF">CHF Franco Svizzero</option>
<option value="CAD">CA$ Dollaro Canadese</option>
<option value="AUD">A$ Dollaro Australiano</option>
<option value="BRL">R$ Real Brasiliano</option>
<option value="JPY">¥ Yen Giapponese</option>
<option value="SEK">kr Corona Svedese</option>
<option value="NOK">kr Corona Norvegese</option>
<option value="DKK">kr Corona Danese</option>
<option value="PLN">zł Zloty Polacco</option>
</select>
</div>
<div class="form-group">
<label data-i18n="settings.price.update_label">🔄 Aggiorna prezzi ogni</label>
<div class="qty-control">
@@ -1339,6 +1322,30 @@
<!-- Info Tab -->
<div class="settings-panel" id="tab-info">
<!-- Currency card -->
<div class="settings-card">
<h4 data-i18n="settings.info.currency_title">💱 Valuta</h4>
<p class="settings-hint" data-i18n="settings.info.currency_hint">La valuta usata per tutti i costi e i prezzi nell'app.</p>
<div class="form-group" style="margin-top:8px">
<select id="setting-price-currency" class="form-input">
<option value="EUR">€ Euro (EUR)</option>
<option value="USD">$ Dollaro USA (USD)</option>
<option value="GBP">£ Sterlina (GBP)</option>
<option value="CHF">CHF Franco Svizzero</option>
<option value="CAD">CA$ Dollaro Canadese</option>
<option value="AUD">A$ Dollaro Australiano</option>
<option value="BRL">R$ Real Brasiliano</option>
<option value="JPY">¥ Yen Giapponese</option>
<option value="SEK">kr Corona Svedese</option>
<option value="NOK">kr Corona Norvegese</option>
<option value="DKK">kr Corona Danese</option>
<option value="PLN">zł Zloty Polacco</option>
</select>
</div>
<div class="form-group" style="margin-top:10px">
<button class="btn btn-primary" onclick="saveSettings()" data-i18n="btn.save">Salva</button>
</div>
</div>
<!-- Gemini AI Usage card -->
<div class="settings-card">
<h4 data-i18n="settings.info.ai_title">Gemini AI — Token Usage</h4>
+4 -5
View File
@@ -780,10 +780,6 @@
"log_size": "Protokolle",
"log_level": "Log-Level",
"ai_overview": "KI-Nutzungsübersicht, Inventar und Systemstatus",
"tracked_section": "Erfasst (seit Monitoring-Start)",
"retro_section": "Rückwirkende Schätzung — {month}",
"retro_note": "Schätzung basiert auf KI-Cache-Dateien, die vor der Monitoring-Aktivierung generiert wurden.",
"year_section": "Jahr {year} — Gesamtschätzung",
"calls_unit": "Aufrufe",
"inv_title": "Inventar",
"inv_active": "Aktiv",
@@ -801,7 +797,10 @@
"cache_entries": "Produkte",
"last_backup": "Letztes Backup",
"bring_days": "Token läuft in {n} Tagen ab",
"bring_expired": "Token abgelaufen"
"bring_expired": "Token abgelaufen",
"year_label": "Jahr {year}",
"currency_title": "Währung",
"currency_hint": "Die Währung, die für alle Kosten und Preise in der App verwendet wird."
}
},
"expiry": {
+4 -5
View File
@@ -780,10 +780,6 @@
"log_size": "Logs",
"log_level": "Log level",
"ai_overview": "AI usage overview, inventory and system status",
"tracked_section": "Tracked (since monitoring start)",
"retro_section": "Retroactive estimate — {month}",
"retro_note": "Estimate based on AI cache files generated before monitoring was activated.",
"year_section": "Year {year} — total estimate",
"calls_unit": "calls",
"inv_title": "Inventory",
"inv_active": "Active",
@@ -801,7 +797,10 @@
"cache_entries": "products",
"last_backup": "Last backup",
"bring_days": "token expires in {n} days",
"bring_expired": "token expired"
"bring_expired": "token expired",
"year_label": "Year {year}",
"currency_title": "Currency",
"currency_hint": "The currency used for all costs and prices in the app."
}
},
"expiry": {
+4 -5
View File
@@ -780,10 +780,6 @@
"log_size": "Log",
"log_level": "Livello log",
"ai_overview": "Prospetto utilizzo AI, inventario e stato del sistema",
"tracked_section": "Tracciato (da inizio monitoraggio)",
"retro_section": "Stima retroattiva — {month}",
"retro_note": "Stima basata sulle voci presenti nei file di cache AI, generati prima dell'attivazione del monitoraggio.",
"year_section": "Anno {year} — totale stimato",
"calls_unit": "call",
"inv_title": "Inventario",
"inv_active": "Attivi",
@@ -801,7 +797,10 @@
"cache_entries": "prodotti",
"last_backup": "Ultimo backup",
"bring_days": "token scade tra {n} giorni",
"bring_expired": "token scaduto"
"bring_expired": "token scaduto",
"year_label": "Anno {year}",
"currency_title": "Valuta",
"currency_hint": "La valuta usata per tutti i costi e i prezzi nell'app."
}
},
"expiry": {