Analytics Dashboard
Explore your beer collection data with comprehensive analytics, charts, and insights about your drinking habits and preferences.
The Analytics Dashboard provides deep insights into your beer collection and consumption patterns through interactive charts, statistics, and data visualizations.
Collection Statistics
Get an overview of your collection’s size, value, and diversity.
Collection Overview Component
<!-- src/components/analytics/CollectionOverview.svelte -->
<script>
let collection = $state([]);
let isLoading = $state(true);
// Load collection data
$effect(() => {
loadCollectionData();
});
async function loadCollectionData() {
try {
const response = await fetch('/api/collection');
if (response.ok) {
collection = await response.json();
}
} finally {
isLoading = false;
}
}
// Computed statistics
let stats = $derived(() => {
if (collection.length === 0) return null;
const totalBeers = collection.length;
const uniqueBreweries = new Set(collection.map(beer => beer.brewery)).size;
const uniqueStyles = new Set(collection.map(beer => beer.style)).size;
const totalValue = collection.reduce((sum, beer) => sum + (beer.purchasePrice || 0), 0);
const averagePrice = totalValue / totalBeers;
// Country distribution
const countryCount = {};
collection.forEach(beer => {
const country = beer.country || 'Unknown';
countryCount[country] = (countryCount[country] || 0) + 1;
});
return {
totalBeers,
uniqueBreweries,
uniqueStyles,
totalValue,
averagePrice,
countryCount
};
});
</script>
<div class="collection-overview">
{#if isLoading}
<div class="loading">Loading collection data...</div>
{:else if stats}
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value">{stats.totalBeers}</div>
<div class="stat-label">Total Beers</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.uniqueBreweries}</div>
<div class="stat-label">Breweries</div>
</div>
<div class="stat-card">
<div class="stat-value">{stats.uniqueStyles}</div>
<div class="stat-label">Beer Styles</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.totalValue.toFixed(2)}</div>
<div class="stat-label">Total Value</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.averagePrice.toFixed(2)}</div>
<div class="stat-label">Average Price</div>
</div>
</div>
<div class="country-breakdown">
<h4>Beers by Country</h4>
<div class="country-list">
{#each Object.entries(stats.countryCount).sort(([,a], [,b]) => b - a) as [country, count]}
<div class="country-item">
<span class="country-name">{country}</span>
<span class="country-count">{count}</span>
<div class="country-bar" style="width: {(count / stats.totalBeers) * 100}%"></div>
</div>
{/each}
</div>
</div>
{:else}
<div class="no-data">No collection data available</div>
{/if}
</div>
<style>
.collection-overview {
padding: 1rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--card-bg);
padding: 1.5rem;
border-radius: 8px;
text-align: center;
}
.stat-value {
font-size: 2rem;
font-weight: bold;
color: var(--primary);
margin-bottom: 0.5rem;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
}
.country-breakdown {
background: var(--card-bg);
padding: 1.5rem;
border-radius: 8px;
}
.country-list {
margin-top: 1rem;
}
.country-item {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
position: relative;
}
.country-name {
flex: 1;
font-weight: 500;
}
.country-count {
margin-left: 1rem;
font-weight: bold;
min-width: 30px;
text-align: right;
}
.country-bar {
position: absolute;
bottom: 0;
left: 0;
height: 4px;
background: var(--primary);
border-radius: 2px;
opacity: 0.3;
}
.loading, .no-data {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
</style>Consumption Trends
Track your drinking habits over time with consumption analytics.
Consumption Analytics Component
<!-- src/components/analytics/ConsumptionAnalytics.svelte -->
<script>
import { Chart } from 'chart.js/auto';
let consumptionData = $state([]);
let chartInstance = $state(null);
let timeRange = $state('month'); // 'week', 'month', 'year'
// Load consumption data
$effect(() => {
loadConsumptionData();
});
// Update chart when data or time range changes
$effect(() => {
if (consumptionData.length > 0) {
updateChart();
}
});
async function loadConsumptionData() {
try {
const response = await fetch(`/api/analytics/consumption?range=${timeRange}`);
if (response.ok) {
consumptionData = await response.json();
updateChart();
}
} catch (error) {
console.error('Failed to load consumption data:', error);
}
}
function updateChart() {
if (!consumptionData.length) return;
const ctx = document.getElementById('consumption-chart');
if (!ctx) return;
// Destroy existing chart
if (chartInstance) {
chartInstance.destroy();
}
const labels = consumptionData.map(item => item.date);
const data = consumptionData.map(item => item.count);
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels,
datasets: [{
label: 'Beers Consumed',
data,
borderColor: 'var(--primary)',
backgroundColor: 'var(--primary-bg)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
}
function changeTimeRange(range) {
timeRange = range;
}
</script>
<div class="consumption-analytics">
<div class="chart-header">
<h3>Consumption Trends</h3>
<div class="time-range-selector">
<button
class:active={timeRange === 'week'}
onclick={() => changeTimeRange('week')}
>
Week
</button>
<button
class:active={timeRange === 'month'}
onclick={() => changeTimeRange('month')}
>
Month
</button>
<button
class:active={timeRange === 'year'}
onclick={() => changeTimeRange('year')}
>
Year
</button>
</div>
</div>
<div class="chart-container">
<canvas id="consumption-chart" width="400" height="200"></canvas>
</div>
{#if consumptionData.length > 0}
<div class="consumption-stats">
<div class="stat">
<span class="stat-value">
{consumptionData.reduce((sum, item) => sum + item.count, 0)}
</span>
<span class="stat-label">Total beers this {timeRange}</span>
</div>
<div class="stat">
<span class="stat-value">
{(consumptionData.reduce((sum, item) => sum + item.count, 0) / consumptionData.length).toFixed(1)}
</span>
<span class="stat-label">Average per day</span>
</div>
<div class="stat">
<span class="stat-value">
{Math.max(...consumptionData.map(item => item.count))}
</span>
<span class="stat-label">Most beers in a day</span>
</div>
</div>
{/if}
</div>
<style>
.consumption-analytics {
background: var(--card-bg);
padding: 1.5rem;
border-radius: 8px;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.time-range-selector {
display: flex;
gap: 0.5rem;
}
.time-range-selector button {
background: var(--bg-secondary);
border: 1px solid var(--border);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.time-range-selector button.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.chart-container {
margin-bottom: 1rem;
}
.consumption-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.stat {
text-align: center;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: bold;
color: var(--primary);
}
.stat-label {
font-size: 0.9rem;
color: var(--text-secondary);
}
</style>Spending Analysis
Analyze your beer purchasing patterns and spending habits.
Spending Analytics Component
<!-- src/components/analytics/SpendingAnalytics.svelte -->
<script>
import { Chart } from 'chart.js/auto';
let spendingData = $state([]);
let chartInstance = $state(null);
let viewMode = $state('monthly'); // 'monthly', 'yearly', 'cumulative'
$effect(() => {
loadSpendingData();
});
$effect(() => {
if (spendingData.length > 0) {
updateChart();
}
});
async function loadSpendingData() {
try {
const response = await fetch('/api/analytics/spending');
if (response.ok) {
spendingData = await response.json();
updateChart();
}
} catch (error) {
console.error('Failed to load spending data:', error);
}
}
function updateChart() {
if (!spendingData.length) return;
const ctx = document.getElementById('spending-chart');
if (!ctx) return;
if (chartInstance) {
chartInstance.destroy();
}
let labels, data;
switch (viewMode) {
case 'monthly':
labels = spendingData.map(item => item.month);
data = spendingData.map(item => item.amount);
break;
case 'yearly':
// Group by year
const yearlyData = {};
spendingData.forEach(item => {
const year = item.month.split('-')[0];
yearlyData[year] = (yearlyData[year] || 0) + item.amount;
});
labels = Object.keys(yearlyData);
data = Object.values(yearlyData);
break;
case 'cumulative':
labels = spendingData.map(item => item.month);
let cumulative = 0;
data = spendingData.map(item => cumulative += item.amount);
break;
}
chartInstance = new Chart(ctx, {
type: viewMode === 'cumulative' ? 'line' : 'bar',
data: {
labels,
datasets: [{
label: viewMode === 'cumulative' ? 'Cumulative Spending' : 'Monthly Spending',
data,
backgroundColor: 'var(--primary-bg)',
borderColor: 'var(--primary)',
borderWidth: viewMode === 'cumulative' ? 2 : 0,
fill: viewMode === 'cumulative'
}]
},
options: {
responsive: true,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: (value) => `$${value.toFixed(0)}`
}
}
}
}
});
}
let totalSpent = $derived(() => {
return spendingData.reduce((sum, item) => sum + item.amount, 0);
});
let averageMonthly = $derived(() => {
if (spendingData.length === 0) return 0;
return totalSpent / spendingData.length;
});
let highestMonth = $derived(() => {
if (spendingData.length === 0) return null;
return spendingData.reduce((max, item) => item.amount > max.amount ? item : max);
});
</script>
<div class="spending-analytics">
<div class="analytics-header">
<h3>Spending Analysis</h3>
<div class="view-selector">
<button
class:active={viewMode === 'monthly'}
onclick={() => viewMode = 'monthly'}
>
Monthly
</button>
<button
class:active={viewMode === 'yearly'}
onclick={() => viewMode = 'yearly'}
>
Yearly
</button>
<button
class:active={viewMode === 'cumulative'}
onclick={() => viewMode = 'cumulative'}
>
Cumulative
</button>
</div>
</div>
<div class="spending-summary">
<div class="summary-stat">
<div class="stat-value">${totalSpent.toFixed(2)}</div>
<div class="stat-label">Total Spent</div>
</div>
<div class="summary-stat">
<div class="stat-value">${averageMonthly.toFixed(2)}</div>
<div class="stat-label">Average per Month</div>
</div>
{#if highestMonth}
<div class="summary-stat">
<div class="stat-value">${highestMonth.amount.toFixed(2)}</div>
<div class="stat-label">Highest Month ({highestMonth.month})</div>
</div>
{/if}
</div>
<div class="chart-container">
<canvas id="spending-chart" width="400" height="200"></canvas>
</div>
</div>
<style>
.spending-analytics {
background: var(--card-bg);
padding: 1.5rem;
border-radius: 8px;
}
.analytics-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.view-selector {
display: flex;
gap: 0.5rem;
}
.view-selector button {
background: var(--bg-secondary);
border: 1px solid var(--border);
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.view-selector button.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.spending-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.summary-stat {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: var(--primary);
}
.stat-label {
font-size: 0.9rem;
color: var(--text-secondary);
}
.chart-container {
margin-top: 1rem;
}
</style>Style Preferences
Discover your favorite beer styles and brewing trends.
Style Preferences Component
<!-- src/components/analytics/StylePreferences.svelte -->
<script>
import { Chart } from 'chart.js/auto';
let styleData = $state([]);
let chartInstance = $state(null);
let sortBy = $state('count'); // 'count', 'rating', 'value'
$effect(() => {
loadStyleData();
});
$effect(() => {
if (styleData.length > 0) {
updateChart();
}
});
async function loadStyleData() {
try {
const response = await fetch('/api/analytics/styles');
if (response.ok) {
styleData = await response.json();
updateChart();
}
} catch (error) {
console.error('Failed to load style data:', error);
}
}
function updateChart() {
if (!styleData.length) return;
const ctx = document.getElementById('style-chart');
if (!ctx) return;
if (chartInstance) {
chartInstance.destroy();
}
// Sort data based on selected criteria
const sortedData = [...styleData].sort((a, b) => {
switch (sortBy) {
case 'count':
return b.count - a.count;
case 'rating':
return b.averageRating - a.averageRating;
case 'value':
return b.totalValue - a.totalValue;
default:
return 0;
}
});
const labels = sortedData.map(item => item.style);
const data = sortedData.map(item => {
switch (sortBy) {
case 'count':
return item.count;
case 'rating':
return item.averageRating;
case 'value':
return item.totalValue;
default:
return item.count;
}
});
chartInstance = new Chart(ctx, {
type: 'horizontalBar',
data: {
labels,
datasets: [{
label: sortBy === 'rating' ? 'Average Rating' :
sortBy === 'value' ? 'Total Value ($)' : 'Beer Count',
data,
backgroundColor: 'var(--primary)',
borderRadius: 4
}]
},
options: {
indexAxis: 'y',
responsive: true,
plugins: {
legend: {
display: false
}
},
scales: {
x: {
beginAtZero: true,
ticks: {
callback: (value) => {
if (sortBy === 'rating') return value.toFixed(1);
if (sortBy === 'value') return `$${value.toFixed(0)}`;
return value;
}
}
}
}
}
});
}
let topStyle = $derived(() => {
if (styleData.length === 0) return null;
return styleData.reduce((top, style) =>
style.count > top.count ? style : top
);
});
let favoriteStyle = $derived(() => {
if (styleData.length === 0) return null;
return styleData.reduce((top, style) =>
style.averageRating > top.averageRating ? style : top
);
});
</script>
<div class="style-preferences">
<div class="preferences-header">
<h3>Style Preferences</h3>
<div class="sort-selector">
<label>Sort by:</label>
<select bind:value={sortBy}>
<option value="count">Number of Beers</option>
<option value="rating">Average Rating</option>
<option value="value">Total Value</option>
</select>
</div>
</div>
{#if topStyle && favoriteStyle}
<div class="style-insights">
<div class="insight-card">
<div class="insight-value">{topStyle.style}</div>
<div class="insight-label">Most Collected Style</div>
<div class="insight-detail">{topStyle.count} beers</div>
</div>
<div class="insight-card">
<div class="insight-value">{favoriteStyle.style}</div>
<div class="insight-label">Highest Rated Style</div>
<div class="insight-detail">{favoriteStyle.averageRating.toFixed(1)}/5 average</div>
</div>
</div>
{/if}
<div class="chart-container">
<canvas id="style-chart" width="400" height="300"></canvas>
</div>
<div class="style-list">
{#each styleData as style}
<div class="style-item">
<div class="style-info">
<strong>{style.style}</strong>
<div class="style-stats">
{style.count} beers • Avg rating: {style.averageRating.toFixed(1)} • Total value: ${style.totalValue.toFixed(2)}
</div>
</div>
<div class="style-rating">
<div class="rating-bar" style="width: {(style.averageRating / 5) * 100}%"></div>
</div>
</div>
{/each}
</div>
</div>
<style>
.style-preferences {
background: var(--card-bg);
padding: 1.5rem;
border-radius: 8px;
}
.preferences-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.sort-selector {
display: flex;
align-items: center;
gap: 0.5rem;
}
.style-insights {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.insight-card {
background: var(--bg-secondary);
padding: 1rem;
border-radius: 6px;
text-align: center;
}
.insight-value {
font-size: 1.2rem;
font-weight: bold;
color: var(--primary);
}
.insight-label {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 0.25rem 0;
}
.insight-detail {
font-size: 0.8rem;
color: var(--text-secondary);
}
.chart-container {
margin-bottom: 1rem;
}
.style-list {
margin-top: 1rem;
}
.style-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
}
.style-item:last-child {
border-bottom: none;
}
.style-info {
flex: 1;
}
.style-stats {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.style-rating {
width: 100px;
height: 8px;
background: var(--bg-secondary);
border-radius: 4px;
overflow: hidden;
}
.rating-bar {
height: 100%;
background: var(--primary);
border-radius: 4px;
}
</style>Advanced Analytics
Export your data and generate custom reports.
Data Export Component
<!-- src/components/analytics/DataExport.svelte -->
<script>
let exportFormat = $state('csv');
let dateRange = $state('all');
let includeRatings = $state(true);
let includeConsumption = $state(true);
let isExporting = $state(false);
async function exportData() {
isExporting = true;
try {
const params = new URLSearchParams({
format: exportFormat,
dateRange,
includeRatings: includeRatings.toString(),
includeConsumption: includeConsumption.toString()
});
const response = await fetch(`/api/analytics/export?${params}`);
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `beer-collection-${new Date().toISOString().split('T')[0]}.${exportFormat}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
}
} finally {
isExporting = false;
}
}
</script>
<div class="data-export">
<h3>Export Your Data</h3>
<p>Download your collection data for analysis in external tools.</p>
<div class="export-options">
<div class="option-group">
<label for="format">Export Format</label>
<select id="format" bind:value={exportFormat}>
<option value="csv">CSV</option>
<option value="json">JSON</option>
<option value="xlsx">Excel</option>
</select>
</div>
<div class="option-group">
<label for="dateRange">Date Range</label>
<select id="dateRange" bind:value={dateRange}>
<option value="all">All Time</option>
<option value="year">Last Year</option>
<option value="month">Last Month</option>
<option value="week">Last Week</option>
</select>
</div>
</div>
<div class="export-sections">
<label>
<input type="checkbox" bind:checked={includeRatings} />
Include ratings and reviews
</label>
<label>
<input type="checkbox" bind:checked={includeConsumption} />
Include consumption history
</label>
</div>
<button onclick={exportData} disabled={isExporting}>
{#if isExporting}
Exporting...
{:else}
Export Data
{/if}
</button>
</div>
<style>
.data-export {
background: var(--card-bg);
padding: 1.5rem;
border-radius: 8px;
}
.export-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin: 1rem 0;
}
.option-group {
display: flex;
flex-direction: column;
}
.export-sections {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 1rem 0;
}
.export-sections label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
button {
background: var(--primary);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
}
button:hover:not(:disabled) {
background: var(--primary-hover);
}
button:disabled {
background: var(--text-secondary);
cursor: not-allowed;
}
</style>Next Steps
- Collection Guide - Manage your beer inventory
- Ratings Guide - Rate beers to improve analytics
- Marketplace Guide - Track marketplace performance
Product Analytics with Rybbit
In addition to user-facing collection analytics, BrewHoard uses Rybbit for product analytics to help us understand how users interact with the app and improve the experience.
What We Track
We track key product events to understand user behavior and improve BrewHoard:
| Event Category | Events | Purpose |
|---|---|---|
| Collection | collection_add, collection_edit, collection_consume | Track collection management patterns |
| Scanner | scan_start, scan_success, scan_fail | Measure scanner accuracy and usage |
| Marketplace | listing_create, listing_view, purchase_start, purchase_complete | Understand marketplace engagement |
| Ratings | rating_submit | Track rating adoption |
| Social | user_follow, user_unfollow | Measure social feature engagement |
| Auth | user_signup, user_login | Track user acquisition and retention |
Implementation
The Rybbit integration is built as a reusable module at $lib/analytics/rybbit.js:
// Import tracking functions
import {
trackCollectionAdd,
trackScanSuccess,
trackRatingSubmit,
trackEvent,
RybbitEvents
} from '$lib/analytics/rybbit.js';
// Track a collection add
trackCollectionAdd({
beerName: 'Westvleteren 12',
style: 'Belgian Quadrupel',
quantity: 2
});
// Track a scan success
trackScanSuccess({
source: 'camera',
beerName: 'Detected Beer',
confidence: 85
});
// Track a rating submission
trackRatingSubmit({
beerId: 'uuid',
beerName: 'Beer Name',
rating: 4.5,
hasReview: true
});
// Track custom events
trackEvent(RybbitEvents.CTA_CLICK, {
cta_name: 'upgrade_premium',
location: 'collection_page'
});Privacy Considerations
- No PII: We only track event names and non-personally-identifiable properties
- Aggregated data: Analytics are used for aggregate insights, not individual tracking
- User control: Users can block analytics via browser extensions if desired
- Transparent: This documentation explains exactly what we track
Available Events
All events are defined in RybbitEvents constant:
export const RybbitEvents = {
// Collection
COLLECTION_ADD: 'collection_add',
COLLECTION_EDIT: 'collection_edit',
COLLECTION_DELETE: 'collection_delete',
COLLECTION_CONSUME: 'collection_consume',
// Scanner
SCAN_START: 'scan_start',
SCAN_SUCCESS: 'scan_success',
SCAN_FAIL: 'scan_fail',
// Marketplace
LISTING_CREATE: 'listing_create',
LISTING_VIEW: 'listing_view',
PURCHASE_START: 'purchase_start',
PURCHASE_COMPLETE: 'purchase_complete',
// Ratings
RATING_SUBMIT: 'rating_submit',
// Social
USER_FOLLOW: 'user_follow',
USER_UNFOLLOW: 'user_unfollow',
// Auth
USER_SIGNUP: 'user_signup',
USER_LOGIN: 'user_login',
USER_LOGOUT: 'user_logout',
// Features
FEATURE_ENABLE: 'feature_enable',
FEATURE_DISABLE: 'feature_disable',
SEARCH_PERFORM: 'search_perform',
EXPORT_DATA: 'export_data',
// Engagement
CTA_CLICK: 'cta_click',
PWA_INSTALL: 'pwa_install',
SHARE: 'share'
};Convenience Functions
For common events, use the typed convenience functions:
| Function | Parameters |
|---|---|
trackCollectionAdd() | beerId, beerName, style, quantity |
trackCollectionConsume() | beerId, beerName, quantity, rating |
trackScanStart() | source |
trackScanSuccess() | source, beerId, beerName, confidence |
trackScanFail() | source, reason |
trackListingCreate() | listingId, beerId, beerName, price, currency |
trackRatingSubmit() | beerId, beerName, rating, hasReview |
trackUserFollow() | followedUserId, followedUsername |
trackUserUnfollow() | unfollowedUserId |
trackSearch() | query, searchType, resultCount |
trackExport() | format, dataType |
These functions handle null-safety and ensure consistent property naming across the codebase.