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 = [];