Enhance money parsing, profile stats, and user experience (#1057)

* Enhance money parsing and normalization in BackupViewSet

* Refactor money parsing in BackupViewSet for schema safety and enhance profile statistics display with new metrics

* Improve throttling handling in auth hooks to enhance user experience during high-load scenarios

* fix(deps):  update countries-states-cities-database v3.1 (#1047)

update countries-states-cities-database to fixed some cities error

* fix: update appVersion to v0.12.0-main-031526

* feat: enhance CategoryFilterDropdown with event dispatching and URL synchronization. Fixes [BUG] Category Filter not working in v0.12.0
Fixes #990

* feat(profile): add record holders for activities and display details in profile page

* feat: restructure issue templates and enhance contribution guidelines

* Potential fix for code scanning alert no. 50: Workflow does not contain permissions

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

* Potential fix for code scanning alert no. 51: Workflow does not contain permissions

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: 橙 <chengjunchao@hotmail.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
Sean Morley
2026-03-16 10:35:38 -04:00
committed by GitHub
parent 7ca58202c9
commit 9a88f7b093
21 changed files with 2369 additions and 1354 deletions

View File

@@ -1,49 +0,0 @@
---
name: Bug report
about: Found something not working as expected? Please file a detailed bug report so we can fix it quickly!
title: "[BUG] <short description>"
labels: bug
assignees: ""
---
🛑 **Note**: Please search existing issues before filing a new one. This helps avoid duplicates and ensures we can address your concern efficiently!
## 🐞 Bug Description
A clear and concise description of the issue. What went wrong?
## 🔄 Steps to Reproduce
Steps to reproduce the behavior:
1.
2.
3.
4. Observe the error
## ✅ Expected Behavior
What did you expect AdventureLog to do?
## 📸 Screenshots / Logs
- Attach screenshots if it helps explain the problem.
- If possible, include **relevant log excerpts** (be sure to remove sensitive info).
## 🐳 Environment Details
- **Platform:** (Docker, Synology, Proxmox, TrueNAS, Unraid, etc.)
- **Install Method:** (Docker Compose, Quick Install Script, Manual, etc.)
- **AdventureLog Version:** (e.g., v0.12.0)
- **Reverse Proxy:** (e.g., Nginx, Traefik, Caddy, etc. or None)
If using Docker and the issue is related to the container, it may be helpful to include your `docker-compose.yml` or relevant variables below.
⚠️ Please remember to redact any sensitive information such as passwords or API keys.
## 📎 Additional Context
Anything else that might be useful (custom configuration, network setup, database version, etc.)?
---
Thank you for taking the time to report this issue! We appreciate your help in making AdventureLog better. Well look into it as soon as we can. 🙌

113
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,113 @@
name: Bug report
description: Found something not working as expected? File a detailed bug report so we can fix it quickly.
title: "[BUG] "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Please search existing issues before filing a new one. This helps avoid duplicates and ensures we can address your concern efficiently.
- type: textarea
id: bug-description
attributes:
label: Bug Description
description: A clear and concise description of the issue. What went wrong?
placeholder: Describe what happened.
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
description: Provide exact steps so we can reliably reproduce the issue.
placeholder: |
1. Go to ...
2. Click on ...
3. Enter ...
4. Observe the error
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected Behavior
description: What did you expect AdventureLog to do?
placeholder: Describe the expected result.
validations:
required: true
- type: textarea
id: screenshots-logs
attributes:
label: Screenshots / Logs
description: |
Attach screenshots if helpful.
Include relevant logs (remove sensitive information).
placeholder: Paste logs and add image links here.
validations:
required: false
- type: dropdown
id: platform
attributes:
label: Platform
description: Where are you running AdventureLog?
options:
- Docker
- Synology
- Proxmox
- TrueNAS
- Unraid
- Other
validations:
required: true
- type: dropdown
id: install-method
attributes:
label: Install Method
options:
- Docker Compose
- Quick Install Script
- Manual
- Other
validations:
required: true
- type: input
id: version
attributes:
label: AdventureLog Version
description: "Example: v0.12.0"
placeholder: v0.0.0
validations:
required: true
- type: input
id: reverse-proxy
attributes:
label: Reverse Proxy
description: "Example: Nginx, Traefik, Caddy, or None"
placeholder: None
validations:
required: true
- type: textarea
id: docker-compose
attributes:
label: Docker Compose / Relevant Variables (Optional)
description: Include docker-compose content or environment variables if relevant. Remove sensitive data.
render: yaml
placeholder: Paste obfuscated configuration here.
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Any extra information that might help us debug this issue.
placeholder: Network setup, custom configuration, database details, etc.
validations:
required: false
- type: checkboxes
id: terms
attributes:
label: Confirmation
options:
- label: I searched existing issues and confirmed this is not a duplicate.
required: true

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Discord Community
url: https://discord.gg/wRbQ9Egr8C
about: For general deployment troubleshooting and community support.

View File

@@ -1,25 +0,0 @@
---
name: Deployment Issue
about: Request help deploying AdventureLog. For faster support, please use our Discord community!
title: "[DEPLOYMENT] <short description>"
labels: deployment
assignees: ""
---
⚠️ **Note:** GitHub issues are primarily for tracking bugs and feature requests.
For general deployment troubleshooting and faster community support, please visit our **Discord**: https://discord.gg/wRbQ9Egr8C
---
## 🖥️ Describe Your Issue
A clear and concise description of the deployment problem.
## 🐳 Docker Compose (Obfuscated)
Please include your `docker-compose.yml` or relevant variable configuration.
⚠️ Make sure to **remove or obfuscate sensitive information** (passwords, tokens, keys, etc.).
```yaml
# Example (with secrets removed)
```

View File

@@ -0,0 +1,58 @@
name: Deployment Issue
description: Request help deploying AdventureLog.
title: "[DEPLOYMENT] "
labels:
- deployment
body:
- type: markdown
attributes:
value: |
GitHub issues are primarily for tracking bugs and feature requests.
For general deployment troubleshooting and faster community support, visit Discord: https://discord.gg/wRbQ9Egr8C
- type: textarea
id: deployment-description
attributes:
label: Describe Your Issue
description: A clear and concise description of the deployment problem.
placeholder: Explain what is failing and when it happens.
validations:
required: true
- type: textarea
id: docker-compose
attributes:
label: Docker Compose (Obfuscated)
description: Include your docker-compose configuration or relevant variables. Remove secrets.
render: yaml
placeholder: Paste your obfuscated configuration.
validations:
required: false
- type: textarea
id: logs
attributes:
label: Logs / Error Output
description: Include relevant service logs or command output.
placeholder: Paste key logs here.
validations:
required: false
- type: input
id: version
attributes:
label: AdventureLog Version
description: "In the web app, click the 3-dot menu in the sidebar and select 'About' to find the version number."
placeholder: v0.0.0
validations:
required: false
- type: input
id: reverse-proxy
attributes:
label: Reverse Proxy
placeholder: Nginx, Traefik, Caddy, None
validations:
required: false
- type: checkboxes
id: terms
attributes:
label: Confirmation
options:
- label: I removed or obfuscated all sensitive data from this issue.
required: true

View File

@@ -1,39 +0,0 @@
---
name: Feature request
about: Suggest a new idea, feature, or improvement for AdventureLog
title: "[REQUEST] <short description>"
labels: enhancement
assignees: ""
---
🛑 **Note**: Please search existing issues before creating a new request. This helps avoid duplicates and keeps discussions focused.
## 💡 Feature Summary
A clear and concise description of the feature or improvement youd like to see.
## 🤔 Problem Statement
Is your request related to a specific problem?
(Example: “Im always frustrated when…”)
## 🛠️ Proposed Solution
Describe the solution youd like.
What should AdventureLog do differently?
## 🔄 Alternatives Considered
List any alternative solutions or workarounds youve tried or thought about.
## 📸 Mockups / Examples (optional)
If you would like, include screenshots, sketches, or links that illustrate the idea.
## 📎 Additional Context
Any other details that might help us understand the request (use cases, user stories, related features, etc.).
---
✨ Thanks for suggesting improvements to AdventureLog! Your ideas help shape the future of the project.

View File

@@ -0,0 +1,65 @@
name: Feature request
description: Suggest a new idea, feature, or improvement for AdventureLog.
title: "[REQUEST] "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Please search existing issues before creating a new request. This helps avoid duplicates and keeps discussions focused.
- type: textarea
id: feature-summary
attributes:
label: Feature Summary
description: A clear and concise description of the feature or improvement you want.
placeholder: Summarize your request.
validations:
required: true
- type: textarea
id: problem-statement
attributes:
label: Problem Statement
description: Is your request related to a specific problem?
placeholder: "Example: I am frustrated when ..."
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Proposed Solution
description: Describe the solution you want.
placeholder: What should AdventureLog do differently?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: List workarounds or alternative solutions you considered.
placeholder: Describe alternatives and why they are not ideal.
validations:
required: false
- type: textarea
id: mockups
attributes:
label: Mockups / Examples (Optional)
description: Include screenshots, sketches, or links that illustrate the idea.
placeholder: Add links or image references.
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional Context
description: Add any other details, use cases, or related features.
placeholder: Extra context.
validations:
required: false
- type: checkboxes
id: terms
attributes:
label: Confirmation
options:
- label: I searched existing issues and confirmed this request is not a duplicate.
required: true

44
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,44 @@
## Related Issue
Closes #
If this PR does not resolve an issue, please explain why.
---
## Description
Explain what this PR changes and why.
---
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Documentation update
- [ ] Refactor / code cleanup
- [ ] Performance improvement
- [ ] Other (please describe)
---
## Checklist
Before submitting this PR, please confirm:
- [ ] I have linked an existing issue
- [ ] The issue is **approved or marked as ready**
- [ ] My changes follow the existing project structure and coding standards
- [ ] I have tested the changes locally
- [ ] Documentation has been updated if necessary
---
## Screenshots (if applicable)
If this PR includes UI changes, please include screenshots or GIFs.
---
Thank you for contributing to **AdventureLog**! Your efforts help make this project better for everyone. Well review your PR as soon as we can. 🙌

85
.github/workflows/adventurelog-bot.yml vendored Normal file
View File

@@ -0,0 +1,85 @@
name: AdventureLog Bot
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
enforce-ready:
permissions:
contents: read
issues: write
pull-requests: write
runs-on: ubuntu-latest
steps:
- name: Validate linked issue
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
// Ignore specific user
if (context.actor === "seanmorley15") {
console.log("Skipping maintainer PR");
return;
}
// Ignore PRs created before enforcement date
const cutoff = new Date("2026-03-16T00:00:00Z");
const created = new Date(pr.created_at);
if (created < cutoff) {
console.log("Skipping PR created before enforcement date");
return;
}
const body = pr.body || "";
const match = body.match(/#(\d+)/);
if (!match) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: "🤖 **AdventureLog Bot**\n\n🚫 This PR was automatically closed because it does not reference an issue.\n\nPlease link an issue using `Closes #issue-number`."
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: "closed"
});
return;
}
const issueNumber = match[1];
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber
});
const labels = issue.labels.map(l => l.name);
if (!labels.includes("ready")) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: "🤖 **AdventureLog Bot**\n\n🚫 This PR was automatically closed.\n\nPull requests may only be opened for issues labeled **ready**."
});
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
state: "closed"
});
}

View File

@@ -0,0 +1,85 @@
name: Sync Project Status
on:
issues:
types: [labeled]
jobs:
update-project:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- name: Update project status from label
uses: actions/github-script@v7
with:
script: |
const labelMap = {
"backlog": "BACKLOG_OPTION_ID",
"needs discussion": "DISCUSSION_OPTION_ID",
"approved": "APPROVED_OPTION_ID",
"ready": "READY_OPTION_ID",
"in progress": "IN_PROGRESS_OPTION_ID",
"in review": "IN_REVIEW_OPTION_ID",
"done": "DONE_OPTION_ID"
};
const label = context.payload.label.name.toLowerCase();
const optionId = labelMap[label];
if (!optionId) return;
const issueNodeId = context.payload.issue.node_id;
const projectId = "PVT_kwHOBeIeKs4AfmUO";
const fieldId = "PVTSSF_lAHOBeIeKs4AfmUOzgU5pCI";
// find project item
const result = await github.graphql(`
query($issueId: ID!) {
node(id: $issueId) {
... on Issue {
projectItems(first: 10) {
nodes {
id
}
}
}
}
}
`, {
issueId: issueNodeId
});
const itemId = result.node.projectItems.nodes[0]?.id;
if (!itemId) {
console.log("Issue not in project");
return;
}
// update status field
await github.graphql(`
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}
) {
projectV2Item {
id
}
}
}
`, {
projectId: projectId,
itemId: itemId,
fieldId: fieldId,
optionId: optionId
});

View File

@@ -1,52 +1,258 @@
# Contributing to AdventureLog
Were excited to have you contribute to AdventureLog! To ensure that this community remains welcoming and productive for all users and developers, please follow this simple Code of Conduct.
Thank you for your interest in contributing to **AdventureLog**!
AdventureLog is an open-source project built by and for people who love travel, exploration, and self-hosting. Contributions of all kinds are welcome — whether thats fixing bugs, improving documentation, suggesting features, or writing code.
## Pull Request Process
Our goal is to keep the project **open, welcoming, and organized** so that contributors can collaborate effectively and the codebase remains maintainable long-term.
1. **Open an Issue First**: Discuss any changes or features you plan to implement by opening an issue. This helps to clarify your idea and ensures theres a shared understanding.
2. **Document Changes**: If your changes impact the user interface, add new environment variables, or introduce new container configurations, make sure to update the documentation accordingly. The documentation is located in the `documentation` folder.
3. **Pull Request**: Submit a pull request with your changes directed towards the `development` branch. Make sure to reference the issue you opened in the description. After your pull request is submitted, it will be reviewed by the maintainers.
4. **Review Process**: The maintainers will review your pull request. They may suggest changes or improvements. Please be open to feedback and ready to make adjustments as needed.
5. **Merge**: Once your pull request is approved, it will be merged into the `development` branch. This branch is where all new features and changes are integrated before being released to the main branch.
This document explains how to contribute and the workflow we use.
## Code of Conduct
---
### Our Pledge
# How Contributions Work
At AdventureLog, we are committed to creating a community that fosters adventure, exploration, and innovation. We encourage diverse participation and strive to maintain a space where everyone feels welcome to contribute, regardless of their background or experience level. We ask that you contribute with respect and kindness, making sure to prioritize collaboration and mutual growth.
AdventureLog uses a structured workflow to keep development organized and to make it easier for contributors to collaborate.
### Our Standards
All development follows this process:
In order to maintain a positive environment, we encourage the following behaviors:
```
Issue → Discussion → Approved → Ready → Development → Review → Merge
```
- **Inclusivity**: Use welcoming and inclusive language that fosters collaboration across all perspectives and experiences.
- **Respect**: Respect differing opinions and engage with empathy, understanding that each persons perspective is valuable.
- **Constructive Feedback**: Offer feedback that helps improve the project and allows contributors to grow from it.
- **Adventure Spirit**: Bring the same sense of curiosity, discovery, and positivity that drives AdventureLog into all interactions with the community.
### 1. Open or Find an Issue
Before starting work, **please open an issue or find an existing one**.
Issues allow us to:
- discuss ideas before development begins
- coordinate work between contributors
- prevent duplicate efforts
- maintain a clear roadmap for the project
If you have an idea for a new feature or improvement, feel free to open an issue describing it.
---
### 2. Wait for Approval / Ready Status
Issues move through several stages:
**Backlog**
An idea or request that has not yet been reviewed.
**Needs Discussion**
The idea requires maintainer feedback or design discussion.
**Approved**
The concept has been accepted but may require planning.
**Ready**
The issue is ready for contributors to begin working on it.
⚠️ **Pull Requests should only be opened for issues marked `Ready`.**
This helps ensure contributors work on changes that are aligned with the projects roadmap.
---
### 3. Start Working on the Issue
Once an issue is marked **Ready**, you can begin working on it.
If you plan to work on a larger issue, feel free to comment on the issue to let others know.
This helps prevent duplicate work.
---
### 4. Create a Pull Request
When your changes are ready, open a pull request targeting the **`development` branch**.
Your pull request must include a reference to the issue it resolves:
```
Closes #issue-number
```
This allows the project automation to track progress and update the project board.
Example PR description:
```
Closes #123
Adds support for exporting trips as GPX files.
```
---
### 5. Review Process
Once submitted, maintainers will review your pull request.
Reviews may include:
- code quality improvements
- consistency with the existing architecture
- performance considerations
- documentation updates
Please be open to feedback — reviews are intended to **improve the project and help contributors grow**.
---
### 6. Merge
After approval, your pull request will be merged into the **`development` branch**.
From there, it will eventually be included in the next release.
Thank you for helping improve AdventureLog!
---
# AI / LLM Assistance
Using AI tools (such as ChatGPT, Copilot, or other LLMs) **is allowed** when contributing to AdventureLog.
However, contributors are responsible for ensuring that generated code:
- is **correct and fully understood**
- follows the **projects coding standards**
- integrates properly with the existing architecture
- does not introduce unnecessary complexity
AI-generated code that does not meet these standards may be rejected or the pull request may be closed.
Please review and clean up any AI-generated code before submitting it.
---
# Code Quality Expectations
To keep the project maintainable, all contributions should:
- follow the existing **code structure and architecture**
- use clear and readable code
- avoid unnecessary dependencies
- include documentation updates when relevant
- maintain compatibility with the existing system
AdventureLog currently includes:
- **Django** for the backend
- **SvelteKit** for the frontend
- **Docker-based deployments**
When contributing, please try to match the **style and patterns already used in the project**.
---
# Documentation Changes
If your changes affect:
- user workflows
- environment variables
- deployment setup
- API behavior
- configuration
please update the documentation in the:
```
/documentation
```
folder accordingly.
Keeping documentation accurate is extremely important.
---
# Good Issues for New Contributors
If you are new to the project, look for issues labeled:
```
good first issue
help wanted
```
These are great starting points for new contributors.
---
# Code of Conduct
## Our Pledge
At AdventureLog, we are committed to creating a community that fosters adventure, exploration, and innovation.
We welcome contributors of all experience levels and backgrounds. Everyone should feel comfortable participating and sharing ideas.
---
## Our Standards
To maintain a positive environment, we encourage the following behaviors:
- **Inclusivity** — Use welcoming and inclusive language.
- **Respect** — Respect differing viewpoints and experiences.
- **Constructive Feedback** — Provide helpful and actionable feedback.
- **Collaboration** — Work together to improve the project.
Examples of unacceptable behavior include:
- Personal attacks, trolling, or any form of harassment.
- Insensitive or discriminatory language, including sexualized comments or imagery.
- Spamming or misusing project spaces for personal gain.
- Publishing or using others private information without permission.
- Anything else that could be seen as disrespectful or unprofessional in a collaborative environment.
- Personal attacks or harassment
- Discriminatory language
- Spamming or promotional misuse of project spaces
- Sharing private information without consent
### Our Responsibilities
---
As maintainers of AdventureLog, we are committed to enforcing this Code of Conduct and taking corrective action when necessary. This may involve moderating comments, pulling code, or banning users who engage in harmful behaviors.
## Maintainer Responsibilities
We strive to foster a community that balances open collaboration with respect for all contributors.
The AdventureLog maintainers are responsible for enforcing this Code of Conduct and maintaining a respectful community.
### Scope
If necessary, maintainers may:
This Code of Conduct applies in all spaces related to AdventureLog. This includes our GitHub repository, discussions, documentation, social media accounts, and events—both online and in person.
- moderate comments
- close pull requests
- remove contributions
- restrict participation
### Enforcement
These actions will only be taken when necessary to protect the community and the project.
If you experience or witness unacceptable behavior, please report it to the project team at `contact@adventurelog.app`. All reports will be confidential and handled swiftly. The maintainers will investigate the issue and take appropriate action as needed.
---
### Attribution
## Scope
This Code of Conduct is inspired by the [Contributor Covenant](http://contributor-covenant.org), version 1.4, and adapted to fit the unique spirit of AdventureLog.
This Code of Conduct applies to all spaces related to AdventureLog, including:
- GitHub repositories
- GitHub Discussions
- documentation
- social media
- community spaces
---
## Reporting Issues
If you experience or witness unacceptable behavior, please contact the maintainers at:
```
contact@adventurelog.app
```
All reports will be handled confidentially.
---
## Attribution
This Code of Conduct is inspired by the
Contributor Covenant (v1.4) and adapted for the AdventureLog community.

View File

@@ -3,6 +3,8 @@ import json
import zipfile
import tempfile
import os
import re
from decimal import Decimal, InvalidOperation
from datetime import datetime
from django.http import HttpResponse
from django.core.files.storage import default_storage
@@ -31,6 +33,73 @@ class BackupViewSet(viewsets.ViewSet):
"""
Simple ViewSet for handling backup and import operations
"""
def _normalize_money_amount(self, value):
"""Return a Decimal amount from legacy or canonical backup values."""
if value is None:
return None
if isinstance(value, Decimal):
return value
if isinstance(value, (int, float)):
try:
return Decimal(str(value))
except (InvalidOperation, ValueError):
return None
if not isinstance(value, str):
return None
text = value.strip()
if not text:
return None
# Accept values like "$1,553.59" and "USD 1,553.59".
cleaned = re.sub(r'[^0-9,\.\-]', '', text)
if not cleaned:
return None
if ',' in cleaned and '.' in cleaned:
if cleaned.rfind(',') > cleaned.rfind('.'):
cleaned = cleaned.replace('.', '').replace(',', '.')
else:
cleaned = cleaned.replace(',', '')
elif ',' in cleaned:
parts = cleaned.split(',')
if len(parts) == 2 and len(parts[-1]) in (1, 2):
cleaned = cleaned.replace(',', '.')
else:
cleaned = cleaned.replace(',', '')
try:
return Decimal(cleaned)
except InvalidOperation:
return None
def _parse_money(self, value, currency=None, default_currency='USD'):
"""Parse a backup money value and return schema-safe (amount, currency)."""
parsed_currency = currency
parsed_value = value
if isinstance(value, dict):
parsed_value = value.get('amount')
parsed_currency = parsed_currency or value.get('currency')
amount = self._normalize_money_amount(parsed_value)
if amount is None:
# django-money stores currency in a separate non-null column even when
# the monetary amount itself is empty, so imports must preserve a
# default currency for blank legacy values.
return None, default_currency
normalized_currency = (parsed_currency or default_currency)
if isinstance(normalized_currency, str):
normalized_currency = normalized_currency.strip().upper() or default_currency
else:
normalized_currency = default_currency
return amount, normalized_currency
@action(detail=False, methods=['get'])
def export(self, request):
@@ -106,6 +175,8 @@ class BackupViewSet(viewsets.ViewSet):
'tags': location.tags,
'description': location.description,
'rating': location.rating,
'price': str(location.price.amount) if location.price else None,
'price_currency': str(location.price.currency) if location.price else None,
'link': location.link,
'is_public': location.is_public,
'longitude': str(location.longitude) if location.longitude else None,
@@ -227,6 +298,8 @@ class BackupViewSet(viewsets.ViewSet):
'name': transport.name,
'description': transport.description,
'rating': transport.rating,
'price': str(transport.price.amount) if transport.price else None,
'price_currency': str(transport.price.currency) if transport.price else None,
'link': transport.link,
'date': transport.date.isoformat() if transport.date else None,
'end_date': transport.end_date.isoformat() if transport.end_date else None,
@@ -300,7 +373,8 @@ class BackupViewSet(viewsets.ViewSet):
'check_out': lodging.check_out.isoformat() if lodging.check_out else None,
'timezone': lodging.timezone,
'reservation_number': lodging.reservation_number,
'price': str(lodging.price) if lodging.price else None,
'price': str(lodging.price.amount) if lodging.price else None,
'price_currency': str(lodging.price.currency) if lodging.price else None,
'latitude': str(lodging.latitude) if lodging.latitude else None,
'longitude': str(lodging.longitude) if lodging.longitude else None,
'location': lodging.location,
@@ -572,6 +646,11 @@ class BackupViewSet(viewsets.ViewSet):
# Import Locations
for adv_data in backup_data.get('locations', []):
location_price, location_price_currency = self._parse_money(
adv_data.get('price'),
adv_data.get('price_currency')
)
city = None
if adv_data.get('city'):
try:
@@ -600,6 +679,8 @@ class BackupViewSet(viewsets.ViewSet):
tags=adv_data.get('tags', []),
description=adv_data.get('description'),
rating=adv_data.get('rating'),
price=location_price,
price_currency=location_price_currency,
link=adv_data.get('link'),
is_public=adv_data.get('is_public', False),
longitude=adv_data.get('longitude'),
@@ -779,6 +860,11 @@ class BackupViewSet(viewsets.ViewSet):
collection = None
if trans_data.get('collection_export_id') is not None:
collection = collection_map.get(trans_data['collection_export_id'])
transport_price, transport_price_currency = self._parse_money(
trans_data.get('price'),
trans_data.get('price_currency')
)
transportation = Transportation.objects.create(
user=user,
@@ -786,6 +872,8 @@ class BackupViewSet(viewsets.ViewSet):
name=trans_data['name'],
description=trans_data.get('description'),
rating=trans_data.get('rating'),
price=transport_price,
price_currency=transport_price_currency,
link=trans_data.get('link'),
date=trans_data.get('date'),
end_date=trans_data.get('end_date'),
@@ -863,6 +951,11 @@ class BackupViewSet(viewsets.ViewSet):
collection = None
if lodg_data.get('collection_export_id') is not None:
collection = collection_map.get(lodg_data['collection_export_id'])
lodging_price, lodging_price_currency = self._parse_money(
lodg_data.get('price'),
lodg_data.get('price_currency')
)
lodging = Lodging.objects.create(
user=user,
@@ -875,7 +968,8 @@ class BackupViewSet(viewsets.ViewSet):
check_out=lodg_data.get('check_out'),
timezone=lodg_data.get('timezone'),
reservation_number=lodg_data.get('reservation_number'),
price=lodg_data.get('price'),
price=lodging_price,
price_currency=lodging_price_currency,
latitude=lodg_data.get('latitude'),
longitude=lodg_data.get('longitude'),
location=lodg_data.get('location'),

View File

@@ -29,7 +29,34 @@ class StatsViewSet(viewsets.ViewSet):
return visited_count
def _get_activity_stats_by_category(self, user_activities):
def _can_view_location(self, request_user, profile_user, location):
if not location:
return False
if request_user.is_authenticated and request_user.id == profile_user.id:
return True
return bool(location.is_public)
def _build_activity_record(self, activity, metric_key, metric_value, profile_user, request_user):
if not activity:
return None
location = activity.visit.location if activity.visit_id and activity.visit else None
can_view_location = self._can_view_location(request_user, profile_user, location)
return {
'metric_key': metric_key,
'metric_value': round(float(metric_value or 0), 2),
'activity_id': str(activity.id),
'activity_name': activity.name if activity.name else None,
'sport_type': activity.sport_type,
'start_date': activity.start_date.isoformat() if activity.start_date else None,
'location_id': str(location.id) if (location and can_view_location) else None,
'location_name': location.name if (location and can_view_location) else None,
}
def _get_activity_stats_by_category(self, user_activities, profile_user, request_user):
"""Calculate detailed stats for each sport category"""
category_stats = {}
@@ -87,12 +114,42 @@ class StatsViewSet(viewsets.ViewSet):
'avg_speed': round(stats['avg_speed'] or 0, 2),
'max_speed': round(stats['max_speed'] or 0, 2),
'total_calories': round(stats['total_calories'] or 0, 2),
'sports': sport_breakdown
'sports': sport_breakdown,
'record_holders': {
'max_distance': self._build_activity_record(
activities.exclude(distance__isnull=True).select_related('visit__location').order_by('-distance', '-start_date').first(),
'distance',
stats['max_distance'],
profile_user,
request_user,
),
'max_speed': self._build_activity_record(
activities.exclude(max_speed__isnull=True).select_related('visit__location').order_by('-max_speed', '-start_date').first(),
'max_speed',
stats['max_speed'],
profile_user,
request_user,
),
'max_elevation_gain': self._build_activity_record(
activities.exclude(elevation_gain__isnull=True).select_related('visit__location').order_by('-elevation_gain', '-start_date').first(),
'elevation_gain',
stats['max_elevation_gain'],
profile_user,
request_user,
),
'max_calories': self._build_activity_record(
activities.exclude(calories__isnull=True).select_related('visit__location').order_by('-calories', '-start_date').first(),
'calories',
activities.exclude(calories__isnull=True).aggregate(value=Max('calories'))['value'],
profile_user,
request_user,
),
}
}
return category_stats
def _get_overall_activity_stats(self, user_activities):
def _get_overall_activity_stats(self, user_activities, profile_user, request_user):
"""Calculate overall activity statistics"""
if not user_activities.exists():
return {
@@ -101,7 +158,13 @@ class StatsViewSet(viewsets.ViewSet):
'total_moving_time': 0,
'total_elevation_gain': 0,
'total_elevation_loss': 0,
'total_calories': 0
'total_calories': 0,
'record_holders': {
'max_distance': None,
'max_speed': None,
'max_elevation_gain': None,
'max_calories': None,
},
}
stats = user_activities.aggregate(
@@ -124,7 +187,37 @@ class StatsViewSet(viewsets.ViewSet):
'total_moving_time': total_moving_seconds,
'total_elevation_gain': round(stats['total_elevation_gain'] or 0, 2),
'total_elevation_loss': round(stats['total_elevation_loss'] or 0, 2),
'total_calories': round(stats['total_calories'] or 0, 2)
'total_calories': round(stats['total_calories'] or 0, 2),
'record_holders': {
'max_distance': self._build_activity_record(
user_activities.exclude(distance__isnull=True).select_related('visit__location').order_by('-distance', '-start_date').first(),
'distance',
user_activities.exclude(distance__isnull=True).aggregate(value=Max('distance'))['value'],
profile_user,
request_user,
),
'max_speed': self._build_activity_record(
user_activities.exclude(max_speed__isnull=True).select_related('visit__location').order_by('-max_speed', '-start_date').first(),
'max_speed',
user_activities.exclude(max_speed__isnull=True).aggregate(value=Max('max_speed'))['value'],
profile_user,
request_user,
),
'max_elevation_gain': self._build_activity_record(
user_activities.exclude(elevation_gain__isnull=True).select_related('visit__location').order_by('-elevation_gain', '-start_date').first(),
'elevation_gain',
user_activities.exclude(elevation_gain__isnull=True).aggregate(value=Max('elevation_gain'))['value'],
profile_user,
request_user,
),
'max_calories': self._build_activity_record(
user_activities.exclude(calories__isnull=True).select_related('visit__location').order_by('-calories', '-start_date').first(),
'calories',
user_activities.exclude(calories__isnull=True).aggregate(value=Max('calories'))['value'],
profile_user,
request_user,
),
},
}
@action(detail=False, methods=['get'], url_path=r'counts/(?P<username>[\w.@+-]+)')
@@ -153,8 +246,8 @@ class StatsViewSet(viewsets.ViewSet):
user_activities = Activity.objects.filter(user=user.id)
# Get enhanced activity statistics
overall_activity_stats = self._get_overall_activity_stats(user_activities)
activity_stats_by_category = self._get_activity_stats_by_category(user_activities)
overall_activity_stats = self._get_overall_activity_stats(user_activities, user, request.user)
activity_stats_by_category = self._get_activity_stats_by_category(user_activities, user, request.user)
return Response({
# Travel stats

View File

@@ -389,9 +389,9 @@ PUBLIC_URL = getenv('PUBLIC_URL', 'http://localhost:8000')
ADVENTURELOG_RELEASE_VERSION = 'v0.12.0'
# https://github.com/dr5hn/countries-states-cities-database/tags
COUNTRY_REGION_JSON_VERSION = 'v3.0'
COUNTRY_REGION_JSON_VERSION = 'v3.1'
# External service keys (do not hardcode secrets)
GOOGLE_MAPS_API_KEY = getenv('GOOGLE_MAPS_API_KEY', '')
STRAVA_CLIENT_ID = getenv('STRAVA_CLIENT_ID', '')
STRAVA_CLIENT_SECRET = getenv('STRAVA_CLIENT_SECRET', '')
STRAVA_CLIENT_SECRET = getenv('STRAVA_CLIENT_SECRET', '')

View File

@@ -42,6 +42,10 @@ class ChangeEmailView(APIView):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class IsRegistrationDisabled(APIView):
# This endpoint is requested on auth pages and should not be globally throttled.
# A 429 here can break signup UX even for legitimate users.
throttle_classes = []
@swagger_auto_schema(
responses={
200: openapi.Response('Registration is disabled'),
@@ -112,6 +116,9 @@ class PublicUserDetailView(APIView):
class UserMetadataView(APIView):
permission_classes = [IsAuthenticated]
# This endpoint is used by the frontend auth hook to hydrate user state.
# Global throttling can cause an auth loop and forced logout behavior.
throttle_classes = []
@swagger_auto_schema(
responses={

View File

@@ -5,6 +5,11 @@ const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
export const authHook: Handle = async ({ event, resolve }) => {
event.cookies.delete('csrftoken', { path: '/' });
try {
// Image proxy requests can be very high-volume and do not need locals.user.
if (event.url.pathname.startsWith('/immich/')) {
return await resolve(event);
}
let sessionid = event.cookies.get('sessionid');
if (!sessionid) {
@@ -23,6 +28,13 @@ export const authHook: Handle = async ({ event, resolve }) => {
});
if (!userFetch.ok) {
// Preserve the session on transient backend failures (e.g. 429 throttling)
// to avoid forcing users into a logout loop.
if (userFetch.status === 429 || userFetch.status >= 500) {
event.locals.user = null;
return await resolve(event);
}
event.locals.user = null;
event.cookies.delete('sessionid', { path: '/', secure: event.url.protocol === 'https:' });
return await resolve(event);

View File

@@ -1,23 +1,29 @@
<script lang="ts">
import type { Category } from '$lib/types';
import { createEventDispatcher } from 'svelte';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
let types_arr: string[] = [];
export let types: string;
let adventure_types: Category[] = [];
let isOpen = false;
const dispatch = createEventDispatcher<{ change: { types: string } }>();
onMount(async () => {
let categoryFetch = await fetch('/api/categories');
let categoryData = await categoryFetch.json();
adventure_types = categoryData;
console.log(categoryData);
types_arr = types.split(',');
});
$: {
types_arr = types ? types.split(',').filter((item) => item !== '') : [];
}
function clearTypes() {
types = '';
types_arr = [];
dispatch('change', { types });
}
function toggleSelect(type: string) {
@@ -29,42 +35,44 @@
types_arr = types_arr.filter((item) => item !== '');
// turn types_arr into a comma seperated list with no spaces
types = types_arr.join(',');
console.log(types);
console.log(types_arr);
dispatch('change', { types });
}
</script>
<div class="collapse collapse-plus mb-4">
<input type="checkbox" />
<div class="collapse-title text-xl bg-base-300 font-medium">
<div class="mb-4 rounded-lg bg-base-300">
<button
type="button"
class="w-full text-left text-xl font-medium p-4"
on:click={() => (isOpen = !isOpen)}
>
{$t('adventures.category_filter')}
</div>
</button>
<div class="collapse-content bg-base-300">
<button class="btn btn-sm btn-neutral-300 w-full mb-2" on:click={clearTypes}>
{$t('adventures.clear')}
</button>
{#if isOpen}
<div class="px-4 pb-4">
<button type="button" class="btn btn-sm btn-neutral-300 w-full mb-2" on:click={clearTypes}>
{$t('adventures.clear')}
</button>
<ul>
{#each adventure_types as type}
<li class="mb-1">
<label class="cursor-pointer flex items-center gap-2">
<input
type="checkbox"
class="checkbox"
value={type.name}
on:change={() => toggleSelect(type.name)}
checked={types.indexOf(type.name) > -1}
/>
<span>
{type.display_name}
{type.icon} ({type.num_locations})
</span>
</label>
</li>
{/each}
</ul>
</div>
<ul>
{#each adventure_types as type}
<li class="mb-1">
<label class="cursor-pointer flex items-center gap-2">
<input
type="checkbox"
class="checkbox"
value={type.name}
on:change={() => toggleSelect(type.name)}
checked={types_arr.includes(type.name)}
/>
<span>
{type.display_name}
{type.icon} ({type.num_locations})
</span>
</label>
</li>
{/each}
</ul>
</div>
{/if}
</div>

View File

@@ -1,4 +1,4 @@
export let appVersion = 'v0.12.0-main-022726';
export let appVersion = 'v0.12.0-main-031526';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.12.0';
export let appTitle = 'AdventureLog';
export let copyrightYear = '2023-2026';

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import LocationCard from '$lib/components/cards/LocationCard.svelte';
import CategoryFilterDropdown from '$lib/components/CategoryFilterDropdown.svelte';
import CategoryModal from '$lib/components/CategoryModal.svelte';
import type { Location } from '$lib/types';
import { onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
import Plus from '~icons/mdi/plus';
@@ -58,17 +59,54 @@
let isLocationModalOpen: boolean = false;
let sidebarOpen = false;
// Reactive statements - Only read from URL, don't write
$: {
if (typeof window !== 'undefined') {
let url = new URL(window.location.href);
let types = url.searchParams.get('types');
if (types) {
typeString = types;
} else {
typeString = '';
function syncTypesFromUrl() {
if (typeof window === 'undefined') {
return;
}
let url = new URL(window.location.href);
typeString = url.searchParams.get('types') || '';
}
onMount(() => {
syncTypesFromUrl();
});
afterNavigate(() => {
syncTypesFromUrl();
});
function doApplyFilters() {
if (typeof window === 'undefined') {
return;
}
const form = document.getElementById('location-filters-form') as HTMLFormElement | null;
if (!form) {
return;
}
const formData = new FormData(form);
const url = new URL(window.location.href);
url.search = '';
for (const [key, value] of formData.entries()) {
if (value !== '') {
url.searchParams.append(key, value.toString());
}
}
if (!url.searchParams.get('types')) {
url.searchParams.delete('types');
}
url.searchParams.delete('page');
goto(url.toString(), { invalidateAll: true, replaceState: true });
}
async function onCategoryChange() {
await tick();
doApplyFilters();
}
$: {
@@ -304,14 +342,19 @@
</div>
<!-- Filters Form -->
<form method="get" class="space-y-6">
<form
id="location-filters-form"
on:submit|preventDefault={doApplyFilters}
class="space-y-6"
>
<input type="hidden" name="types" value={typeString} />
<!-- Category Filter -->
<div class="card bg-base-200/50 p-4">
<h3 class="font-semibold text-lg mb-4 flex items-center gap-2">
<Tag class="w-5 h-5" />
Categories
</h3>
<CategoryFilterDropdown bind:types={typeString} />
<CategoryFilterDropdown bind:types={typeString} on:change={onCategoryChange} />
<button
type="button"
on:click={() => (is_category_modal_open = true)}

View File

@@ -34,6 +34,17 @@
let measurementSystem: string = 'metric';
let expandedCategories = new Set();
type ActivityRecord = {
metric_key: string;
metric_value: number;
activity_id: string;
activity_name: string | null;
sport_type: string | null;
start_date: string | null;
location_id: string | null;
location_name: string | null;
};
let stats: {
visited_country_count: number;
total_regions: number;
@@ -50,6 +61,12 @@
total_elevation_gain: number;
total_elevation_loss: number;
total_calories: number;
record_holders: {
max_distance: ActivityRecord | null;
max_speed: ActivityRecord | null;
max_elevation_gain: ActivityRecord | null;
max_calories: ActivityRecord | null;
};
};
activities_by_category: Record<
string,
@@ -66,6 +83,12 @@
avg_speed: number;
max_speed: number;
total_calories: number;
record_holders: {
max_distance: ActivityRecord | null;
max_speed: ActivityRecord | null;
max_elevation_gain: ActivityRecord | null;
max_calories: ActivityRecord | null;
};
sports: Record<
string,
{
@@ -220,15 +243,31 @@
}
}
function getPercentage(value: number, total: number): number {
if (!total || total <= 0) {
return 0;
}
return Math.round((value / total) * 100);
}
function getRecordActivityTitle(record: ActivityRecord | null): string {
if (!record) {
return 'Activity';
}
return record.activity_name || record.sport_type || 'Activity';
}
// Calculate achievements
$: worldExplorationPercentage = stats
? Math.round((stats.visited_country_count / stats.total_countries) * 100)
? getPercentage(stats.visited_country_count, stats.total_countries)
: 0;
$: regionExplorationPercentage = stats
? Math.round((stats.visited_region_count / stats.total_regions) * 100)
? getPercentage(stats.visited_region_count, stats.total_regions)
: 0;
$: cityExplorationPercentage = stats
? Math.round((stats.visited_city_count / stats.total_cities) * 100)
? getPercentage(stats.visited_city_count, stats.total_cities)
: 0;
// Achievement levels
@@ -430,7 +469,7 @@
<progress
class="progress progress-success w-full h-2"
value={stats.visited_country_count}
max={stats.total_countries}
max={Math.max(stats.total_countries, 1)}
></progress>
</div>
</div>
@@ -464,7 +503,7 @@
<progress
class="progress progress-info w-full h-2"
value={stats.visited_region_count}
max={stats.total_regions}
max={Math.max(stats.total_regions, 1)}
></progress>
</div>
</div>
@@ -494,7 +533,7 @@
<progress
class="progress progress-warning w-full h-2"
value={stats.visited_city_count}
max={stats.total_cities}
max={Math.max(stats.total_cities, 1)}
></progress>
</div>
</div>
@@ -515,7 +554,7 @@
</div>
<!-- Overall Activity Summary -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 mb-8">
<!-- Total Activities -->
<div
class="stat-card card bg-gradient-to-br from-accent/10 to-accent/5 shadow-xl border border-accent/20 hover:shadow-2xl transition-all duration-300"
@@ -552,6 +591,21 @@
{getDistance(stats.activities_overall.total_distance)}
</div>
<div class="text-error/60 mt-2">{$t('adventures.distance_covered')}</div>
{#if stats.activities_overall.record_holders?.max_distance}
{@const maxDistanceRecord =
stats.activities_overall.record_holders.max_distance}
<div class="mt-2 text-xs text-error/70">
Longest single activity: {getRecordActivityTitle(maxDistanceRecord)}
</div>
{#if maxDistanceRecord?.location_id}
<a
href={`/locations/${maxDistanceRecord.location_id}`}
class="link link-error text-xs"
>
Happened at {maxDistanceRecord.location_name || 'this location'}
</a>
{/if}
{/if}
</div>
<div class="p-3 bg-error/20 rounded-2xl">
<TrendingUpOutline class="w-6 h-6 text-error" />
@@ -596,6 +650,21 @@
{getElevation(stats.activities_overall.total_elevation_gain)}
</div>
<div class="text-orange-500/60 mt-2">{$t('adventures.total_climbed')}</div>
{#if stats.activities_overall.record_holders?.max_elevation_gain}
{@const maxGainRecord =
stats.activities_overall.record_holders.max_elevation_gain}
<div class="mt-2 text-xs text-orange-500/70">
Biggest climb: {getRecordActivityTitle(maxGainRecord)}
</div>
{#if maxGainRecord?.location_id}
<a
href={`/locations/${maxGainRecord.location_id}`}
class="link text-xs text-orange-500"
>
Happened at {maxGainRecord.location_name || 'this location'}
</a>
{/if}
{/if}
</div>
<div class="p-3 bg-orange-500/20 rounded-2xl">
<Mountain class="w-6 h-6 text-orange-500" />
@@ -603,6 +672,67 @@
</div>
</div>
</div>
<!-- Elevation Loss -->
<div
class="stat-card card bg-gradient-to-br from-info/10 to-info/5 shadow-xl border border-info/20 hover:shadow-2xl transition-all duration-300"
>
<div class="card-body p-6">
<div class="flex items-center justify-between">
<div>
<div class="text-info/70 font-medium text-sm uppercase tracking-wide">
{$t('adventures.elevation_loss')}
</div>
<div class="text-3xl font-bold text-info">
{getElevation(stats.activities_overall.total_elevation_loss)}
</div>
<div class="text-info/60 mt-2">Descent recorded</div>
</div>
<div class="p-3 bg-info/20 rounded-2xl">
<Mountain class="w-6 h-6 text-info rotate-180" />
</div>
</div>
</div>
</div>
{#if stats.activities_overall.total_calories > 0}
<!-- Calories -->
<div
class="stat-card card bg-gradient-to-br from-warning/10 to-warning/5 shadow-xl border border-warning/20 hover:shadow-2xl transition-all duration-300"
>
<div class="card-body p-6">
<div class="flex items-center justify-between">
<div>
<div class="text-warning/70 font-medium text-sm uppercase tracking-wide">
{$t('adventures.calories')}
</div>
<div class="text-3xl font-bold text-warning">
{Math.round(stats.activities_overall.total_calories)}
</div>
<div class="text-warning/60 mt-2">Energy burned</div>
{#if stats.activities_overall.record_holders?.max_calories}
{@const maxCaloriesRecord =
stats.activities_overall.record_holders.max_calories}
<div class="mt-2 text-xs text-warning/70">
Most calories in: {getRecordActivityTitle(maxCaloriesRecord)}
</div>
{#if maxCaloriesRecord?.location_id}
<a
href={`/locations/${maxCaloriesRecord.location_id}`}
class="link link-warning text-xs"
>
Happened at {maxCaloriesRecord.location_name || 'this location'}
</a>
{/if}
{/if}
</div>
<div class="p-3 bg-warning/20 rounded-2xl">
<Fire class="w-6 h-6 text-warning" />
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Activity Categories -->
@@ -653,8 +783,9 @@
{getDistance(categoryData.total_distance)}
</div>
<div class="text-{config.color}/60 text-sm">
{getElevation(categoryData.total_elevation_gain)}
{$t('adventures.elevation')}
Max {getSpeed(categoryData.max_speed)}
<span class="mx-1"></span>
{getElevation(categoryData.total_elevation_gain)} gain
</div>
</div>
<svelte:component
@@ -668,7 +799,7 @@
{#if isExpanded}
<div class="mt-6 space-y-6">
<!-- Quick Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4">
<div
class="bg-{config.color}/5 rounded-lg p-4 border {config.borderColor}"
>
@@ -689,6 +820,36 @@
{getSpeed(categoryData.avg_speed)}
</div>
</div>
<div
class="bg-{config.color}/5 rounded-lg p-4 border {config.borderColor}"
>
<div class="text-{config.color}/70 text-xs uppercase font-medium">
Max Speed
</div>
<div class="text-lg font-bold text-{config.color}">
{getSpeed(categoryData.max_speed)}
</div>
{#if categoryData.record_holders?.max_speed?.location_id}
<a
href={`/locations/${categoryData.record_holders.max_speed.location_id}`}
class="link text-xs text-{config.color}"
>
{$t('locations.best_happened_at')}
{categoryData.record_holders.max_speed.location_name ||
'this location'}
</a>
{/if}
</div>
<div
class="bg-{config.color}/5 rounded-lg p-4 border {config.borderColor}"
>
<div class="text-{config.color}/70 text-xs uppercase font-medium">
Avg Distance
</div>
<div class="text-lg font-bold text-{config.color}">
{getDistance(categoryData.avg_distance)}
</div>
</div>
<div
class="bg-{config.color}/5 rounded-lg p-4 border {config.borderColor}"
>
@@ -698,6 +859,54 @@
<div class="text-lg font-bold text-{config.color}">
{getDistance(categoryData.max_distance)}
</div>
{#if categoryData.record_holders?.max_distance?.location_id}
<a
href={`/locations/${categoryData.record_holders.max_distance.location_id}`}
class="link text-xs text-{config.color}"
>
Best happened at {categoryData.record_holders.max_distance
.location_name || 'this location'}
</a>
{/if}
</div>
<div
class="bg-{config.color}/5 rounded-lg p-4 border {config.borderColor}"
>
<div class="text-{config.color}/70 text-xs uppercase font-medium">
Avg Gain
</div>
<div class="text-lg font-bold text-{config.color}">
{getElevation(categoryData.avg_elevation_gain)}
</div>
</div>
<div
class="bg-{config.color}/5 rounded-lg p-4 border {config.borderColor}"
>
<div class="text-{config.color}/70 text-xs uppercase font-medium">
Max Gain
</div>
<div class="text-lg font-bold text-{config.color}">
{getElevation(categoryData.max_elevation_gain)}
</div>
{#if categoryData.record_holders?.max_elevation_gain?.location_id}
<a
href={`/locations/${categoryData.record_holders.max_elevation_gain.location_id}`}
class="link text-xs text-{config.color}"
>
Best happened at {categoryData.record_holders.max_elevation_gain
.location_name || 'this location'}
</a>
{/if}
</div>
<div
class="bg-{config.color}/5 rounded-lg p-4 border {config.borderColor}"
>
<div class="text-{config.color}/70 text-xs uppercase font-medium">
{$t('adventures.elevation_loss')}
</div>
<div class="text-lg font-bold text-{config.color}">
{getElevation(categoryData.total_elevation_loss)}
</div>
</div>
{#if categoryData.total_calories > 0}
<div