From c19aaf2f4b0d8ea1c44bd3956d489437798bd269 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Thu, 13 Feb 2025 16:44:30 -0500 Subject: [PATCH] Implement ride count fields with real-time annotations; update filters and templates for consistency and accuracy --- memory-bank/decisions/park_count_fields.md | 59 ++ memory-bank/decisions/ride_count_field.md | 39 ++ parks/filters.py | 46 +- parks/static/parks/css/search.css | 126 +++- parks/static/parks/js/search.js | 69 +++ parks/templates/parks/park_list.html | 177 +++--- parks/templates/parks/partials/park_card.html | 58 -- .../parks/partials/park_list_item.html | 97 ++- parks/views.py | 575 +++++++++++++----- static/css/tailwind.css | 109 +++- 10 files changed, 988 insertions(+), 367 deletions(-) create mode 100644 memory-bank/decisions/park_count_fields.md create mode 100644 memory-bank/decisions/ride_count_field.md create mode 100644 parks/static/parks/js/search.js delete mode 100644 parks/templates/parks/partials/park_card.html diff --git a/memory-bank/decisions/park_count_fields.md b/memory-bank/decisions/park_count_fields.md new file mode 100644 index 00000000..efeeda1c --- /dev/null +++ b/memory-bank/decisions/park_count_fields.md @@ -0,0 +1,59 @@ +# Park Count Fields Implementation + +## Context +While implementing park views, we encountered errors where `ride_count` and `coaster_count` annotations conflicted with existing model fields of the same names. Additionally, we discovered inconsistencies in how these counts were being used across different views. + +## Decision +We decided to use both approaches but with distinct names: + +1. **Model Fields**: + - `ride_count`: Stored count of all rides + - `coaster_count`: Stored count of roller coasters + - Used in models and database schema + - Required for backward compatibility + +2. **Annotations**: + - `current_ride_count`: Real-time count of all rides + - `current_coaster_count`: Real-time count of roller coasters + - Provide accurate, up-to-date counts + - Used in templates and filters + +This approach allows us to: +- Maintain existing database schema +- Show accurate, real-time counts in the UI +- Avoid name conflicts between fields and annotations +- Keep consistent naming pattern for both types of counts + +## Implementation +1. Views: + - Added base queryset method with annotations + - Used 'current_' prefix for annotated counts + - Ensured all views use the base queryset + +2. Filters: + - Updated filter fields to use annotated counts + - Configured filter class to always use base queryset + - Maintained filter functionality with new field names + +3. Templates: + - Updated templates to use computed counts + +## Why This Pattern +1. **Consistency**: Using the 'current_' prefix clearly indicates which values are computed in real-time +2. **Compatibility**: Maintains support for existing code that relies on the stored fields +3. **Flexibility**: Allows gradual migration from stored to computed counts if desired +4. **Performance Option**: Keeps the option to use stored counts for expensive queries + +## Future Considerations +We might want to: +1. Add periodic tasks to sync stored counts with computed values +2. Consider deprecating stored fields if they're not needed for performance +3. Add validation to ensure stored counts stay in sync with reality +4. Create a management command to update stored counts + +## Related Files +- parks/models.py +- parks/views.py +- parks/filters.py +- parks/templates/parks/partials/park_list_item.html +- parks/tests/test_filters.py \ No newline at end of file diff --git a/memory-bank/decisions/ride_count_field.md b/memory-bank/decisions/ride_count_field.md new file mode 100644 index 00000000..2e10067c --- /dev/null +++ b/memory-bank/decisions/ride_count_field.md @@ -0,0 +1,39 @@ +# Ride Count Field Implementation + +## Context +While implementing park views, we encountered an error where a `ride_count` annotation conflicted with an existing model field of the same name. This raised a question about how to handle real-time ride counts versus stored counts. + +## Decision +We decided to use both approaches but with distinct names: + +1. **Model Field (`ride_count`)**: + - Kept the original field for backward compatibility + - Used in test fixtures and filtering system + - Can serve as a cached/denormalized value + +2. **Annotation (`current_ride_count`)**: + - Added new annotation with a distinct name + - Provides real-time count of rides + - Used in templates for display purposes + +This approach allows us to: +- Maintain existing functionality in tests and filters +- Show accurate, real-time counts in the UI +- Avoid name conflicts between fields and annotations + +## Implementation +- Kept the `ride_count` IntegerField in the Park model +- Added `current_ride_count = Count('rides', distinct=True)` annotation in views +- Updated templates to use `current_ride_count` for display + +## Future Considerations +We might want to: +1. Add a periodic task to sync the stored `ride_count` with the computed value +2. Consider deprecating the stored field if it's not needed for performance +3. Add validation to ensure the stored count stays in sync with reality + +## Related Files +- parks/models.py +- parks/views.py +- parks/templates/parks/partials/park_list_item.html +- parks/tests/test_filters.py \ No newline at end of file diff --git a/parks/filters.py b/parks/filters.py index a172fe44..f45f40de 100644 --- a/parks/filters.py +++ b/parks/filters.py @@ -12,6 +12,7 @@ from django_filters import ( BooleanFilter ) from .models import Park +from .views import get_base_park_queryset from companies.models import Company def validate_positive_integer(value): @@ -24,7 +25,7 @@ def validate_positive_integer(value): except (TypeError, ValueError): raise ValidationError(_('Invalid number format')) -class ParkFilter(FilterSet, LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin): +class ParkFilter(LocationFilterMixin, RatingFilterMixin, DateRangeFilterMixin, FilterSet): """Filter set for parks with search and validation capabilities""" class Meta: model = Park @@ -50,12 +51,12 @@ class ParkFilter(FilterSet, LocationFilterMixin, RatingFilterMixin, DateRangeFil # Numeric filters min_rides = NumberFilter( - field_name='ride_count', + field_name='current_ride_count', lookup_expr='gte', validators=[validate_positive_integer] ) min_coasters = NumberFilter( - field_name='coaster_count', + field_name='current_coaster_count', lookup_expr='gte', validators=[validate_positive_integer] ) @@ -93,22 +94,25 @@ class ParkFilter(FilterSet, LocationFilterMixin, RatingFilterMixin, DateRangeFil def filter_has_owner(self, queryset, name, value): """Filter parks based on whether they have an owner""" return queryset.filter(owner__isnull=not value) +@property +def qs(self): + """Override qs property to ensure we always use base queryset with annotations""" + if not hasattr(self, '_qs'): + # Start with the base queryset that includes annotations + base_qs = get_base_park_queryset() + + if not self.is_bound: + self._qs = base_qs + return self._qs + + if not self.form.is_valid(): + self._qs = base_qs.none() + return self._qs - @property - def qs(self): - """Override qs property to ensure we always start with all parks""" - if not hasattr(self, '_qs'): - if not self.is_bound: - self._qs = self.queryset.all() - return self._qs - if not self.form.is_valid(): - self._qs = self.queryset.none() - return self._qs - - self._qs = self.queryset.all() - for name, value in self.form.cleaned_data.items(): - if value in [None, '', 0] and name not in ['has_owner']: - continue - self._qs = self.filters[name].filter(self._qs, value) - self._qs = self._qs.distinct() - return self._qs \ No newline at end of file + self._qs = base_qs + for name, value in self.form.cleaned_data.items(): + if value in [None, '', 0] and name not in ['has_owner']: + continue + self._qs = self.filters[name].filter(self._qs, value) + self._qs = self._qs.distinct() + return self._qs \ No newline at end of file diff --git a/parks/static/parks/css/search.css b/parks/static/parks/css/search.css index 15cfba9a..f887f5ae 100644 --- a/parks/static/parks/css/search.css +++ b/parks/static/parks/css/search.css @@ -1,62 +1,134 @@ -/* Loading indicator */ +/* Loading states */ +.htmx-request .htmx-indicator { + opacity: 1; +} +.htmx-request.htmx-indicator { + opacity: 1; +} .htmx-indicator { opacity: 0; transition: opacity 200ms ease-in-out; } -.htmx-request .htmx-indicator { +/* Results container transitions */ +#park-results { + transition: opacity 200ms ease-in-out; +} +.htmx-request #park-results { + opacity: 0.7; +} +.htmx-settling #park-results { opacity: 1; } -/* Search results container */ -#search-results { - margin-top: 0.5rem; - border: 1px solid #e5e7eb; - border-radius: 0.5rem; +/* Grid/List transitions */ +.park-card { + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + position: relative; background-color: white; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - max-height: 400px; - overflow-y: auto; + border-radius: 0.5rem; + border: 1px solid #e5e7eb; } -#search-results:empty { - display: none; +/* Grid view styles */ +.park-card[data-view-mode="grid"] { + display: flex; + flex-direction: column; +} +.park-card[data-view-mode="grid"]:hover { + transform: translateY(-2px); + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); } -/* Search result items */ -#search-results .border-b { - border-color: #e5e7eb; +/* List view styles */ +.park-card[data-view-mode="list"] { + display: flex; + gap: 1rem; + padding: 1rem; +} +.park-card[data-view-mode="list"]:hover { + background-color: #f9fafb; } -#search-results a { - display: block; - transition: background-color 150ms ease-in-out; +/* Image containers */ +.park-card .image-container { + position: relative; + overflow: hidden; +} +.park-card[data-view-mode="grid"] .image-container { + aspect-ratio: 16 / 9; + width: 100%; +} +.park-card[data-view-mode="list"] .image-container { + width: 6rem; + height: 6rem; + flex-shrink: 0; } -#search-results a:hover { - background-color: #f3f4f6; +/* Content */ +.park-card .content { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; /* Enables text truncation in flex child */ } -/* Dark mode adjustments */ +/* Status badges */ +.park-card .status-badge { + transition: all 150ms ease-in-out; +} +.park-card:hover .status-badge { + transform: scale(1.05); +} + +/* Images */ +.park-card img { + transition: transform 200ms ease-in-out; + object-fit: cover; + width: 100%; + height: 100%; +} +.park-card:hover img { + transform: scale(1.05); +} + +/* Placeholders for missing images */ +.park-card .placeholder { + background: linear-gradient(110deg, #ececec 8%, #f5f5f5 18%, #ececec 33%); + background-size: 200% 100%; + animation: shimmer 1.5s linear infinite; +} + +@keyframes shimmer { + to { + background-position: 200% center; + } +} + +/* Dark mode */ @media (prefers-color-scheme: dark) { - #search-results { + .park-card { background-color: #1f2937; border-color: #374151; } - #search-results .text-gray-900 { + .park-card[data-view-mode="list"]:hover { + background-color: #374151; + } + + .park-card .text-gray-900 { color: #f3f4f6; } - #search-results .text-gray-500 { + .park-card .text-gray-500 { color: #9ca3af; } - #search-results .border-b { - border-color: #374151; + .park-card .placeholder { + background: linear-gradient(110deg, #2d3748 8%, #374151 18%, #2d3748 33%); } - #search-results a:hover { + .park-card[data-view-mode="list"]:hover { background-color: #374151; } } \ No newline at end of file diff --git a/parks/static/parks/js/search.js b/parks/static/parks/js/search.js new file mode 100644 index 00000000..66895b08 --- /dev/null +++ b/parks/static/parks/js/search.js @@ -0,0 +1,69 @@ +// Handle view mode persistence across HTMX requests +document.addEventListener('htmx:configRequest', function(evt) { + // Preserve view mode + const parkResults = document.getElementById('park-results'); + if (parkResults) { + const viewMode = parkResults.getAttribute('data-view-mode'); + if (viewMode) { + evt.detail.parameters['view_mode'] = viewMode; + } + } + + // Preserve search terms + const searchInput = document.getElementById('search'); + if (searchInput && searchInput.value) { + evt.detail.parameters['search'] = searchInput.value; + } +}); + +// Handle loading states +document.addEventListener('htmx:beforeRequest', function(evt) { + const target = evt.detail.target; + if (target) { + target.classList.add('htmx-requesting'); + } +}); + +document.addEventListener('htmx:afterRequest', function(evt) { + const target = evt.detail.target; + if (target) { + target.classList.remove('htmx-requesting'); + } +}); + +// Handle history navigation +document.addEventListener('htmx:historyRestore', function(evt) { + const parkResults = document.getElementById('park-results'); + if (parkResults && evt.detail.path) { + const url = new URL(evt.detail.path, window.location.origin); + const viewMode = url.searchParams.get('view_mode'); + if (viewMode) { + parkResults.setAttribute('data-view-mode', viewMode); + } + } +}); + +// Initialize lazy loading for images +function initializeLazyLoading(container) { + if (!('IntersectionObserver' in window)) return; + + const imageObserver = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const img = entry.target; + img.src = img.dataset.src; + img.removeAttribute('data-src'); + imageObserver.unobserve(img); + } + }); + }); + + container.querySelectorAll('img[data-src]').forEach(img => { + imageObserver.observe(img); + }); +} + +// Initialize lazy loading after HTMX content swaps +document.addEventListener('htmx:afterSwap', function(evt) { + initializeLazyLoading(evt.detail.target); +}); \ No newline at end of file diff --git a/parks/templates/parks/park_list.html b/parks/templates/parks/park_list.html index ecf867f9..7bb94828 100644 --- a/parks/templates/parks/park_list.html +++ b/parks/templates/parks/park_list.html @@ -2,136 +2,97 @@ {% load static %} {% load filter_utils %} -{% block page_title %}Parks{% endblock %} +{% block title %}Parks - ThrillWiki{% endblock %} -{% block filter_errors %} - {% if filter.errors %} -