chore: auto-merge develop → main
Triggered by: b985247 feat: monthly stats panel in rotating insight banner (closes #100)
This commit is contained in:
@@ -801,6 +801,10 @@ try {
|
|||||||
getStats($db);
|
getStats($db);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'monthly_stats':
|
||||||
|
getMonthlyStats($db);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'consumption_predictions':
|
case 'consumption_predictions':
|
||||||
getConsumptionPredictions($db);
|
getConsumptionPredictions($db);
|
||||||
break;
|
break;
|
||||||
@@ -3592,6 +3596,80 @@ function getStats(PDO $db): void {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== MONTHLY STATS =====
|
||||||
|
function getMonthlyStats(PDO $db): void {
|
||||||
|
EverLog::debug('getMonthlyStats');
|
||||||
|
|
||||||
|
$thisMonthStart = date('Y-m-01');
|
||||||
|
$lastMonthStart = date('Y-m-01', strtotime('first day of last month'));
|
||||||
|
$lastMonthEnd = date('Y-m-01'); // exclusive upper bound for prev month
|
||||||
|
|
||||||
|
// Totals: consumed + added + wasted this month vs previous calendar month
|
||||||
|
$totals = $db->query("
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN created_at >= '{$thisMonthStart}'
|
||||||
|
AND type IN ('out','waste') AND undone=0 THEN 1 ELSE 0 END) AS this_out,
|
||||||
|
SUM(CASE WHEN created_at >= '{$lastMonthStart}' AND created_at < '{$lastMonthEnd}'
|
||||||
|
AND type IN ('out','waste') AND undone=0 THEN 1 ELSE 0 END) AS prev_out,
|
||||||
|
SUM(CASE WHEN created_at >= '{$thisMonthStart}'
|
||||||
|
AND type = 'in' AND undone=0 THEN 1 ELSE 0 END) AS this_in,
|
||||||
|
SUM(CASE WHEN created_at >= '{$thisMonthStart}'
|
||||||
|
AND type = 'waste' AND undone=0 THEN 1 ELSE 0 END) AS this_wasted
|
||||||
|
FROM transactions
|
||||||
|
WHERE created_at >= '{$lastMonthStart}'
|
||||||
|
")->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$thisOut = (int)($totals['this_out'] ?? 0);
|
||||||
|
$prevOut = (int)($totals['prev_out'] ?? 0);
|
||||||
|
$thisIn = (int)($totals['this_in'] ?? 0);
|
||||||
|
$thisWaste = (int)($totals['this_wasted'] ?? 0);
|
||||||
|
|
||||||
|
// Top categories consumed this month
|
||||||
|
$catRows = $db->query("
|
||||||
|
SELECT COALESCE(NULLIF(TRIM(p.category), ''), 'altro') AS cat, COUNT(*) AS cnt
|
||||||
|
FROM transactions t
|
||||||
|
JOIN products p ON t.product_id = p.id
|
||||||
|
WHERE t.type IN ('out','waste') AND t.undone = 0
|
||||||
|
AND t.created_at >= '{$thisMonthStart}'
|
||||||
|
GROUP BY cat
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
LIMIT 5
|
||||||
|
")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$totalCatEvents = array_sum(array_column($catRows, 'cnt')) ?: 1;
|
||||||
|
$topCats = array_map(fn($r) => [
|
||||||
|
'cat' => $r['cat'],
|
||||||
|
'count' => (int)$r['cnt'],
|
||||||
|
'pct' => (int)round((int)$r['cnt'] / $totalCatEvents * 100),
|
||||||
|
], $catRows);
|
||||||
|
|
||||||
|
// Top consumed products this month
|
||||||
|
$topProds = $db->query("
|
||||||
|
SELECT p.name, COUNT(*) AS cnt
|
||||||
|
FROM transactions t
|
||||||
|
JOIN products p ON t.product_id = p.id
|
||||||
|
WHERE t.type IN ('out','waste') AND t.undone = 0
|
||||||
|
AND t.created_at >= '{$thisMonthStart}'
|
||||||
|
GROUP BY t.product_id
|
||||||
|
ORDER BY cnt DESC
|
||||||
|
LIMIT 3
|
||||||
|
")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => true,
|
||||||
|
'month' => date('Y-m'),
|
||||||
|
'items_consumed' => $thisOut,
|
||||||
|
'items_consumed_prev' => $prevOut,
|
||||||
|
'items_added' => $thisIn,
|
||||||
|
'items_wasted' => $thisWaste,
|
||||||
|
'top_categories' => $topCats,
|
||||||
|
'top_products' => array_map(fn($r) => [
|
||||||
|
'name' => $r['name'],
|
||||||
|
'count' => (int)$r['cnt'],
|
||||||
|
], $topProds),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== RECENT & POPULAR PRODUCTS =====
|
// ===== RECENT & POPULAR PRODUCTS =====
|
||||||
function recentPopularProducts(PDO $db): void {
|
function recentPopularProducts(PDO $db): void {
|
||||||
EverLog::debug('recentPopularProducts');
|
EverLog::debug('recentPopularProducts');
|
||||||
|
|||||||
@@ -6924,6 +6924,82 @@ body.cooking-mode-active .app-header {
|
|||||||
}
|
}
|
||||||
.nutr-score-val { flex: 0 0 32px; text-align: right; font-weight: 600; }
|
.nutr-score-val { flex: 0 0 32px; text-align: right; font-weight: 600; }
|
||||||
|
|
||||||
|
/* ===== MONTHLY STATS PANEL ===== */
|
||||||
|
.ms-main-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
}
|
||||||
|
.ms-main-num {
|
||||||
|
font-size: 2.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #6366f1;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.ms-main-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.ms-main-label {
|
||||||
|
font-size: .85rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.ms-trend {
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ms-cats-section {
|
||||||
|
margin: 6px 0 4px;
|
||||||
|
}
|
||||||
|
.ms-cats-title {
|
||||||
|
font-size: .68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
.ms-cat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.ms-cat-name {
|
||||||
|
font-size: .74rem;
|
||||||
|
color: #cbd5e1;
|
||||||
|
min-width: 78px;
|
||||||
|
max-width: 78px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.ms-cat-bar-wrap {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background: #1e293b;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ms-cat-bar {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
.ms-cat-cnt {
|
||||||
|
font-size: .7rem;
|
||||||
|
color: #64748b;
|
||||||
|
min-width: 22px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.ms-badges-row {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== SETUP WIZARD ===== */
|
/* ===== SETUP WIZARD ===== */
|
||||||
.setup-wizard-content {
|
.setup-wizard-content {
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
|
|||||||
+131
-26
@@ -4355,47 +4355,147 @@ function _nutrScoreBar(label, val, color) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ===== MONTHLY STATS SECTION =====
|
||||||
* Start the waste ↔ nutrition alternation on the dashboard.
|
// Third panel in the insight rotation (waste → nutrition → monthly → waste …)
|
||||||
* One is shown, the other hidden; they swap every hour (+ random phase offset).
|
|
||||||
*/
|
|
||||||
let _insightPhase = null; // 'waste' | 'nutrition'
|
|
||||||
|
|
||||||
function _startInsightAlternation(inventory) {
|
function _renderMonthlyStatsSection(data) {
|
||||||
|
const section = document.getElementById('monthly-stats-section');
|
||||||
|
if (!section) return;
|
||||||
|
if (!data || !data.success || data.items_consumed === 0) {
|
||||||
|
section.innerHTML = '';
|
||||||
|
section.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Month label from 'YYYY-MM' → formatted locale string
|
||||||
|
const [yr, mo] = data.month.split('-').map(Number);
|
||||||
|
const localeMap = { de: 'de-DE', fr: 'fr-FR', es: 'es-ES', en: 'en-GB', it: 'it-IT' };
|
||||||
|
const locale = localeMap[_currentLang] || 'it-IT';
|
||||||
|
const monthLabel = new Date(yr, mo - 1, 1).toLocaleDateString(locale, { month: 'long', year: 'numeric' });
|
||||||
|
const prevLabel = new Date(yr, mo - 2, 1).toLocaleDateString(locale, { month: 'long' });
|
||||||
|
|
||||||
|
// Trend vs previous month
|
||||||
|
let trendHTML = '';
|
||||||
|
const prev = data.items_consumed_prev;
|
||||||
|
const curr = data.items_consumed;
|
||||||
|
if (prev > 0) {
|
||||||
|
const diff = Math.round((curr - prev) / prev * 100);
|
||||||
|
if (diff < -2) {
|
||||||
|
trendHTML = `<span class="aw-arrow-good">↓ ${t('stats_monthly.trend_down').replace('{pct}', Math.abs(diff)).replace('{prev}', prevLabel)}</span>`;
|
||||||
|
} else if (diff > 2) {
|
||||||
|
trendHTML = `<span class="aw-arrow-bad">↑ ${t('stats_monthly.trend_up').replace('{pct}', diff).replace('{prev}', prevLabel)}</span>`;
|
||||||
|
} else {
|
||||||
|
trendHTML = `<span class="aw-arrow-ok">→ ${t('stats_monthly.trend_same')}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top category bars
|
||||||
|
const top = (data.top_categories || []).slice(0, 4);
|
||||||
|
const maxCnt = top.length ? Math.max(...top.map(c => c.count)) : 1;
|
||||||
|
const catBars = top.map(c => {
|
||||||
|
const color = _NUTR_COLORS[c.cat] || '#64748b';
|
||||||
|
const barPct = Math.round(c.count / maxCnt * 100);
|
||||||
|
const label = t('categories.' + c.cat) || c.cat;
|
||||||
|
return `<div class="ms-cat-row">
|
||||||
|
<span class="ms-cat-name">${escapeHtml(label)}</span>
|
||||||
|
<div class="ms-cat-bar-wrap">
|
||||||
|
<div class="ms-cat-bar" style="background:${color}" data-target="${barPct}"></div>
|
||||||
|
</div>
|
||||||
|
<span class="ms-cat-cnt">${c.count}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Badges
|
||||||
|
const badges = [];
|
||||||
|
if (data.items_added > 0)
|
||||||
|
badges.push(`<span class="aw-badge"><span class="aw-badge-icon">📦</span><span class="aw-badge-body"><b>${data.items_added}</b><small>${t('stats_monthly.added')}</small></span></span>`);
|
||||||
|
if (data.items_wasted > 0)
|
||||||
|
badges.push(`<span class="aw-badge aw-badge-wasted"><span class="aw-badge-icon">🗑️</span><span class="aw-badge-body"><b>${data.items_wasted}</b><small>${t('stats_monthly.wasted')}</small></span></span>`);
|
||||||
|
if (data.top_products?.length > 0)
|
||||||
|
badges.push(`<span class="aw-badge aw-badge-better"><span class="aw-badge-icon">⭐</span><span class="aw-badge-body"><b>${escapeHtml(data.top_products[0].name)}</b><small>${t('stats_monthly.top_used')}</small></span></span>`);
|
||||||
|
|
||||||
|
section.innerHTML = `
|
||||||
|
<div class="nutr-card">
|
||||||
|
<div class="aw-header">
|
||||||
|
<div class="aw-title-row">
|
||||||
|
<span class="aw-live-dot aw-live-on"></span>
|
||||||
|
<h3 class="aw-title">${t('stats_monthly.title')}</h3>
|
||||||
|
</div>
|
||||||
|
<span class="aw-grade" style="background:#6366f1;font-size:.75rem;padding:4px 10px">${monthLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ms-main-row">
|
||||||
|
<div class="ms-main-num">${curr}</div>
|
||||||
|
<div class="ms-main-info">
|
||||||
|
<div class="ms-main-label">${t('stats_monthly.consumed')}</div>
|
||||||
|
<div class="ms-trend">${trendHTML}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${top.length > 0 ? `
|
||||||
|
<div class="ms-cats-section">
|
||||||
|
<div class="ms-cats-title">${t('stats_monthly.top_cats')}</div>
|
||||||
|
${catBars}
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
|
${badges.length > 0 ? `<div class="aw-savings-row ms-badges-row">${badges.join('')}</div>` : ''}
|
||||||
|
|
||||||
|
<div class="aw-source">${t('stats_monthly.source')}</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
// Show only if it's the active phase (mirrors _applyInsightPhase logic)
|
||||||
|
section.style.display = (_insightPhase === 'monthly') ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the waste ↔ nutrition ↔ monthly stats alternation on the dashboard.
|
||||||
|
*/
|
||||||
|
let _insightPhase = null; // 'waste' | 'nutrition' | 'monthly'
|
||||||
|
const _INSIGHT_PHASES = ['waste', 'nutrition', 'monthly'];
|
||||||
|
|
||||||
|
function _startInsightAlternation() {
|
||||||
clearInterval(_insightFlipTimer);
|
clearInterval(_insightFlipTimer);
|
||||||
// Pick initial panel based on current minute: even hour → waste, odd → nutrition
|
// Pick initial panel based on current hour, cycling through 3 phases
|
||||||
const isNutritionTurn = Math.floor(Date.now() / 3_600_000) % 2 === 1;
|
const idx = Math.floor(Date.now() / 3_600_000) % _INSIGHT_PHASES.length;
|
||||||
_insightPhase = isNutritionTurn ? 'nutrition' : 'waste';
|
_insightPhase = _INSIGHT_PHASES[idx];
|
||||||
_applyInsightPhase();
|
_applyInsightPhase();
|
||||||
// Flip every hour
|
// Advance to next phase every hour
|
||||||
_insightFlipTimer = setInterval(() => {
|
_insightFlipTimer = setInterval(() => {
|
||||||
_insightPhase = _insightPhase === 'waste' ? 'nutrition' : 'waste';
|
_insightPhase = _INSIGHT_PHASES[(_INSIGHT_PHASES.indexOf(_insightPhase) + 1) % _INSIGHT_PHASES.length];
|
||||||
_applyInsightPhase();
|
_applyInsightPhase();
|
||||||
}, 3_600_000);
|
}, 3_600_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _applyInsightPhase() {
|
function _applyInsightPhase() {
|
||||||
const wasteEl = document.getElementById('waste-chart-section');
|
const wasteEl = document.getElementById('waste-chart-section');
|
||||||
const nutrEl = document.getElementById('nutrition-section');
|
const nutrEl = document.getElementById('nutrition-section');
|
||||||
|
const monthlyEl = document.getElementById('monthly-stats-section');
|
||||||
if (!wasteEl || !nutrEl) return;
|
if (!wasteEl || !nutrEl) return;
|
||||||
const showNutr = _insightPhase === 'nutrition' && nutrEl.innerHTML.trim() !== '';
|
const showWaste = _insightPhase === 'waste' && wasteEl.innerHTML.trim() !== '';
|
||||||
const showWaste = _insightPhase === 'waste' && wasteEl.innerHTML.trim() !== '';
|
const showNutr = _insightPhase === 'nutrition' && nutrEl.innerHTML.trim() !== '';
|
||||||
// Fade-swap
|
const showMonthly = _insightPhase === 'monthly' && !!monthlyEl && monthlyEl.innerHTML.trim() !== '';
|
||||||
[wasteEl, nutrEl].forEach(el => { el.style.opacity = '0'; el.style.transition = 'opacity .6s'; });
|
// Fade-swap all three panels
|
||||||
|
const els = [wasteEl, nutrEl, ...(monthlyEl ? [monthlyEl] : [])];
|
||||||
|
els.forEach(el => { el.style.opacity = '0'; el.style.transition = 'opacity .6s'; });
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
wasteEl.style.display = showWaste ? 'block' : 'none';
|
wasteEl.style.display = showWaste ? 'block' : 'none';
|
||||||
nutrEl.style.display = showNutr ? 'block' : 'none';
|
nutrEl.style.display = showNutr ? 'block' : 'none';
|
||||||
// If neither ready yet, keep waste visible
|
if (monthlyEl) monthlyEl.style.display = showMonthly ? 'block' : 'none';
|
||||||
if (!showWaste && !showNutr) wasteEl.style.display = 'block';
|
// If nothing ready yet, keep waste visible as fallback
|
||||||
|
if (!showWaste && !showNutr && !showMonthly) wasteEl.style.display = 'block';
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
wasteEl.style.opacity = '1';
|
els.forEach(el => { el.style.opacity = '1'; });
|
||||||
nutrEl.style.opacity = '1';
|
|
||||||
// Animate score bars when nutrition becomes visible
|
|
||||||
if (showNutr) {
|
if (showNutr) {
|
||||||
nutrEl.querySelectorAll('.nutr-score-fill').forEach(bar => {
|
nutrEl.querySelectorAll('.nutr-score-fill').forEach(bar => {
|
||||||
bar.style.width = (bar.dataset.target || 0) + '%';
|
bar.style.width = (bar.dataset.target || 0) + '%';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (showMonthly && monthlyEl) {
|
||||||
|
monthlyEl.querySelectorAll('.ms-cat-bar').forEach(bar => {
|
||||||
|
bar.style.transition = 'width 0.6s ease';
|
||||||
|
bar.style.width = (bar.dataset.target || 0) + '%';
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}, 620);
|
}, 620);
|
||||||
}
|
}
|
||||||
@@ -4512,10 +4612,11 @@ async function loadDashboard() {
|
|||||||
// Banner alerts (suspicious quantities + consumption predictions)
|
// Banner alerts (suspicious quantities + consumption predictions)
|
||||||
loadBannerAlerts();
|
loadBannerAlerts();
|
||||||
|
|
||||||
// Anti-waste section + Nutrition section: load in parallel
|
// Anti-waste section + Nutrition section + Monthly stats: load in parallel
|
||||||
const [, invForNutr] = await Promise.all([
|
const [, invForNutr, monthlyData] = await Promise.all([
|
||||||
_awLoadFacts(),
|
_awLoadFacts(),
|
||||||
api('inventory_list').then(d => d.inventory || []).catch(() => []),
|
api('inventory_list').then(d => d.inventory || []).catch(() => []),
|
||||||
|
api('monthly_stats').catch(() => null),
|
||||||
]);
|
]);
|
||||||
_renderAntiWasteSection(
|
_renderAntiWasteSection(
|
||||||
statsData.used_30d || 0, statsData.wasted_30d || 0,
|
statsData.used_30d || 0, statsData.wasted_30d || 0,
|
||||||
@@ -4527,6 +4628,10 @@ async function loadDashboard() {
|
|||||||
|
|
||||||
// Nutrition section — built from the full inventory list
|
// Nutrition section — built from the full inventory list
|
||||||
_renderNutritionSection(invForNutr);
|
_renderNutritionSection(invForNutr);
|
||||||
|
|
||||||
|
// Monthly stats panel
|
||||||
|
_renderMonthlyStatsSection(monthlyData);
|
||||||
|
|
||||||
_startInsightAlternation();
|
_startInsightAlternation();
|
||||||
|
|
||||||
// Opened (partially used products with known package capacity)
|
// Opened (partially used products with known package capacity)
|
||||||
|
|||||||
+2
-1
@@ -169,10 +169,11 @@
|
|||||||
<div id="expired-list"></div>
|
<div id="expired-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Anti-Waste Report Card + Nutrition Analysis (alternating, content rendered by JS) -->
|
<!-- Anti-Waste Report Card + Nutrition Analysis + Monthly Stats (alternating, content rendered by JS) -->
|
||||||
<div id="dashboard-insight-wrap" style="position:relative">
|
<div id="dashboard-insight-wrap" style="position:relative">
|
||||||
<div id="waste-chart-section" style="display:none"></div>
|
<div id="waste-chart-section" style="display:none"></div>
|
||||||
<div id="nutrition-section" style="display:none"></div>
|
<div id="nutrition-section" style="display:none"></div>
|
||||||
|
<div id="monthly-stats-section" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alert for soonest expiring items -->
|
<!-- Alert for soonest expiring items -->
|
||||||
|
|||||||
@@ -1424,5 +1424,17 @@
|
|||||||
"retry": "Erneut versuchen",
|
"retry": "Erneut versuchen",
|
||||||
"syncing_local": "Lokale Daten synchronisieren...",
|
"syncing_local": "Lokale Daten synchronisieren...",
|
||||||
"sync_done": "Lokale Daten aktualisiert"
|
"sync_done": "Lokale Daten aktualisiert"
|
||||||
|
},
|
||||||
|
"stats_monthly": {
|
||||||
|
"title": "Monatsstatistik",
|
||||||
|
"consumed": "Produkte verbraucht",
|
||||||
|
"trend_up": "+{pct}% vs. {prev}",
|
||||||
|
"trend_down": "-{pct}% vs. {prev}",
|
||||||
|
"trend_same": "gleiches Tempo wie letzten Monat",
|
||||||
|
"added": "hinzugefügt",
|
||||||
|
"wasted": "verschwendet",
|
||||||
|
"top_used": "meistbenutzt",
|
||||||
|
"top_cats": "Hauptkategorien",
|
||||||
|
"source": "Transaktionsverlauf · aktueller Monat"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1424,5 +1424,17 @@
|
|||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"syncing_local": "Syncing local data...",
|
"syncing_local": "Syncing local data...",
|
||||||
"sync_done": "Local data synced"
|
"sync_done": "Local data synced"
|
||||||
|
},
|
||||||
|
"stats_monthly": {
|
||||||
|
"title": "Monthly Stats",
|
||||||
|
"consumed": "products used",
|
||||||
|
"trend_up": "+{pct}% vs {prev}",
|
||||||
|
"trend_down": "-{pct}% vs {prev}",
|
||||||
|
"trend_same": "same pace as last month",
|
||||||
|
"added": "added",
|
||||||
|
"wasted": "wasted",
|
||||||
|
"top_used": "top used",
|
||||||
|
"top_cats": "Top categories",
|
||||||
|
"source": "Transaction history · current month"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1367,5 +1367,17 @@
|
|||||||
"retry": "Reintentar",
|
"retry": "Reintentar",
|
||||||
"syncing_local": "Sincronizando datos locales...",
|
"syncing_local": "Sincronizando datos locales...",
|
||||||
"sync_done": "Datos locales sincronizados"
|
"sync_done": "Datos locales sincronizados"
|
||||||
|
},
|
||||||
|
"stats_monthly": {
|
||||||
|
"title": "Estadísticas Mensuales",
|
||||||
|
"consumed": "productos usados",
|
||||||
|
"trend_up": "+{pct}% vs {prev}",
|
||||||
|
"trend_down": "-{pct}% vs {prev}",
|
||||||
|
"trend_same": "mismo ritmo que el mes pasado",
|
||||||
|
"added": "añadidos",
|
||||||
|
"wasted": "desperdiciados",
|
||||||
|
"top_used": "más usado",
|
||||||
|
"top_cats": "Categorías principales",
|
||||||
|
"source": "Historial de transacciones · mes actual"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1367,5 +1367,17 @@
|
|||||||
"retry": "Réessayer",
|
"retry": "Réessayer",
|
||||||
"syncing_local": "Synchronisation des données locales...",
|
"syncing_local": "Synchronisation des données locales...",
|
||||||
"sync_done": "Données locales synchronisées"
|
"sync_done": "Données locales synchronisées"
|
||||||
|
},
|
||||||
|
"stats_monthly": {
|
||||||
|
"title": "Statistiques Mensuelles",
|
||||||
|
"consumed": "produits utilisés",
|
||||||
|
"trend_up": "+{pct}% vs {prev}",
|
||||||
|
"trend_down": "-{pct}% vs {prev}",
|
||||||
|
"trend_same": "même rythme que le mois dernier",
|
||||||
|
"added": "ajoutés",
|
||||||
|
"wasted": "gaspillés",
|
||||||
|
"top_used": "le plus utilisé",
|
||||||
|
"top_cats": "Catégories principales",
|
||||||
|
"source": "Historique des transactions · mois en cours"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1424,5 +1424,17 @@
|
|||||||
"retry": "Riprova",
|
"retry": "Riprova",
|
||||||
"syncing_local": "Sincronizzazione dati locali...",
|
"syncing_local": "Sincronizzazione dati locali...",
|
||||||
"sync_done": "Dati locali aggiornati"
|
"sync_done": "Dati locali aggiornati"
|
||||||
|
},
|
||||||
|
"stats_monthly": {
|
||||||
|
"title": "Statistiche Mensili",
|
||||||
|
"consumed": "prodotti usati",
|
||||||
|
"trend_up": "+{pct}% rispetto a {prev}",
|
||||||
|
"trend_down": "-{pct}% rispetto a {prev}",
|
||||||
|
"trend_same": "stesso ritmo del mese scorso",
|
||||||
|
"added": "aggiunti",
|
||||||
|
"wasted": "sprecati",
|
||||||
|
"top_used": "più usato",
|
||||||
|
"top_cats": "Categorie principali",
|
||||||
|
"source": "Storico transazioni · mese corrente"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user