diff --git a/.env.sample b/.env.sample index 12541e5..5bf4014 100644 --- a/.env.sample +++ b/.env.sample @@ -122,6 +122,10 @@ # Default: false # BK_HIDE_ALL_STORAGES=true +# Optional: Hide the 'Statistics' entry from the menu. Does not disable the route. +# Default: false +# BK_HIDE_STATISTICS=true + # Optional: Hide the 'Instructions' entry in a Set card # Default: false # BK_HIDE_SET_INSTRUCTIONS=true @@ -391,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/CHANGELOG.md b/CHANGELOG.md index cc51561..94c5e05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,11 +41,28 @@ - Improved theme filtering: handles duplicate theme names correctly - Fixed set number sorting: proper numeric sorting in both ascending and descending order - Mixed status indicators for consolidated sets: three-state checkboxes (unchecked/partial/checked) with count badges - - Enhanced SQL aggregation with `IFNULL(SUM())` for accurate count statistics - Template logic handles three states: none (0/2), all (2/2), partial (1/2) with visual indicators - Purple overlay styling for partial states, disabled checkboxes for read-only consolidated status display - Individual sets maintain full interactive checkbox functionality - +- Add comprehensive statistics system (#91) + - New Statistics page with collection analytics + - Financial overview: total cost, average price, price range, investment tracking + - Collection metrics: total sets, unique sets, parts count, minifigures count + - Theme distribution statistics with clickable drill-down to filtered sets + - Storage location statistics showing sets per location with value calculations + - Purchase location analytics with spending patterns and date ranges + - Problem tracking: missing and damaged parts statistics + - Clickable numbers throughout statistics that filter to relevant sets + - `BK_HIDE_STATISTICS`: Environment variable to hide statistics menu item + - Year-based analytics: Sets by release year and purchases by year + - Sets by Release Year: Shows collection distribution across LEGO release years + - Purchases by Year: Tracks spending patterns and acquisition timeline + - Year summary with peak collection/spending years and timeline insights + - Enhanced statistics interface and functionality + - Collapsible sections: All statistics sections have clickable headers to expand/collapse + - Collection growth charts: Line charts showing sets, parts, and minifigures over time + - Configuration options: `BK_STATISTICS_SHOW_CHARTS` and `BK_STATISTICS_DEFAULT_EXPANDED` environment variables + ### 1.2.4 > **Warning** diff --git a/bricktracker/app.py b/bricktracker/app.py index 5f57bd8..8130d00 100644 --- a/bricktracker/app.py +++ b/bricktracker/app.py @@ -32,6 +32,7 @@ from bricktracker.views.login import login_page from bricktracker.views.minifigure import minifigure_page from bricktracker.views.part import part_page from bricktracker.views.set import set_page +from bricktracker.views.statistics import statistics_page from bricktracker.views.storage import storage_page from bricktracker.views.wish import wish_page @@ -82,6 +83,7 @@ def setup_app(app: Flask) -> None: app.register_blueprint(minifigure_page) app.register_blueprint(part_page) app.register_blueprint(set_page) + app.register_blueprint(statistics_page) app.register_blueprint(storage_page) app.register_blueprint(wish_page) diff --git a/bricktracker/config.py b/bricktracker/config.py index 84dda85..824a49e 100644 --- a/bricktracker/config.py +++ b/bricktracker/config.py @@ -31,6 +31,7 @@ CONFIG: Final[list[dict[str, Any]]] = [ {'n': 'HIDE_ALL_PROBLEMS_PARTS', 'e': 'BK_HIDE_MISSING_PARTS', 'c': bool}, {'n': 'HIDE_ALL_SETS', 'c': bool}, {'n': 'HIDE_ALL_STORAGES', 'c': bool}, + {'n': 'HIDE_STATISTICS', 'c': bool}, {'n': 'HIDE_SET_INSTRUCTIONS', 'c': bool}, {'n': 'HIDE_TABLE_DAMAGED_PARTS', 'c': bool}, {'n': 'HIDE_TABLE_MISSING_PARTS', 'c': bool}, @@ -89,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/navbar.py b/bricktracker/navbar.py index 20a2b29..893dead 100644 --- a/bricktracker/navbar.py +++ b/bricktracker/navbar.py @@ -15,6 +15,7 @@ NAVBAR: Final[list[dict[str, Any]]] = [ {'e': 'minifigure.list', 't': 'Minifigures', 'i': 'group-line', 'f': 'HIDE_ALL_MINIFIGURES'}, # noqa: E501 {'e': 'instructions.list', 't': 'Instructions', 'i': 'file-line', 'f': 'HIDE_ALL_INSTRUCTIONS'}, # noqa: E501 {'e': 'storage.list', 't': 'Storages', 'i': 'archive-2-line', 'f': 'HIDE_ALL_STORAGES'}, # noqa: E501 + {'e': 'statistics.overview', 't': 'Statistics', 'i': 'bar-chart-line', 'f': 'HIDE_STATISTICS'}, # noqa: E501 {'e': 'wish.list', 't': 'Wishlist', 'i': 'gift-line', 'f': 'HIDE_WISHES'}, {'e': 'admin.admin', 't': 'Admin', 'i': 'settings-4-line', 'f': 'HIDE_ADMIN'}, # noqa: E501 ] diff --git a/bricktracker/set.py b/bricktracker/set.py index c397b13..56c7359 100644 --- a/bricktracker/set.py +++ b/bricktracker/set.py @@ -169,6 +169,20 @@ class BrickSet(RebrickableSet): else: return '' + # Purchase date max formatted for consolidated sets + def purchase_date_max_formatted(self, /, *, standard: bool = False) -> str: + if hasattr(self.fields, 'purchase_date_max') and self.fields.purchase_date_max is not None: + time = datetime.fromtimestamp(self.fields.purchase_date_max) + + if standard: + return time.strftime('%Y/%m/%d') + else: + return time.strftime( + current_app.config['PURCHASE_DATE_FORMAT'] + ) + else: + return '' + # Purchase price with currency def purchase_price(self, /) -> str: if self.fields.purchase_price is not None: diff --git a/bricktracker/set_list.py b/bricktracker/set_list.py index 2b0c978..9a030f1 100644 --- a/bricktracker/set_list.py +++ b/bricktracker/set_list.py @@ -20,6 +20,7 @@ from .instructions_list import BrickInstructionsList # All the sets from the database class BrickSetList(BrickRecordList[BrickSet]): themes: list[str] + years: list[int] order: str # Queries @@ -41,6 +42,7 @@ class BrickSetList(BrickRecordList[BrickSet]): # Placeholders self.themes = [] + self.years = [] # Store the order for this list self.order = current_app.config['SETS_DEFAULT_ORDER'] @@ -83,6 +85,7 @@ class BrickSetList(BrickRecordList[BrickSet]): purchase_location_filter: str | None = None, storage_filter: str | None = None, tag_filter: str | None = None, + year_filter: str | None = None, use_consolidated: bool = True ) -> tuple[Self, int]: # Convert theme name to theme ID for filtering @@ -91,7 +94,7 @@ class BrickSetList(BrickRecordList[BrickSet]): theme_id_filter = self._theme_name_to_id(theme_filter) # Check if any filters are applied - has_filters = any([status_filter, theme_id_filter, owner_filter, purchase_location_filter, storage_filter, tag_filter]) + has_filters = any([status_filter, theme_id_filter, owner_filter, purchase_location_filter, storage_filter, tag_filter, year_filter]) # Prepare filter context filter_context = { @@ -102,6 +105,7 @@ class BrickSetList(BrickRecordList[BrickSet]): 'purchase_location_filter': purchase_location_filter, 'storage_filter': storage_filter, 'tag_filter': tag_filter, + 'year_filter': year_filter, 'owners': BrickSetOwnerList.as_columns(), 'statuses': BrickSetStatusList.as_columns(), 'tags': BrickSetTagList.as_columns(), @@ -183,11 +187,15 @@ class BrickSetList(BrickRecordList[BrickSet]): **filter_context ) - # Populate themes for filter dropdown from filtered dataset (not just current page) + # Populate themes and years for filter dropdown from filtered dataset (not just current page) result._populate_themes_from_filtered_dataset( query_to_use, **filter_context ) + result._populate_years_from_filtered_dataset( + query_to_use, + **filter_context + ) return result, total_count @@ -201,16 +209,37 @@ class BrickSetList(BrickRecordList[BrickSet]): self.themes = list(themes) self.themes.sort() - def _theme_name_to_id(self, theme_name: str) -> str | None: - """Convert a theme name to theme ID for filtering""" + def _populate_years(self) -> None: + """Populate years list from the current records""" + years = set() + for record in self.records: + if hasattr(record, 'fields') and hasattr(record.fields, 'year') and record.fields.year: + years.add(record.fields.year) + + self.years = list(years) + self.years.sort(reverse=True) # Most recent years first + + def _theme_name_to_id(self, theme_name_or_id: str) -> str | None: + """Convert a theme name or ID to theme ID for filtering""" try: + # Check if the input is already a numeric theme ID + if theme_name_or_id.isdigit(): + # Input is already a theme ID, validate it exists + theme_list = BrickThemeList() + theme_id = int(theme_name_or_id) + if theme_id in theme_list.themes: + return str(theme_id) + else: + return None + + # Input is a theme name, convert to ID from .sql import BrickSQL theme_list = BrickThemeList() # Find all theme IDs that match the name matching_theme_ids = [] for theme_id, theme in theme_list.themes.items(): - if theme.name.lower() == theme_name.lower(): + if theme.name.lower() == theme_name_or_id.lower(): matching_theme_ids.append(str(theme_id)) if not matching_theme_ids: @@ -238,6 +267,27 @@ class BrickSetList(BrickRecordList[BrickSet]): # If themes can't be loaded, return None to disable theme filtering return None + def _theme_id_to_name(self, theme_id: str) -> str | None: + """Convert a theme ID to theme name (lowercase) for dropdown display""" + try: + if not theme_id or not theme_id.isdigit(): + return None + + from .theme_list import BrickThemeList + theme_list = BrickThemeList() + theme_id_int = int(theme_id) + + if theme_id_int in theme_list.themes: + return theme_list.themes[theme_id_int].name.lower() + + return None + except Exception as e: + # For debugging - log the exception + import logging + logger = logging.getLogger(__name__) + logger.warning(f"Failed to convert theme ID {theme_id} to name: {e}") + return None + def _all_filtered_paginated_with_instructions( self, search_query: str | None, @@ -309,12 +359,15 @@ class BrickSetList(BrickRecordList[BrickSet]): result = BrickSetList() result.records = paginated_records - # Copy themes from the source that has all sets + # Copy themes and years from the source that has all sets result.themes = all_sets.themes if hasattr(all_sets, 'themes') else [] + result.years = all_sets.years if hasattr(all_sets, 'years') else [] - # If themes weren't populated, populate them globally + # If themes or years weren't populated, populate them from current records if not result.themes: - result._populate_themes_global() + result._populate_themes() + if not result.years: + result._populate_years() return result, total_count @@ -326,6 +379,29 @@ class BrickSetList(BrickRecordList[BrickSet]): purchase_location_filter, storage_filter, tag_filter ) + def _populate_years_from_filtered_dataset(self, query_name: str, **filter_context) -> None: + """Populate years list from all available records in filtered dataset""" + try: + # Get all records matching the current filters (not just current page) + unlimited_context = filter_context.copy() + unlimited_context.pop('limit', None) + unlimited_context.pop('offset', None) + + # Query all records for year extraction + all_sets = self._query_sets(query_name, **unlimited_context) + + if all_sets.records: + years = set() + for record in all_sets.records: + if hasattr(record, 'fields') and hasattr(record.fields, 'year') and record.fields.year: + years.add(record.fields.year) + + self.years = list(years) + self.years.sort(reverse=True) # Most recent years first + except Exception: + # Final fallback to current page years + self._populate_years() + def _populate_themes_from_filtered_dataset(self, query_name: str, **filter_context) -> None: """Populate themes list from filtered dataset (all pages, not just current page)""" try: @@ -364,15 +440,21 @@ class BrickSetList(BrickRecordList[BrickSet]): all_sets.list(do_theme=True) themes = set() + years = set() for record in all_sets.records: if hasattr(record, 'theme') and hasattr(record.theme, 'name'): themes.add(record.theme.name) + if hasattr(record, 'fields') and hasattr(record.fields, 'year') and record.fields.year: + years.add(record.fields.year) self.themes = list(themes) self.themes.sort() + self.years = list(years) + self.years.sort(reverse=True) except Exception: # Final fallback to current page themes self._populate_themes() + self._populate_years() def _matches_search(self, record, search_query: str) -> bool: """Check if record matches search query""" @@ -488,6 +570,7 @@ class BrickSetList(BrickRecordList[BrickSet]): **context: Any, ) -> None: themes = set() + years = set() if order is None: order = self.order @@ -504,11 +587,15 @@ class BrickSetList(BrickRecordList[BrickSet]): self.records.append(brickset) if do_theme: themes.add(brickset.theme.name) + if hasattr(brickset, 'fields') and hasattr(brickset.fields, 'year') and brickset.fields.year: + years.add(brickset.fields.year) # Convert the set into a list and sort it if do_theme: self.themes = list(themes) self.themes.sort() + self.years = list(years) + self.years.sort(reverse=True) # Most recent years first # Sets missing a minifigure part def missing_minifigure(self, figure: str, /) -> Self: diff --git a/bricktracker/sql/set/list/all_filtered.sql b/bricktracker/sql/set/list/all_filtered.sql index f080401..44735f0 100644 --- a/bricktracker/sql/set/list/all_filtered.sql +++ b/bricktracker/sql/set/list/all_filtered.sql @@ -11,6 +11,10 @@ AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%') AND "rebrickable_sets"."theme_id" = {{ theme_filter }} {% endif %} +{% if year_filter %} +AND "rebrickable_sets"."year" = {{ year_filter }} +{% endif %} + {% if storage_filter %} AND "bricktracker_sets"."storage" = '{{ storage_filter }}' {% endif %} diff --git a/bricktracker/sql/set/list/consolidated.sql b/bricktracker/sql/set/list/consolidated.sql index f25a814..efd4f8c 100644 --- a/bricktracker/sql/set/list/consolidated.sql +++ b/bricktracker/sql/set/list/consolidated.sql @@ -17,8 +17,9 @@ SELECT GROUP_CONCAT("bricktracker_sets"."id", '|') AS "instance_ids", REPLACE(GROUP_CONCAT(DISTINCT "bricktracker_sets"."storage"), ',', '|') AS "storage", MIN("bricktracker_sets"."purchase_date") AS "purchase_date", + MAX("bricktracker_sets"."purchase_date") AS "purchase_date_max", REPLACE(GROUP_CONCAT(DISTINCT "bricktracker_sets"."purchase_location"), ',', '|') AS "purchase_location", - MIN("bricktracker_sets"."purchase_price") AS "purchase_price" + ROUND(AVG("bricktracker_sets"."purchase_price"), 1) AS "purchase_price" {% block owners %} {% if owners_dict %} {% for column, uuid in owners_dict.items() %} @@ -93,6 +94,10 @@ AND (LOWER("rebrickable_sets"."name") LIKE LOWER('%{{ search_query }}%') AND "rebrickable_sets"."theme_id" = {{ theme_filter }} {% endif %} +{% if year_filter %} +AND "rebrickable_sets"."year" = {{ year_filter }} +{% endif %} + {% if storage_filter %} AND EXISTS ( SELECT 1 FROM "bricktracker_sets" bs_filter diff --git a/bricktracker/sql/statistics/overview.sql b/bricktracker/sql/statistics/overview.sql new file mode 100644 index 0000000..2133244 --- /dev/null +++ b/bricktracker/sql/statistics/overview.sql @@ -0,0 +1,33 @@ +-- Statistics Overview Query +-- Provides statistics for BrickTracker dashboard + +SELECT + -- Basic counts + (SELECT COUNT(*) FROM "bricktracker_sets") AS "total_sets", + (SELECT COUNT(DISTINCT "bricktracker_sets"."set") FROM "bricktracker_sets") AS "unique_sets", + (SELECT COUNT(*) FROM "rebrickable_sets" WHERE "rebrickable_sets"."set" IN (SELECT DISTINCT "set" FROM "bricktracker_sets")) AS "unique_rebrickable_sets", + + -- Parts statistics + (SELECT COUNT(*) FROM "bricktracker_parts") AS "total_part_instances", + (SELECT SUM("bricktracker_parts"."quantity") FROM "bricktracker_parts") AS "total_parts_count", + (SELECT COUNT(DISTINCT "bricktracker_parts"."part") FROM "bricktracker_parts") AS "unique_parts", + (SELECT SUM("bricktracker_parts"."missing") FROM "bricktracker_parts") AS "total_missing_parts", + (SELECT SUM("bricktracker_parts"."damaged") FROM "bricktracker_parts") AS "total_damaged_parts", + + -- Minifigures statistics + (SELECT COUNT(*) FROM "bricktracker_minifigures") AS "total_minifigure_instances", + (SELECT SUM("bricktracker_minifigures"."quantity") FROM "bricktracker_minifigures") AS "total_minifigures_count", + (SELECT COUNT(DISTINCT "bricktracker_minifigures"."figure") FROM "bricktracker_minifigures") AS "unique_minifigures", + + -- Financial statistics + (SELECT COUNT(*) FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL) AS "sets_with_price", + (SELECT ROUND(SUM("purchase_price"), 2) FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL) AS "total_cost", + (SELECT ROUND(AVG("purchase_price"), 2) FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL) AS "average_cost", + (SELECT ROUND(MIN("purchase_price"), 2) FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL) AS "minimum_cost", + (SELECT ROUND(MAX("purchase_price"), 2) FROM "bricktracker_sets" WHERE "purchase_price" IS NOT NULL) AS "maximum_cost", + + -- Storage and location statistics + (SELECT COUNT(DISTINCT "storage") FROM "bricktracker_sets" WHERE "storage" IS NOT NULL) AS "storage_locations_used", + (SELECT COUNT(DISTINCT "purchase_location") FROM "bricktracker_sets" WHERE "purchase_location" IS NOT NULL) AS "purchase_locations_used", + (SELECT COUNT(*) FROM "bricktracker_sets" WHERE "storage" IS NOT NULL) AS "sets_with_storage", + (SELECT COUNT(*) FROM "bricktracker_sets" WHERE "purchase_location" IS NOT NULL) AS "sets_with_purchase_location" \ No newline at end of file diff --git a/bricktracker/sql/statistics/purchase_locations.sql b/bricktracker/sql/statistics/purchase_locations.sql new file mode 100644 index 0000000..81db813 --- /dev/null +++ b/bricktracker/sql/statistics/purchase_locations.sql @@ -0,0 +1,45 @@ +-- Purchase Location Statistics +-- Shows statistics grouped by purchase location + +SELECT + "bricktracker_sets"."purchase_location" AS "location_id", + "bricktracker_metadata_purchase_locations"."name" AS "location_name", + COUNT("bricktracker_sets"."id") AS "set_count", + COUNT(DISTINCT "bricktracker_sets"."set") AS "unique_set_count", + SUM("rebrickable_sets"."number_of_parts") AS "total_parts", + ROUND(AVG("rebrickable_sets"."number_of_parts"), 0) AS "avg_parts_per_set", + -- Financial statistics per purchase location + COUNT(CASE WHEN "bricktracker_sets"."purchase_price" IS NOT NULL THEN 1 END) AS "sets_with_price", + ROUND(SUM("bricktracker_sets"."purchase_price"), 2) AS "total_spent", + ROUND(AVG("bricktracker_sets"."purchase_price"), 2) AS "avg_price", + ROUND(MIN("bricktracker_sets"."purchase_price"), 2) AS "min_price", + ROUND(MAX("bricktracker_sets"."purchase_price"), 2) AS "max_price", + -- Date range statistics + MIN("bricktracker_sets"."purchase_date") AS "first_purchase", + MAX("bricktracker_sets"."purchase_date") AS "latest_purchase", + -- Problem statistics per purchase location + COALESCE(SUM("problem_stats"."missing_parts"), 0) AS "missing_parts", + COALESCE(SUM("problem_stats"."damaged_parts"), 0) AS "damaged_parts", + -- Minifigure statistics per purchase location + 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 "bricktracker_metadata_purchase_locations" ON "bricktracker_sets"."purchase_location" = "bricktracker_metadata_purchase_locations"."id" +LEFT JOIN ( + SELECT + "bricktracker_parts"."id", + SUM("bricktracker_parts"."missing") AS "missing_parts", + SUM("bricktracker_parts"."damaged") AS "damaged_parts" + 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" +WHERE "bricktracker_sets"."purchase_location" IS NOT NULL +GROUP BY "bricktracker_sets"."purchase_location", "bricktracker_metadata_purchase_locations"."name" +ORDER BY "set_count" DESC, "location_name" ASC \ No newline at end of file diff --git a/bricktracker/sql/statistics/purchases_by_year.sql b/bricktracker/sql/statistics/purchases_by_year.sql new file mode 100644 index 0000000..d89eeba --- /dev/null +++ b/bricktracker/sql/statistics/purchases_by_year.sql @@ -0,0 +1,49 @@ +-- Purchases by Year Statistics +-- Shows statistics grouped by purchase year (when you bought the sets) + +SELECT + strftime('%Y', datetime("bricktracker_sets"."purchase_date", 'unixepoch')) AS "purchase_year", + COUNT("bricktracker_sets"."id") AS "total_sets", + COUNT(DISTINCT "bricktracker_sets"."set") AS "unique_sets", + SUM("rebrickable_sets"."number_of_parts") AS "total_parts", + ROUND(AVG("rebrickable_sets"."number_of_parts"), 0) AS "avg_parts_per_set", + -- Financial statistics per purchase year + COUNT(CASE WHEN "bricktracker_sets"."purchase_price" IS NOT NULL THEN 1 END) AS "sets_with_price", + ROUND(SUM("bricktracker_sets"."purchase_price"), 2) AS "total_spent", + ROUND(AVG("bricktracker_sets"."purchase_price"), 2) AS "avg_price_per_set", + ROUND(MIN("bricktracker_sets"."purchase_price"), 2) AS "min_price", + ROUND(MAX("bricktracker_sets"."purchase_price"), 2) AS "max_price", + -- Release year statistics for sets purchased in this year + MIN("rebrickable_sets"."year") AS "oldest_set_year", + MAX("rebrickable_sets"."year") AS "newest_set_year", + ROUND(AVG("rebrickable_sets"."year"), 0) AS "avg_set_release_year", + -- Problem statistics per purchase year + COALESCE(SUM("problem_stats"."missing_parts"), 0) AS "missing_parts", + COALESCE(SUM("problem_stats"."damaged_parts"), 0) AS "damaged_parts", + -- Minifigure statistics per purchase year + COALESCE(SUM("minifigure_stats"."minifigure_count"), 0) AS "total_minifigures", + -- Diversity statistics per purchase year + COUNT(DISTINCT "rebrickable_sets"."theme_id") AS "unique_themes", + COUNT(DISTINCT "bricktracker_sets"."purchase_location") AS "unique_purchase_locations", + -- Monthly statistics within the year + COUNT(DISTINCT strftime('%m', datetime("bricktracker_sets"."purchase_date", 'unixepoch'))) AS "months_with_purchases" +FROM "bricktracker_sets" +INNER JOIN "rebrickable_sets" ON "bricktracker_sets"."set" = "rebrickable_sets"."set" +LEFT JOIN ( + SELECT + "bricktracker_parts"."id", + SUM("bricktracker_parts"."missing") AS "missing_parts", + SUM("bricktracker_parts"."damaged") AS "damaged_parts" + 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" +WHERE "bricktracker_sets"."purchase_date" IS NOT NULL +GROUP BY strftime('%Y', datetime("bricktracker_sets"."purchase_date", 'unixepoch')) +ORDER BY "purchase_year" DESC \ No newline at end of file diff --git a/bricktracker/sql/statistics/sets_by_year.sql b/bricktracker/sql/statistics/sets_by_year.sql new file mode 100644 index 0000000..292cd27 --- /dev/null +++ b/bricktracker/sql/statistics/sets_by_year.sql @@ -0,0 +1,44 @@ +-- Sets by Year Statistics +-- Shows statistics grouped by LEGO set release year + +SELECT + "rebrickable_sets"."year", + COUNT("bricktracker_sets"."id") AS "total_sets", + COUNT(DISTINCT "bricktracker_sets"."set") AS "unique_sets", + SUM("rebrickable_sets"."number_of_parts") AS "total_parts", + ROUND(AVG("rebrickable_sets"."number_of_parts"), 0) AS "avg_parts_per_set", + MIN("rebrickable_sets"."number_of_parts") AS "min_parts", + MAX("rebrickable_sets"."number_of_parts") AS "max_parts", + -- Financial statistics per year (release year) + COUNT(CASE WHEN "bricktracker_sets"."purchase_price" IS NOT NULL THEN 1 END) AS "sets_with_price", + ROUND(SUM("bricktracker_sets"."purchase_price"), 2) AS "total_spent", + ROUND(AVG("bricktracker_sets"."purchase_price"), 2) AS "avg_price_per_set", + ROUND(MIN("bricktracker_sets"."purchase_price"), 2) AS "min_price", + ROUND(MAX("bricktracker_sets"."purchase_price"), 2) AS "max_price", + -- Problem statistics per year + COALESCE(SUM("problem_stats"."missing_parts"), 0) AS "missing_parts", + COALESCE(SUM("problem_stats"."damaged_parts"), 0) AS "damaged_parts", + -- Minifigure statistics per year + COALESCE(SUM("minifigure_stats"."minifigure_count"), 0) AS "total_minifigures", + -- Theme diversity per year + COUNT(DISTINCT "rebrickable_sets"."theme_id") AS "unique_themes" +FROM "bricktracker_sets" +INNER JOIN "rebrickable_sets" ON "bricktracker_sets"."set" = "rebrickable_sets"."set" +LEFT JOIN ( + SELECT + "bricktracker_parts"."id", + SUM("bricktracker_parts"."missing") AS "missing_parts", + SUM("bricktracker_parts"."damaged") AS "damaged_parts" + 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" +WHERE "rebrickable_sets"."year" IS NOT NULL +GROUP BY "rebrickable_sets"."year" +ORDER BY "rebrickable_sets"."year" DESC \ No newline at end of file diff --git a/bricktracker/sql/statistics/storage.sql b/bricktracker/sql/statistics/storage.sql new file mode 100644 index 0000000..2d78849 --- /dev/null +++ b/bricktracker/sql/statistics/storage.sql @@ -0,0 +1,40 @@ +-- Storage Location Statistics +-- Shows statistics grouped by storage location + +SELECT + "bricktracker_sets"."storage" AS "storage_id", + "bricktracker_metadata_storages"."name" AS "storage_name", + COUNT("bricktracker_sets"."id") AS "set_count", + COUNT(DISTINCT "bricktracker_sets"."set") AS "unique_set_count", + SUM("rebrickable_sets"."number_of_parts") AS "total_parts", + ROUND(AVG("rebrickable_sets"."number_of_parts"), 0) AS "avg_parts_per_set", + -- Financial statistics per storage + COUNT(CASE WHEN "bricktracker_sets"."purchase_price" IS NOT NULL THEN 1 END) AS "sets_with_price", + ROUND(SUM("bricktracker_sets"."purchase_price"), 2) AS "total_value", + ROUND(AVG("bricktracker_sets"."purchase_price"), 2) AS "avg_price", + -- Problem statistics per storage + COALESCE(SUM("problem_stats"."missing_parts"), 0) AS "missing_parts", + COALESCE(SUM("problem_stats"."damaged_parts"), 0) AS "damaged_parts", + -- Minifigure statistics per storage + 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 "bricktracker_metadata_storages" ON "bricktracker_sets"."storage" = "bricktracker_metadata_storages"."id" +LEFT JOIN ( + SELECT + "bricktracker_parts"."id", + SUM("bricktracker_parts"."missing") AS "missing_parts", + SUM("bricktracker_parts"."damaged") AS "damaged_parts" + 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" +WHERE "bricktracker_sets"."storage" IS NOT NULL +GROUP BY "bricktracker_sets"."storage", "bricktracker_metadata_storages"."name" +ORDER BY "set_count" DESC, "storage_name" ASC \ No newline at end of file diff --git a/bricktracker/sql/statistics/themes.sql b/bricktracker/sql/statistics/themes.sql new file mode 100644 index 0000000..082e38c --- /dev/null +++ b/bricktracker/sql/statistics/themes.sql @@ -0,0 +1,39 @@ +-- Theme Distribution Statistics +-- Shows statistics grouped by theme + +SELECT + "rebrickable_sets"."theme_id", + COUNT("bricktracker_sets"."id") AS "set_count", + COUNT(DISTINCT "bricktracker_sets"."set") AS "unique_set_count", + SUM("rebrickable_sets"."number_of_parts") AS "total_parts", + ROUND(AVG("rebrickable_sets"."number_of_parts"), 0) AS "avg_parts_per_set", + MIN("rebrickable_sets"."year") AS "earliest_year", + MAX("rebrickable_sets"."year") AS "latest_year", + -- Financial statistics per theme + COUNT(CASE WHEN "bricktracker_sets"."purchase_price" IS NOT NULL THEN 1 END) AS "sets_with_price", + ROUND(SUM("bricktracker_sets"."purchase_price"), 2) AS "total_spent", + 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", + -- 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 ( + SELECT + "bricktracker_parts"."id", + SUM("bricktracker_parts"."missing") AS "missing_parts", + SUM("bricktracker_parts"."damaged") AS "damaged_parts" + 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/statistics.py b/bricktracker/statistics.py new file mode 100644 index 0000000..fbbb3c6 --- /dev/null +++ b/bricktracker/statistics.py @@ -0,0 +1,132 @@ +""" +Statistics module for BrickTracker +Provides statistics and analytics functionality +""" + +import logging +from typing import Any + +from .sql import BrickSQL +from .theme_list import BrickThemeList + +logger = logging.getLogger(__name__) + + +class BrickStatistics: + """Main statistics class providing overview and detailed statistics""" + + def __init__(self): + self.sql = BrickSQL() + + def get_overview(self) -> dict[str, Any]: + """Get overview statistics""" + result = self.sql.fetchone('statistics/overview') + if result: + return dict(result) + return {} + + def get_theme_statistics(self) -> list[dict[str, Any]]: + """Get statistics grouped by theme with theme names""" + results = self.sql.fetchall('statistics/themes') + + # Load theme list to get theme names + theme_list = BrickThemeList() + + statistics = [] + for row in results: + stat = dict(row) + # Add theme name from theme list + theme = theme_list.get(stat['theme_id']) + stat['theme_name'] = theme.name if theme else f"Theme {stat['theme_id']}" + statistics.append(stat) + + return statistics + + def get_storage_statistics(self) -> list[dict[str, Any]]: + """Get statistics grouped by storage location""" + results = self.sql.fetchall('statistics/storage') + return [dict(row) for row in results] + + def get_purchase_location_statistics(self) -> list[dict[str, Any]]: + """Get statistics grouped by purchase location""" + results = self.sql.fetchall('statistics/purchase_locations') + return [dict(row) for row in results] + + def get_financial_summary(self) -> dict[str, Any]: + """Get financial summary from overview statistics""" + overview = self.get_overview() + return { + 'total_cost': overview.get('total_cost', 0), + 'average_cost': overview.get('average_cost', 0), + 'minimum_cost': overview.get('minimum_cost', 0), + 'maximum_cost': overview.get('maximum_cost', 0), + 'sets_with_price': overview.get('sets_with_price', 0), + 'total_sets': overview.get('total_sets', 0), + 'percentage_with_price': round( + (overview.get('sets_with_price', 0) / max(overview.get('total_sets', 1), 1)) * 100, 1 + ) + } + + def get_collection_summary(self) -> dict[str, Any]: + """Get collection summary from overview statistics""" + overview = self.get_overview() + return { + 'total_sets': overview.get('total_sets', 0), + 'unique_sets': overview.get('unique_sets', 0), + 'total_parts_count': overview.get('total_parts_count', 0), + 'unique_parts': overview.get('unique_parts', 0), + 'total_minifigures_count': overview.get('total_minifigures_count', 0), + 'unique_minifigures': overview.get('unique_minifigures', 0), + 'total_missing_parts': overview.get('total_missing_parts', 0), + 'total_damaged_parts': overview.get('total_damaged_parts', 0), + 'storage_locations_used': overview.get('storage_locations_used', 0), + 'purchase_locations_used': overview.get('purchase_locations_used', 0) + } + + def get_sets_by_year_statistics(self) -> list[dict[str, Any]]: + """Get statistics grouped by LEGO set release year""" + results = self.sql.fetchall('statistics/sets_by_year') + return [dict(row) for row in results] + + def get_purchases_by_year_statistics(self) -> list[dict[str, Any]]: + """Get statistics grouped by purchase year""" + results = self.sql.fetchall('statistics/purchases_by_year') + return [dict(row) for row in results] + + def get_year_summary(self) -> dict[str, Any]: + """Get year-based summary statistics""" + sets_by_year = self.get_sets_by_year_statistics() + purchases_by_year = self.get_purchases_by_year_statistics() + + # Calculate summary metrics + years_represented = len(sets_by_year) + years_with_purchases = len(purchases_by_year) + + # Find peak year for collection (by set count) + peak_collection_year = None + max_sets_in_year = 0 + if sets_by_year: + peak_year_data = max(sets_by_year, key=lambda x: x['total_sets']) + peak_collection_year = peak_year_data['year'] + max_sets_in_year = peak_year_data['total_sets'] + + # Find peak spending year + peak_spending_year = None + max_spending = 0 + if purchases_by_year: + spending_years = [y for y in purchases_by_year if y.get('total_spent')] + if spending_years: + peak_spending_data = max(spending_years, key=lambda x: x['total_spent'] or 0) + peak_spending_year = peak_spending_data['purchase_year'] + max_spending = peak_spending_data['total_spent'] + + return { + 'years_represented': years_represented, + 'years_with_purchases': years_with_purchases, + 'peak_collection_year': peak_collection_year, + 'max_sets_in_year': max_sets_in_year, + 'peak_spending_year': peak_spending_year, + 'max_spending': max_spending, + 'oldest_set_year': min([y['year'] for y in sets_by_year]) if sets_by_year else None, + 'newest_set_year': max([y['year'] for y in sets_by_year]) if sets_by_year else None + } \ No newline at end of file diff --git a/bricktracker/views/set.py b/bricktracker/views/set.py index 3fa4001..e51886c 100644 --- a/bricktracker/views/set.py +++ b/bricktracker/views/set.py @@ -46,6 +46,7 @@ def list() -> str: purchase_location_filter = request.args.get('purchase_location') storage_filter = request.args.get('storage') tag_filter = request.args.get('tag') + year_filter = request.args.get('year') # Get pagination configuration per_page, is_mobile = get_pagination_config('sets') @@ -65,6 +66,7 @@ def list() -> str: purchase_location_filter=purchase_location_filter, storage_filter=storage_filter, tag_filter=tag_filter, + year_filter=year_filter, use_consolidated=current_app.config['SETS_CONSOLIDATION'] ) @@ -77,6 +79,16 @@ def list() -> str: sets = BrickSetList().all() pagination_context = None + # Convert theme ID to theme name for dropdown display if needed + display_theme_filter = theme_filter + if theme_filter and theme_filter.isdigit(): + # Theme filter is an ID, convert to name for dropdown + # Create a fresh BrickSetList instance for theme conversion + converter = BrickSetList() + theme_name = converter._theme_id_to_name(theme_filter) + if theme_name: + display_theme_filter = theme_name + template_context = { 'collection': sets, 'search_query': search_query, @@ -84,11 +96,12 @@ def list() -> str: 'current_sort': sort_field, 'current_order': sort_order, 'current_status_filter': status_filter, - 'current_theme_filter': theme_filter, + 'current_theme_filter': display_theme_filter, 'current_owner_filter': owner_filter, 'current_purchase_location_filter': purchase_location_filter, 'current_storage_filter': storage_filter, 'current_tag_filter': tag_filter, + 'current_year_filter': year_filter, 'brickset_statuses': BrickSetStatusList.list(), **set_metadata_lists(as_class=True) } diff --git a/bricktracker/views/statistics.py b/bricktracker/views/statistics.py new file mode 100644 index 0000000..7f06b8e --- /dev/null +++ b/bricktracker/views/statistics.py @@ -0,0 +1,189 @@ +""" +Statistics views for BrickTracker +Provides statistics and analytics pages +""" + +import logging + +from flask import Blueprint, render_template, request, url_for, redirect, current_app +from werkzeug.wrappers.response import Response + +from .exceptions import exception_handler +from ..statistics import BrickStatistics + +logger = logging.getLogger(__name__) + +statistics_page = Blueprint('statistics', __name__, url_prefix='/statistics') + + +@statistics_page.route('/', methods=['GET']) +@exception_handler(__file__) +def overview() -> str: + """Statistics overview page with metrics""" + + stats = BrickStatistics() + + # Get all statistics data + overview_stats = stats.get_overview() + theme_stats = stats.get_theme_statistics() + storage_stats = stats.get_storage_statistics() + purchase_location_stats = stats.get_purchase_location_statistics() + financial_summary = stats.get_financial_summary() + collection_summary = stats.get_collection_summary() + sets_by_year_stats = stats.get_sets_by_year_statistics() + 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') + + # If a filter is applied, redirect to sets page with appropriate filters + if filter_type and filter_value: + return redirect_to_filtered_sets(filter_type, filter_value) + + return render_template( + 'statistics.html', + overview=overview_stats, + theme_statistics=theme_stats, + storage_statistics=storage_stats, + purchase_location_statistics=purchase_location_stats, + financial_summary=financial_summary, + collection_summary=collection_summary, + 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" + ) + + +def redirect_to_filtered_sets(filter_type: str, filter_value: str) -> Response: + """Redirect to sets page with appropriate filters based on statistics click""" + + # Map filter types to sets page parameters + filter_mapping = { + 'theme': {'theme': filter_value}, + 'storage': {'storage': filter_value}, + 'purchase_location': {'purchase_location': filter_value}, + 'has_price': {'has_price': '1'} if filter_value == '1' else {}, + 'missing_parts': {'status': 'has-missing'}, + 'damaged_parts': {'status': 'has-damaged'}, + 'has_storage': {'status': 'has-storage'}, + 'no_storage': {'status': '-has-storage'}, + } + + # Get the appropriate filter parameters + filter_params = filter_mapping.get(filter_type, {}) + + if filter_params: + return redirect(url_for('set.list', **filter_params)) + else: + # Default fallback to sets page + return redirect(url_for('set.list')) + + +@statistics_page.route('/themes', methods=['GET']) +@exception_handler(__file__) +def themes() -> str: + """Detailed theme statistics page""" + + stats = BrickStatistics() + theme_stats = stats.get_theme_statistics() + + return render_template( + 'statistics_themes.html', + theme_statistics=theme_stats, + title="Theme Statistics" + ) + + +@statistics_page.route('/storage', methods=['GET']) +@exception_handler(__file__) +def storage() -> str: + """Detailed storage statistics page""" + + stats = BrickStatistics() + storage_stats = stats.get_storage_statistics() + + return render_template( + 'statistics_storage.html', + storage_statistics=storage_stats, + title="Storage Statistics" + ) + + +@statistics_page.route('/purchase-locations', methods=['GET']) +@exception_handler(__file__) +def purchase_locations() -> str: + """Detailed purchase location statistics page""" + + stats = BrickStatistics() + purchase_stats = stats.get_purchase_location_statistics() + + return render_template( + 'statistics_purchase_locations.html', + purchase_location_statistics=purchase_stats, + title="Purchase Location Statistics" + ) + + +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/grid/grid.js b/static/scripts/grid/grid.js index d0b9dab..1b8bd82 100644 --- a/static/scripts/grid/grid.js +++ b/static/scripts/grid/grid.js @@ -17,7 +17,13 @@ class BrickGrid { } } +// Global grid instances storage +window.gridInstances = {}; + // Helper to setup the grids const setup_grids = () => document.querySelectorAll('*[data-grid="true"]').forEach( - el => new BrickGrid(el) + el => { + const grid = new BrickGrid(el); + window.gridInstances[el.id] = grid; + } ); diff --git a/static/scripts/sets.js b/static/scripts/sets.js index 84fa4e9..861bc62 100644 --- a/static/scripts/sets.js +++ b/static/scripts/sets.js @@ -68,6 +68,9 @@ document.addEventListener("DOMContentLoaded", () => { // Setup filter dropdowns for pagination mode setupPaginationFilterDropdowns(); + // Initialize filter dropdowns from URL parameters + initializeFilterDropdowns(); + // Initialize sort button states and icons for pagination mode const urlParams = new URLSearchParams(window.location.search); const currentSort = urlParams.get('sort'); @@ -75,8 +78,9 @@ document.addEventListener("DOMContentLoaded", () => { window.initializeSortButtonStates(currentSort, currentOrder); } else { - // ORIGINAL MODE - Grid search functionality is handled by existing grid scripts - // No additional setup needed here + // ORIGINAL MODE - Client-side filtering with grid scripts + // Initialize filter dropdowns from URL parameters for client-side mode too + initializeClientSideFilterDropdowns(); } } }); @@ -131,6 +135,195 @@ function setupPaginationSortButtons() { } } +function initializeFilterDropdowns() { + // Set filter dropdown values from URL parameters + const urlParams = new URLSearchParams(window.location.search); + + // Set each filter dropdown value if the parameter exists + const yearParam = urlParams.get('year'); + if (yearParam) { + const yearDropdown = document.getElementById('grid-year'); + if (yearDropdown) { + yearDropdown.value = yearParam; + } + } + + const themeParam = urlParams.get('theme'); + if (themeParam) { + const themeDropdown = document.getElementById('grid-theme'); + if (themeDropdown) { + // Try to set the theme value directly first (for theme names) + themeDropdown.value = themeParam; + + // If that didn't work and the param is numeric (theme ID), + // try to find the corresponding theme name by looking at cards + if (themeDropdown.value !== themeParam && /^\d+$/.test(themeParam)) { + // Look for a card with this theme ID and get its theme name + const cardWithTheme = document.querySelector(`[data-theme-id="${themeParam}"]`); + if (cardWithTheme) { + const themeName = cardWithTheme.getAttribute('data-theme'); + if (themeName) { + themeDropdown.value = themeName; + } + } + } + } + } + + const statusParam = urlParams.get('status'); + if (statusParam) { + const statusDropdown = document.getElementById('grid-status'); + if (statusDropdown) { + statusDropdown.value = statusParam; + } + } + + const ownerParam = urlParams.get('owner'); + if (ownerParam) { + const ownerDropdown = document.getElementById('grid-owner'); + if (ownerDropdown) { + ownerDropdown.value = ownerParam; + } + } + + const purchaseLocationParam = urlParams.get('purchase_location'); + if (purchaseLocationParam) { + const purchaseLocationDropdown = document.getElementById('grid-purchase-location'); + if (purchaseLocationDropdown) { + purchaseLocationDropdown.value = purchaseLocationParam; + } + } + + const storageParam = urlParams.get('storage'); + if (storageParam) { + const storageDropdown = document.getElementById('grid-storage'); + if (storageDropdown) { + storageDropdown.value = storageParam; + } + } + + const tagParam = urlParams.get('tag'); + if (tagParam) { + const tagDropdown = document.getElementById('grid-tag'); + if (tagDropdown) { + tagDropdown.value = tagParam; + } + } +} + +function initializeClientSideFilterDropdowns() { + // Set filter dropdown values from URL parameters and trigger filtering for client-side mode + const urlParams = new URLSearchParams(window.location.search); + let needsFiltering = false; + + // Check if we have any filter parameters to avoid flash of all content + const hasFilterParams = urlParams.has('year') || urlParams.has('theme') || urlParams.has('storage') || urlParams.has('purchase_location'); + + // If we have filter parameters, temporarily hide the grid to prevent flash + if (hasFilterParams) { + const gridElement = document.querySelector('#grid'); + if (gridElement && gridElement.getAttribute('data-grid') === 'true') { + gridElement.style.opacity = '0'; + } + } + + // Set year filter if parameter exists + const yearParam = urlParams.get('year'); + if (yearParam) { + const yearDropdown = document.getElementById('grid-year'); + if (yearDropdown) { + yearDropdown.value = yearParam; + needsFiltering = true; + } + } + + // Set theme filter - handle both theme names and theme IDs + const themeParam = urlParams.get('theme'); + if (themeParam) { + const themeDropdown = document.getElementById('grid-theme'); + if (themeDropdown) { + if (/^\d+$/.test(themeParam)) { + // Theme parameter is an ID, need to convert to theme name by looking at cards + const themeNameFromId = findThemeNameById(themeParam); + if (themeNameFromId) { + themeDropdown.value = themeNameFromId; + needsFiltering = true; + } + } else { + // Theme parameter is already a name + themeDropdown.value = themeParam.toLowerCase(); + needsFiltering = true; + } + } + } + + // Set storage filter if parameter exists + const storageParam = urlParams.get('storage'); + if (storageParam) { + const storageDropdown = document.getElementById('grid-storage'); + if (storageDropdown) { + storageDropdown.value = storageParam; + needsFiltering = true; + } + } + + // Set purchase location filter if parameter exists + const purchaseLocationParam = urlParams.get('purchase_location'); + if (purchaseLocationParam) { + const purchaseLocationDropdown = document.getElementById('grid-purchase-location'); + if (purchaseLocationDropdown) { + purchaseLocationDropdown.value = purchaseLocationParam; + needsFiltering = true; + } + } + + // Trigger filtering if any parameters were set + if (needsFiltering) { + // Try to trigger filtering immediately + const tryToFilter = () => { + const gridElement = document.querySelector('#grid'); + if (gridElement && gridElement.getAttribute('data-grid') === 'true' && window.gridInstances) { + const gridInstance = window.gridInstances[gridElement.id]; + if (gridInstance && gridInstance.filter) { + // This is client-side mode, trigger the filter directly + gridInstance.filter.filter(); + + // Show the grid again after filtering + if (hasFilterParams) { + gridElement.style.opacity = '1'; + gridElement.style.transition = 'opacity 0.2s ease-in-out'; + } + + return true; + } + } + return false; + }; + + // Try filtering immediately + if (!tryToFilter()) { + // If not ready, try again with a shorter delay + setTimeout(() => { + if (!tryToFilter()) { + // Final attempt with longer delay + setTimeout(tryToFilter, 100); + } + }, 50); + } + } +} + +function findThemeNameById(themeId) { + // Look through all cards to find the theme name for this theme ID + const cards = document.querySelectorAll('.card[data-theme-id]'); + for (const card of cards) { + if (card.getAttribute('data-theme-id') === themeId) { + return card.getAttribute('data-theme'); + } + } + return null; +} + function setupPaginationFilterDropdowns() { // Filter dropdown functionality for pagination mode const filterDropdowns = document.querySelectorAll('#grid-filter select'); @@ -147,6 +340,7 @@ function setupPaginationFilterDropdowns() { // Get all filter values const statusFilter = document.getElementById('grid-status')?.value || ''; const themeFilter = document.getElementById('grid-theme')?.value || ''; + const yearFilter = document.getElementById('grid-year')?.value || ''; const ownerFilter = document.getElementById('grid-owner')?.value || ''; const purchaseLocationFilter = document.getElementById('grid-purchase-location')?.value || ''; const storageFilter = document.getElementById('grid-storage')?.value || ''; @@ -165,6 +359,12 @@ function setupPaginationFilterDropdowns() { currentUrl.searchParams.delete('theme'); } + if (yearFilter) { + currentUrl.searchParams.set('year', yearFilter); + } else { + currentUrl.searchParams.delete('year'); + } + if (ownerFilter) { currentUrl.searchParams.set('owner', ownerFilter); } else { 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 @@ +