// dashboard.jsx β BeanScriptor home screen
const TINTS = ['#F5E6D3', '#EAD7C2', '#E2CDB4', '#D9C0A4', '#EFDFC8'];
function formatDate(iso) {
if (!iso) return '';
const d = new Date(iso);
const now = new Date();
const yes = new Date(now); yes.setDate(now.getDate() - 1);
if (d.toDateString() === now.toDateString()) return 'Today';
if (d.toDateString() === yes.toDateString()) return 'Yesterday';
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
const SCORE_STEPS = [0, 70, 75, 80, 85, 90];
function deriveScore(log) {
if (log.cva_total_score != null) return Math.round(log.cva_total_score);
const vals = [log.acidity, log.body, log.balance, log.sweetness].filter(v => v != null);
if (!vals.length) return 0;
return Math.round(vals.reduce((a, b) => a + b, 0) / vals.length * 10);
}
function mapLog(log) {
return {
id: log.id,
name: log.coffee_name,
roaster: log.roaster || 'β',
method: log.brew_method || 'β',
date: formatDate(log.created_at),
score: deriveScore(log),
tint: TINTS[log.id % TINTS.length],
};
}
function greeting() {
const h = new Date().getHours();
if (h < 12) return 'Morning';
if (h < 18) return 'Afternoon';
return 'Evening';
}
function Achievement({ icon, title, sub, color, locked }) {
return (
);
}
function StatPill({ label, value, accent }) {
return (
);
}
function ScoreRing({ score, size = 44 }) {
const r = size / 2 - 3;
const c = 2 * Math.PI * r;
const p = score / 100;
const tone = score >= 88 ? PALETTE.sageDeep
: score >= 80 ? PALETTE.caramel
: PALETTE.cocoa;
return (
);
}
function LogRow({ log, onOpen }) {
return (
{log.roaster}
{log.method}
{log.date}
);
}
const ACHIEVEMENTS = [
{ icon: 'π―', title: '100 Cups', sub: 'logged this season', color: '#F4E0C2' },
{ icon: 'π', title: 'Brew Master', sub: 'rated 90+ Γ 5', color: '#E5D4BC' },
{ icon: 'π', title: 'Sensory Pro', sub: 'all 8 flavors used', color: '#DCC8AE' },
{ icon: 'π₯', title: '14-day Streak',sub: 'keep it going', color: '#F0D9BC' },
{ icon: 'π', title: 'Origin Hunter',sub: '12 origins tasted', color: '#D8C5A8', locked: true },
{ icon: 'β±', title: 'Timer Hero', sub: 'log 50 timers', color: '#CDB89C', locked: true },
];
function Dashboard({ onNewLog, onOpenLog }) {
const [logs, setLogs] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState(false);
const [minScoreIdx, setMinScoreIdx] = React.useState(0);
const API_BASE = window.APP_API_BASE ?? '';
React.useEffect(() => {
let cancelled = false;
fetch(`${API_BASE}/api/logs`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(data => {
if (cancelled) return;
setLogs(Array.isArray(data) ? data : []);
setLoading(false);
})
.catch(() => {
if (cancelled) return;
setLoadError(true);
setLoading(false);
});
return () => { cancelled = true; };
}, []);
const now = new Date();
const { weekCount, originsThisWeek, dayCounts, maxDay, avgScore, bestScore, avgTds } = React.useMemo(() => {
// Week stats
const weekStart = new Date(now); weekStart.setDate(now.getDate() - 6); weekStart.setHours(0,0,0,0);
const weekLogs = logs.filter(l => new Date(l.created_at) >= weekStart);
// Bar chart β last 7 days
const dayCounts = Array.from({ length: 7 }, (_, i) => {
const d = new Date(now); d.setDate(now.getDate() - (6 - i)); const ds = d.toDateString();
return logs.filter(l => new Date(l.created_at).toDateString() === ds).length;
});
// Aggregate stats
const scores = logs.map(deriveScore).filter(s => s > 0);
const tdsVals = logs.map(l => l.tds).filter(v => v != null);
return {
weekCount: weekLogs.length,
originsThisWeek: new Set(weekLogs.map(l => l.roaster).filter(Boolean)).size,
dayCounts,
maxDay: Math.max(...dayCounts, 1),
avgScore: scores.length ? (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1) : 'β',
bestScore: scores.length ? Math.max(...scores) : 'β',
avgTds: tdsVals.length
? (tdsVals.reduce((a, b) => a + b, 0) / tdsVals.length).toFixed(2) + '%'
: 'β',
};
}, [logs]);
const tg = window.Telegram?.WebApp;
const firstName = tg?.initDataUnsafe?.user?.first_name || 'there';
const minScore = SCORE_STEPS[minScoreIdx];
const displayLogs = React.useMemo(() => logs
.filter(l => !minScore || deriveScore(l) >= minScore)
.slice(0, 10)
.map(mapLog), [logs, minScore]);
return (
{/* top action bar */}
{/* greeting + avatar */}
{now.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric' })}
{greeting()}, {firstName}.
{firstName.slice(0, 2).toUpperCase()}
{/* hero stats */}
This week
{weekCount}
{weekCount === 1 ? 'cup' : 'cups'}{originsThisWeek > 0 ? ` Β· ${originsThisWeek} origin${originsThisWeek > 1 ? 's' : ''}` : ''}
{/* mini bar chart */}
{dayCounts.map((v, i) => (
{['S','M','T','W','T','F','S'][(new Date(now.getTime() - (6 - i) * 86400000)).getDay()]}
))}
{/* achievements */}
Achievements
{ACHIEVEMENTS.map((a, i) =>
)}
{/* logs */}
0 ? `${logs.length} total` : ''} style={{ padding: '4px 24px 8px' }}>
Active logs
{SCORE_STEPS.map((s, i) => (
))}
{loading && (
Loadingβ¦
)}
{!loading && loadError && (
Couldn't load logs β check the backend and refresh.
)}
{!loading && !loadError && displayLogs.length === 0 && (
No logs yet β tap New tasting to start.
)}
{displayLogs.map((l, i) => (
onOpenLog && onOpenLog(l)} />
{i < displayLogs.length - 1 && (
)}
))}
);
}
Object.assign(window, { Dashboard });