diff --git a/.env.sample b/.env.sample index 0ff7cdd..5bf4014 100644 --- a/.env.sample +++ b/.env.sample @@ -395,3 +395,12 @@ # - "bricktracker_wishes"."number_of_parts": set number of parts # Default: "bricktracker_wishes"."rowid" DESC # BK_WISHES_DEFAULT_ORDER="bricktracker_wishes"."set" DESC + +# Optional: Show collection growth charts on the statistics page +# Default: true +# BK_STATISTICS_SHOW_CHARTS=false + +# Optional: Default state of statistics page sections (expanded or collapsed) +# When true, all sections start expanded. When false, all sections start collapsed. +# Default: true +# BK_STATISTICS_DEFAULT_EXPANDED=false diff --git a/bricktracker/config.py b/bricktracker/config.py index d65cf05..824a49e 100644 --- a/bricktracker/config.py +++ b/bricktracker/config.py @@ -90,4 +90,6 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'TIMEZONE', 'd': 'Etc/UTC'}, {'n': 'USE_REMOTE_IMAGES', 'c': bool}, {'n': 'WISHES_DEFAULT_ORDER', 'd': '"bricktracker_wishes"."rowid" DESC'}, + {'n': 'STATISTICS_SHOW_CHARTS', 'd': True, 'c': bool}, + {'n': 'STATISTICS_DEFAULT_EXPANDED', 'd': True, 'c': bool}, ] diff --git a/bricktracker/sql/statistics/themes.sql b/bricktracker/sql/statistics/themes.sql index fbf12ca..082e38c 100644 --- a/bricktracker/sql/statistics/themes.sql +++ b/bricktracker/sql/statistics/themes.sql @@ -15,7 +15,9 @@ SELECT ROUND(AVG("bricktracker_sets"."purchase_price"), 2) AS "avg_price", -- Problem statistics per theme COALESCE(SUM("problem_stats"."missing_parts"), 0) AS "missing_parts", - COALESCE(SUM("problem_stats"."damaged_parts"), 0) AS "damaged_parts" + COALESCE(SUM("problem_stats"."damaged_parts"), 0) AS "damaged_parts", + -- Minifigure statistics per theme + COALESCE(SUM("minifigure_stats"."minifigure_count"), 0) AS "total_minifigures" FROM "bricktracker_sets" INNER JOIN "rebrickable_sets" ON "bricktracker_sets"."set" = "rebrickable_sets"."set" LEFT JOIN ( @@ -26,5 +28,12 @@ LEFT JOIN ( FROM "bricktracker_parts" GROUP BY "bricktracker_parts"."id" ) "problem_stats" ON "bricktracker_sets"."id" = "problem_stats"."id" +LEFT JOIN ( + SELECT + "bricktracker_minifigures"."id", + SUM("bricktracker_minifigures"."quantity") AS "minifigure_count" + FROM "bricktracker_minifigures" + GROUP BY "bricktracker_minifigures"."id" +) "minifigure_stats" ON "bricktracker_sets"."id" = "minifigure_stats"."id" GROUP BY "rebrickable_sets"."theme_id" ORDER BY "set_count" DESC, "rebrickable_sets"."theme_id" ASC \ No newline at end of file diff --git a/bricktracker/views/statistics.py b/bricktracker/views/statistics.py index 6d21302..7f06b8e 100644 --- a/bricktracker/views/statistics.py +++ b/bricktracker/views/statistics.py @@ -5,7 +5,7 @@ Provides statistics and analytics pages import logging -from flask import Blueprint, render_template, request, url_for, redirect +from flask import Blueprint, render_template, request, url_for, redirect, current_app from werkzeug.wrappers.response import Response from .exceptions import exception_handler @@ -34,6 +34,11 @@ def overview() -> str: purchases_by_year_stats = stats.get_purchases_by_year_statistics() year_summary = stats.get_year_summary() + # Prepare chart data for visualization (only if charts are enabled) + chart_data = {} + if current_app.config['STATISTICS_SHOW_CHARTS']: + chart_data = prepare_chart_data(sets_by_year_stats, purchases_by_year_stats) + # Get filter parameters for clickable statistics filter_type = request.args.get('filter_type') filter_value = request.args.get('filter_value') @@ -53,6 +58,7 @@ def overview() -> str: sets_by_year_statistics=sets_by_year_stats, purchases_by_year_statistics=purchases_by_year_stats, year_summary=year_summary, + chart_data=chart_data, title="Statistics Overview" ) @@ -124,4 +130,60 @@ def purchase_locations() -> str: 'statistics_purchase_locations.html', purchase_location_statistics=purchase_stats, title="Purchase Location Statistics" - ) \ No newline at end of file + ) + + +def prepare_chart_data(sets_by_year_stats, purchases_by_year_stats): + """Prepare data for Chart.js visualization""" + import json + + # Get all years from both datasets + all_years = set() + + # Add years from sets by year + if sets_by_year_stats: + for year_stat in sets_by_year_stats: + if 'year' in year_stat: + all_years.add(year_stat['year']) + + # Add years from purchases by year + if purchases_by_year_stats: + for year_stat in purchases_by_year_stats: + if 'purchase_year' in year_stat: + all_years.add(int(year_stat['purchase_year'])) + + # Create sorted list of years + years = sorted(list(all_years)) + + # Initialize data arrays + sets_data = [] + parts_data = [] + minifigs_data = [] + + # Create lookup dictionaries for quick access + sets_by_year_lookup = {} + if sets_by_year_stats: + for year_stat in sets_by_year_stats: + if 'year' in year_stat: + sets_by_year_lookup[year_stat['year']] = year_stat + + # Fill data arrays + for year in years: + # Get sets and parts data from sets_by_year + year_data = sets_by_year_lookup.get(year) + if year_data: + sets_data.append(year_data.get('total_sets', 0)) + parts_data.append(year_data.get('total_parts', 0)) + # Use actual minifigure count from the database + minifigs_data.append(year_data.get('total_minifigures', 0)) + else: + sets_data.append(0) + parts_data.append(0) + minifigs_data.append(0) + + return { + 'years': json.dumps(years), + 'sets_data': json.dumps(sets_data), + 'parts_data': json.dumps(parts_data), + 'minifigs_data': json.dumps(minifigs_data) + } \ No newline at end of file diff --git a/static/scripts/statistics.js b/static/scripts/statistics.js new file mode 100644 index 0000000..f51c4bf --- /dev/null +++ b/static/scripts/statistics.js @@ -0,0 +1,153 @@ +/** + * Statistics page chart functionality + */ + +document.addEventListener('DOMContentLoaded', function() { + // Check if charts are enabled and chart data exists + if (typeof window.chartData === 'undefined') { + return; + } + + // Debug: Log chart data to console + console.log('Chart Data:', window.chartData); + + // Common chart configuration + const commonConfig = { + type: 'line', + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + intersect: false, + mode: 'index' + }, + scales: { + x: { + title: { + display: true, + text: 'Release Year' + }, + grid: { + display: false + } + }, + y: { + beginAtZero: true, + title: { + display: true, + text: 'Count' + }, + ticks: { + precision: 0 + } + } + }, + plugins: { + legend: { + display: false + }, + tooltip: { + backgroundColor: 'rgba(0, 0, 0, 0.8)', + titleColor: 'white', + bodyColor: 'white', + cornerRadius: 4 + } + }, + elements: { + point: { + radius: 3, + hoverRadius: 5 + }, + line: { + borderWidth: 2 + } + } + } + }; + + // Sets Chart + const setsCanvas = document.getElementById('setsChart'); + if (setsCanvas) { + const setsCtx = setsCanvas.getContext('2d'); + new Chart(setsCtx, { + ...commonConfig, + data: { + labels: window.chartData.years, + datasets: [{ + label: 'Sets', + data: window.chartData.sets, + borderColor: '#0d6efd', + backgroundColor: 'rgba(13, 110, 253, 0.1)', + fill: true, + tension: 0.3 + }] + } + }); + } + + // Parts Chart + const partsCanvas = document.getElementById('partsChart'); + if (partsCanvas) { + const partsCtx = partsCanvas.getContext('2d'); + new Chart(partsCtx, { + ...commonConfig, + data: { + labels: window.chartData.years, + datasets: [{ + label: 'Parts', + data: window.chartData.parts, + borderColor: '#198754', + backgroundColor: 'rgba(25, 135, 84, 0.1)', + fill: true, + tension: 0.3 + }] + }, + options: { + ...commonConfig.options, + scales: { + ...commonConfig.options.scales, + y: { + ...commonConfig.options.scales.y, + title: { + display: true, + text: 'Parts Count' + } + } + } + } + }); + } + + // Minifigures Chart + const minifigsCanvas = document.getElementById('minifigsChart'); + if (minifigsCanvas) { + const minifigsCtx = minifigsCanvas.getContext('2d'); + new Chart(minifigsCtx, { + ...commonConfig, + data: { + labels: window.chartData.years, + datasets: [{ + label: 'Minifigures', + data: window.chartData.minifigs, + borderColor: '#fd7e14', + backgroundColor: 'rgba(253, 126, 20, 0.1)', + fill: true, + tension: 0.3 + }] + }, + options: { + ...commonConfig.options, + scales: { + ...commonConfig.options.scales, + y: { + ...commonConfig.options.scales.y, + title: { + display: true, + text: 'Minifigures Count' + } + } + } + } + }); + } +}); \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 310bccb..b95433d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -12,6 +12,7 @@ +