mirror of
https://github.com/joaovitoriasilva/endurain.git
synced 2026-01-10 08:17:59 -05:00
summary view added + some fixes
This commit is contained in:
11
.gitignore
vendored
11
.gitignore
vendored
@@ -14,6 +14,7 @@ backend/app/*.pyc
|
||||
# Logs
|
||||
backend/app/logs/*.log
|
||||
backend/app/*.log
|
||||
backend/logs/*.log
|
||||
|
||||
# server image folder images
|
||||
backend/app/server_images/*.jpeg
|
||||
@@ -32,6 +33,7 @@ backend/app/files/bulk_import/*.gpx
|
||||
backend/app/files/bulk_import/*.fit
|
||||
backend/app/files/processed/*.gpx
|
||||
backend/app/files/processed/*.fit
|
||||
backend/files/
|
||||
|
||||
# Frontend
|
||||
frontend/app/img/users_img/*.*
|
||||
@@ -70,4 +72,11 @@ frontend/app/*.tsbuildinfo
|
||||
.DS_Store
|
||||
|
||||
# site folder
|
||||
site/
|
||||
site/
|
||||
|
||||
# LLM supporting files
|
||||
memory-bank/
|
||||
.clinerules
|
||||
|
||||
# local postgres
|
||||
postgres/
|
||||
339
backend/app/activity_summaries/crud.py
Normal file
339
backend/app/activity_summaries/crud.py
Normal file
@@ -0,0 +1,339 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, extract
|
||||
from datetime import timedelta, date
|
||||
|
||||
from typing import List
|
||||
from activities.activity.models import Activity
|
||||
from activities.activity.utils import set_activity_name_based_on_activity_type, ACTIVITY_NAME_TO_ID
|
||||
from .schema import (
|
||||
WeeklySummaryResponse, MonthlySummaryResponse, YearlySummaryResponse,
|
||||
DaySummary, WeekSummary, MonthSummary, SummaryMetrics, TypeBreakdownItem,
|
||||
LifetimeSummaryResponse, YearlyPeriodSummary
|
||||
)
|
||||
|
||||
def _get_type_breakdown(db: Session, user_id: int, start_date: date, end_date: date, activity_type: str | None = None) -> List[TypeBreakdownItem]:
|
||||
"""Helper function to get summary breakdown by activity type, optionally filtered by a specific type."""
|
||||
query = db.query(
|
||||
Activity.activity_type.label('activity_type'),
|
||||
func.coalesce(func.sum(Activity.distance), 0).label('total_distance'),
|
||||
func.coalesce(func.sum(Activity.total_timer_time), 0.0).label('total_duration'),
|
||||
func.coalesce(func.sum(Activity.elevation_gain), 0).label('total_elevation_gain'),
|
||||
func.coalesce(func.sum(Activity.calories), 0).label('total_calories'),
|
||||
func.count(Activity.id).label('activity_count')
|
||||
).filter(
|
||||
Activity.user_id == user_id
|
||||
)
|
||||
|
||||
if not (start_date == date.min and end_date == date.max):
|
||||
query = query.filter(
|
||||
Activity.start_time >= start_date,
|
||||
Activity.start_time < end_date
|
||||
)
|
||||
|
||||
if activity_type:
|
||||
activity_type_id = ACTIVITY_NAME_TO_ID.get(activity_type.lower())
|
||||
if activity_type_id is not None:
|
||||
query = query.filter(Activity.activity_type == activity_type_id)
|
||||
else:
|
||||
return None
|
||||
|
||||
query = query.group_by(
|
||||
Activity.activity_type
|
||||
).order_by(
|
||||
func.count(Activity.id).desc(),
|
||||
Activity.activity_type.asc()
|
||||
)
|
||||
|
||||
type_results = query.all()
|
||||
type_breakdown_list = []
|
||||
for row in type_results:
|
||||
activity_type_name = set_activity_name_based_on_activity_type(row.activity_type)
|
||||
type_breakdown_list.append(TypeBreakdownItem(
|
||||
activity_type_id=int(row.activity_type),
|
||||
activity_type=activity_type_name,
|
||||
total_distance=float(row.total_distance),
|
||||
total_duration=float(row.total_duration),
|
||||
total_elevation_gain=float(row.total_elevation_gain),
|
||||
total_calories=float(row.total_calories),
|
||||
activity_count=int(row.activity_count)
|
||||
))
|
||||
return type_breakdown_list
|
||||
|
||||
def get_weekly_summary(db: Session, user_id: int, target_date: date, activity_type: str | None = None) -> WeeklySummaryResponse:
|
||||
start_of_week = target_date - timedelta(days=target_date.weekday())
|
||||
end_of_week = start_of_week + timedelta(days=7)
|
||||
|
||||
query = db.query(
|
||||
extract('isodow', Activity.start_time).label('day_of_week'),
|
||||
func.coalesce(func.sum(Activity.distance), 0).label('total_distance'),
|
||||
func.coalesce(func.sum(Activity.total_timer_time), 0.0).label('total_duration'),
|
||||
func.coalesce(func.sum(Activity.elevation_gain), 0).label('total_elevation_gain'),
|
||||
func.coalesce(func.sum(Activity.calories), 0).label('total_calories'),
|
||||
func.count(Activity.id).label('activity_count')
|
||||
).filter(
|
||||
Activity.user_id == user_id,
|
||||
Activity.start_time >= start_of_week,
|
||||
Activity.start_time < end_of_week
|
||||
)
|
||||
|
||||
activity_type_id = None
|
||||
if activity_type:
|
||||
activity_type_id = ACTIVITY_NAME_TO_ID.get(activity_type.lower())
|
||||
if activity_type_id is not None:
|
||||
query = query.filter(Activity.activity_type == activity_type_id)
|
||||
else:
|
||||
query = query.filter(Activity.id == -1) # Force no results
|
||||
|
||||
query = query.group_by(
|
||||
extract('isodow', Activity.start_time)
|
||||
).order_by(
|
||||
extract('isodow', Activity.start_time)
|
||||
)
|
||||
|
||||
daily_results = query.all()
|
||||
breakdown = []
|
||||
overall_metrics = SummaryMetrics()
|
||||
|
||||
day_map = {day.day_of_week: day for day in daily_results}
|
||||
|
||||
for i in range(1, 8):
|
||||
day_data = day_map.get(i)
|
||||
if day_data:
|
||||
day_summary = DaySummary(
|
||||
day_of_week=i - 1,
|
||||
total_distance=float(day_data.total_distance),
|
||||
total_duration=float(day_data.total_duration),
|
||||
total_elevation_gain=float(day_data.total_elevation_gain),
|
||||
total_calories=float(day_data.total_calories),
|
||||
activity_count=int(day_data.activity_count)
|
||||
)
|
||||
breakdown.append(day_summary)
|
||||
overall_metrics.total_distance += day_summary.total_distance
|
||||
overall_metrics.total_duration += day_summary.total_duration
|
||||
overall_metrics.total_elevation_gain += day_summary.total_elevation_gain
|
||||
overall_metrics.total_calories += day_summary.total_calories
|
||||
overall_metrics.activity_count += day_summary.activity_count
|
||||
else:
|
||||
breakdown.append(DaySummary(day_of_week=i - 1))
|
||||
|
||||
return WeeklySummaryResponse(
|
||||
total_distance=overall_metrics.total_distance,
|
||||
total_duration=overall_metrics.total_duration,
|
||||
total_elevation_gain=overall_metrics.total_elevation_gain,
|
||||
total_calories=overall_metrics.total_calories,
|
||||
activity_count=overall_metrics.activity_count,
|
||||
breakdown=breakdown,
|
||||
type_breakdown=_get_type_breakdown(db, user_id, start_of_week, end_of_week, activity_type)
|
||||
)
|
||||
|
||||
def get_monthly_summary(db: Session, user_id: int, target_date: date, activity_type: str | None = None) -> MonthlySummaryResponse:
|
||||
start_of_month = target_date.replace(day=1)
|
||||
next_month = (start_of_month + timedelta(days=32)).replace(day=1)
|
||||
end_of_month = next_month
|
||||
|
||||
query = db.query(
|
||||
extract('week', Activity.start_time).label('week_number'),
|
||||
func.coalesce(func.sum(Activity.distance), 0).label('total_distance'),
|
||||
func.coalesce(func.sum(Activity.total_timer_time), 0.0).label('total_duration'),
|
||||
func.coalesce(func.sum(Activity.elevation_gain), 0).label('total_elevation_gain'),
|
||||
func.coalesce(func.sum(Activity.calories), 0).label('total_calories'),
|
||||
func.count(Activity.id).label('activity_count')
|
||||
).filter(
|
||||
Activity.user_id == user_id,
|
||||
Activity.start_time >= start_of_month,
|
||||
Activity.start_time < end_of_month
|
||||
)
|
||||
|
||||
activity_type_id = None
|
||||
if activity_type:
|
||||
activity_type_id = ACTIVITY_NAME_TO_ID.get(activity_type.lower())
|
||||
if activity_type_id is not None:
|
||||
query = query.filter(Activity.activity_type == activity_type_id)
|
||||
else:
|
||||
query = query.filter(Activity.id == -1) # Force no results
|
||||
|
||||
query = query.group_by(
|
||||
extract('week', Activity.start_time)
|
||||
).order_by(
|
||||
extract('week', Activity.start_time)
|
||||
)
|
||||
|
||||
weekly_results = query.all()
|
||||
breakdown = []
|
||||
overall_metrics = SummaryMetrics()
|
||||
|
||||
for week_data in weekly_results:
|
||||
week_summary = WeekSummary(
|
||||
week_number=int(week_data.week_number),
|
||||
total_distance=float(week_data.total_distance),
|
||||
total_duration=float(week_data.total_duration),
|
||||
total_elevation_gain=float(week_data.total_elevation_gain),
|
||||
total_calories=float(week_data.total_calories),
|
||||
activity_count=int(week_data.activity_count)
|
||||
)
|
||||
breakdown.append(week_summary)
|
||||
overall_metrics.total_distance += week_summary.total_distance
|
||||
overall_metrics.total_duration += week_summary.total_duration
|
||||
overall_metrics.total_elevation_gain += week_summary.total_elevation_gain
|
||||
overall_metrics.total_calories += week_summary.total_calories
|
||||
overall_metrics.activity_count += week_summary.activity_count
|
||||
|
||||
return MonthlySummaryResponse(
|
||||
total_distance=overall_metrics.total_distance,
|
||||
total_duration=overall_metrics.total_duration,
|
||||
total_elevation_gain=overall_metrics.total_elevation_gain,
|
||||
total_calories=overall_metrics.total_calories,
|
||||
activity_count=overall_metrics.activity_count,
|
||||
breakdown=breakdown,
|
||||
type_breakdown=_get_type_breakdown(db, user_id, start_of_month, end_of_month, activity_type)
|
||||
)
|
||||
|
||||
def get_yearly_summary(db: Session, user_id: int, year: int, activity_type: str | None = None) -> YearlySummaryResponse:
|
||||
start_of_year = date(year, 1, 1)
|
||||
end_of_year = date(year + 1, 1, 1)
|
||||
|
||||
query = db.query(
|
||||
extract('month', Activity.start_time).label('month_number'),
|
||||
func.coalesce(func.sum(Activity.distance), 0).label('total_distance'),
|
||||
func.coalesce(func.sum(Activity.total_timer_time), 0.0).label('total_duration'),
|
||||
func.coalesce(func.sum(Activity.elevation_gain), 0).label('total_elevation_gain'),
|
||||
func.coalesce(func.sum(Activity.calories), 0).label('total_calories'),
|
||||
func.count(Activity.id).label('activity_count')
|
||||
).filter(
|
||||
Activity.user_id == user_id,
|
||||
Activity.start_time >= start_of_year,
|
||||
Activity.start_time < end_of_year
|
||||
)
|
||||
|
||||
activity_type_id = None
|
||||
if activity_type:
|
||||
activity_type_id = ACTIVITY_NAME_TO_ID.get(activity_type.lower())
|
||||
if activity_type_id is not None:
|
||||
query = query.filter(Activity.activity_type == activity_type_id)
|
||||
else:
|
||||
query = query.filter(Activity.id == -1) # Force no results
|
||||
|
||||
query = query.group_by(
|
||||
extract('month', Activity.start_time)
|
||||
).order_by(
|
||||
extract('month', Activity.start_time)
|
||||
)
|
||||
|
||||
monthly_results = query.all()
|
||||
breakdown = []
|
||||
overall_metrics = SummaryMetrics()
|
||||
|
||||
month_map = {month.month_number: month for month in monthly_results}
|
||||
|
||||
for i in range(1, 13):
|
||||
month_data = month_map.get(i)
|
||||
if month_data:
|
||||
month_summary = MonthSummary(
|
||||
month_number=i,
|
||||
total_distance=float(month_data.total_distance),
|
||||
total_duration=float(month_data.total_duration),
|
||||
total_elevation_gain=float(month_data.total_elevation_gain),
|
||||
total_calories=float(month_data.total_calories),
|
||||
activity_count=int(month_data.activity_count)
|
||||
)
|
||||
breakdown.append(month_summary)
|
||||
overall_metrics.total_distance += month_summary.total_distance
|
||||
overall_metrics.total_duration += month_summary.total_duration
|
||||
overall_metrics.total_elevation_gain += month_summary.total_elevation_gain
|
||||
overall_metrics.total_calories += month_summary.total_calories
|
||||
overall_metrics.activity_count += month_summary.activity_count
|
||||
else:
|
||||
breakdown.append(MonthSummary(month_number=i))
|
||||
return YearlySummaryResponse(
|
||||
total_distance=overall_metrics.total_distance,
|
||||
total_duration=overall_metrics.total_duration,
|
||||
total_elevation_gain=overall_metrics.total_elevation_gain,
|
||||
total_calories=overall_metrics.total_calories,
|
||||
activity_count=overall_metrics.activity_count,
|
||||
breakdown=breakdown,
|
||||
type_breakdown=_get_type_breakdown(db, user_id, start_of_year, end_of_year, activity_type)
|
||||
)
|
||||
|
||||
def get_lifetime_summary(db: Session, user_id: int, activity_type: str | None = None) -> LifetimeSummaryResponse:
|
||||
# Base query for overall metrics and yearly breakdown
|
||||
base_metrics_query = db.query(
|
||||
func.coalesce(func.sum(Activity.distance), 0.0).label('total_distance'),
|
||||
func.coalesce(func.sum(Activity.total_timer_time), 0.0).label('total_duration'),
|
||||
func.coalesce(func.sum(Activity.elevation_gain), 0.0).label('total_elevation_gain'),
|
||||
func.coalesce(func.sum(Activity.calories), 0.0).label('total_calories'),
|
||||
func.count(Activity.id).label('activity_count')
|
||||
).filter(Activity.user_id == user_id)
|
||||
|
||||
# Apply activity type filter if provided
|
||||
activity_type_id_filter = None
|
||||
if activity_type:
|
||||
activity_type_id_filter = ACTIVITY_NAME_TO_ID.get(activity_type.lower())
|
||||
if activity_type_id_filter is not None:
|
||||
base_metrics_query = base_metrics_query.filter(Activity.activity_type == activity_type_id_filter)
|
||||
else:
|
||||
# Invalid activity type, force no results for metrics
|
||||
base_metrics_query = base_metrics_query.filter(Activity.id == -1)
|
||||
|
||||
overall_totals = base_metrics_query.one_or_none()
|
||||
|
||||
# Yearly breakdown query
|
||||
yearly_breakdown_query = db.query(
|
||||
extract('year', Activity.start_time).label('year_number'),
|
||||
func.coalesce(func.sum(Activity.distance), 0.0).label('total_distance'),
|
||||
func.coalesce(func.sum(Activity.total_timer_time), 0.0).label('total_duration'),
|
||||
func.coalesce(func.sum(Activity.elevation_gain), 0.0).label('total_elevation_gain'),
|
||||
func.coalesce(func.sum(Activity.calories), 0.0).label('total_calories'),
|
||||
func.count(Activity.id).label('activity_count')
|
||||
).filter(Activity.user_id == user_id)
|
||||
|
||||
if activity_type: # Apply same activity type filter to breakdown
|
||||
if activity_type_id_filter is not None:
|
||||
yearly_breakdown_query = yearly_breakdown_query.filter(Activity.activity_type == activity_type_id_filter)
|
||||
else:
|
||||
yearly_breakdown_query = yearly_breakdown_query.filter(Activity.id == -1) # Force no results
|
||||
|
||||
yearly_breakdown_query = yearly_breakdown_query.group_by(
|
||||
extract('year', Activity.start_time)
|
||||
).order_by(
|
||||
extract('year', Activity.start_time).desc() # Show recent years first
|
||||
)
|
||||
|
||||
yearly_results = yearly_breakdown_query.all()
|
||||
breakdown_list = []
|
||||
for row in yearly_results:
|
||||
breakdown_list.append(YearlyPeriodSummary(
|
||||
year_number=int(row.year_number),
|
||||
total_distance=float(row.total_distance),
|
||||
total_duration=float(row.total_duration),
|
||||
total_elevation_gain=float(row.total_elevation_gain),
|
||||
total_calories=float(row.total_calories),
|
||||
activity_count=int(row.activity_count)
|
||||
))
|
||||
|
||||
# Handle case where overall_totals might be None (e.g., no activities at all for the user/filter)
|
||||
if overall_totals:
|
||||
response = LifetimeSummaryResponse(
|
||||
total_distance=float(overall_totals.total_distance),
|
||||
total_duration=float(overall_totals.total_duration),
|
||||
total_elevation_gain=float(overall_totals.total_elevation_gain),
|
||||
total_calories=float(overall_totals.total_calories),
|
||||
activity_count=int(overall_totals.activity_count),
|
||||
breakdown=breakdown_list,
|
||||
type_breakdown=_get_type_breakdown(db, user_id, date.min, date.max, activity_type)
|
||||
)
|
||||
else: # No activities matching criteria
|
||||
response = LifetimeSummaryResponse(
|
||||
breakdown=[], # Empty breakdown
|
||||
type_breakdown=[] # Empty type breakdown or None based on _get_type_breakdown behavior
|
||||
)
|
||||
# If activity_type was specified but invalid, _get_type_breakdown might return None
|
||||
# If activity_type was valid but no activities, it should return []
|
||||
# If activity_type was None and no activities, it should return []
|
||||
# Let's ensure type_breakdown is [] if overall_totals is None and activity_type was not invalid for _get_type_breakdown
|
||||
if activity_type and activity_type_id_filter is None: # Invalid activity type for main summary
|
||||
response.type_breakdown = None
|
||||
else:
|
||||
response.type_breakdown = _get_type_breakdown(db, user_id, date.min, date.max, activity_type)
|
||||
|
||||
|
||||
return response
|
||||
76
backend/app/activity_summaries/router.py
Normal file
76
backend/app/activity_summaries/router.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from fastapi import APIRouter, Depends, Security, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Annotated, Callable, Union
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
import core.database as core_database
|
||||
import session.security as session_security
|
||||
import users.user.dependencies as users_dependencies
|
||||
import activity_summaries.crud as summary_crud
|
||||
import activity_summaries.schema as summary_schema
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get(
|
||||
"/user/{user_id}/{view_type}",
|
||||
response_model=Union[
|
||||
summary_schema.WeeklySummaryResponse,
|
||||
summary_schema.MonthlySummaryResponse,
|
||||
summary_schema.YearlySummaryResponse,
|
||||
summary_schema.LifetimeSummaryResponse
|
||||
],
|
||||
summary="Get Activity Summary by Period",
|
||||
description="Retrieves aggregated activity summaries (weekly, monthly, yearly, or lifetime) for a specific user.",
|
||||
)
|
||||
async def read_activity_summary(
|
||||
user_id: int,
|
||||
view_type: str,
|
||||
validate_user_id: Annotated[Callable, Depends(users_dependencies.validate_user_id)],
|
||||
check_scopes: Annotated[
|
||||
Callable, Security(session_security.check_scopes, scopes=["activities:read"])
|
||||
],
|
||||
token_user_id: Annotated[
|
||||
int,
|
||||
Depends(session_security.get_user_id_from_access_token),
|
||||
],
|
||||
db: Annotated[
|
||||
Session,
|
||||
Depends(core_database.get_db),
|
||||
],
|
||||
target_date_str: Annotated[str | None, Query(alias="date", description="Target date (YYYY-MM-DD) for week/month view. Defaults to today.")] = None,
|
||||
target_year: Annotated[int | None, Query(alias="year", description="Target year for year view. Defaults to current year.")] = None,
|
||||
activity_type: Annotated[str | None, Query(alias="type", description="Filter summary by activity type name.")] = None,
|
||||
):
|
||||
if user_id != token_user_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Not authorized to access summaries for this user",
|
||||
)
|
||||
|
||||
today = datetime.now(timezone.utc).date()
|
||||
|
||||
if view_type == "week":
|
||||
try:
|
||||
current_date = date.fromisoformat(target_date_str) if target_date_str else today
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date format. Use YYYY-MM-DD.")
|
||||
return summary_crud.get_weekly_summary(db=db, user_id=user_id, target_date=current_date, activity_type=activity_type)
|
||||
elif view_type == "month":
|
||||
try:
|
||||
current_date = date.fromisoformat(target_date_str) if target_date_str else today
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid date format. Use YYYY-MM-DD.")
|
||||
month_start_date = current_date.replace(day=1)
|
||||
return summary_crud.get_monthly_summary(db=db, user_id=user_id, target_date=month_start_date, activity_type=activity_type)
|
||||
elif view_type == "year":
|
||||
current_year = target_year if target_year else today.year
|
||||
if not (1900 <= current_year <= 2100):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid year. Must be between 1900 and 2100.")
|
||||
return summary_crud.get_yearly_summary(db=db, user_id=user_id, year=current_year, activity_type=activity_type)
|
||||
elif view_type == "lifetime":
|
||||
return summary_crud.get_lifetime_summary(db=db, user_id=user_id, activity_type=activity_type)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid view_type. Must be 'week', 'month', 'year', or 'lifetime'.",
|
||||
)
|
||||
47
backend/app/activity_summaries/schema.py
Normal file
47
backend/app/activity_summaries/schema.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
|
||||
class SummaryMetrics(BaseModel):
|
||||
total_distance: float = 0.0
|
||||
total_duration: float = 0.0
|
||||
total_elevation_gain: float = 0.0
|
||||
activity_count: int = 0
|
||||
total_calories: float = 0.0
|
||||
|
||||
class DaySummary(SummaryMetrics):
|
||||
day_of_week: int # 0=Monday, 6=Sunday
|
||||
|
||||
class WeekSummary(SummaryMetrics):
|
||||
week_number: int
|
||||
|
||||
class MonthSummary(SummaryMetrics):
|
||||
month_number: int # 1=January, 12=December
|
||||
|
||||
class YearlyPeriodSummary(SummaryMetrics):
|
||||
year_number: int
|
||||
|
||||
class TypeBreakdownItem(SummaryMetrics):
|
||||
"""Schema for breakdown by activity type."""
|
||||
activity_type_id: int
|
||||
activity_type: str
|
||||
|
||||
class WeeklySummaryResponse(SummaryMetrics):
|
||||
breakdown: List[DaySummary]
|
||||
type_breakdown: List[TypeBreakdownItem] | None = None
|
||||
|
||||
class MonthlySummaryResponse(SummaryMetrics):
|
||||
breakdown: List[WeekSummary]
|
||||
type_breakdown: List[TypeBreakdownItem] | None = None
|
||||
|
||||
class YearlySummaryResponse(SummaryMetrics):
|
||||
breakdown: List[MonthSummary]
|
||||
type_breakdown: List[TypeBreakdownItem] | None = None
|
||||
|
||||
class LifetimeSummaryResponse(SummaryMetrics):
|
||||
breakdown: List[YearlyPeriodSummary]
|
||||
type_breakdown: List[TypeBreakdownItem] | None = None
|
||||
|
||||
class SummaryParams(BaseModel):
|
||||
user_id: int
|
||||
start_date: str
|
||||
end_date: str
|
||||
@@ -13,6 +13,7 @@ import activities.activity_laps.router as activity_laps_router
|
||||
import activities.activity_sets.router as activity_sets_router
|
||||
import activities.activity_streams.router as activity_streams_router
|
||||
import activities.activity_workout_steps.router as activity_workout_steps_router
|
||||
import activity_summaries.router as activity_summaries_router
|
||||
import gears.router as gears_router
|
||||
import followers.router as followers_router
|
||||
import strava.router as strava_router
|
||||
@@ -100,6 +101,12 @@ router.include_router(
|
||||
tags=["activity_workout_steps"],
|
||||
dependencies=[Depends(session_security.validate_access_token)],
|
||||
)
|
||||
router.include_router(
|
||||
activity_summaries_router.router,
|
||||
prefix=core_config.ROOT_PATH + "/summaries",
|
||||
tags=["summaries"],
|
||||
dependencies=[Depends(session_security.validate_access_token)],
|
||||
)
|
||||
router.include_router(
|
||||
gears_router.router,
|
||||
prefix=core_config.ROOT_PATH + "/gears",
|
||||
|
||||
@@ -1 +1 @@
|
||||
VITE_ENDURAIN_HOST=MY_APP_ENDURAIN_HOST
|
||||
VITE_ENDURAIN_HOST=http://localhost:8080
|
||||
@@ -1,122 +1,170 @@
|
||||
<template>
|
||||
<nav class="navbar bg-body-tertiary text-center" v-if="authStore.isAuthenticated">
|
||||
<div class="container-fluid justify-content-around">
|
||||
<router-link :to="{ name: 'home' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'fa-home']" />
|
||||
<br>
|
||||
{{ $t("navbarBottomMobileComponent.home") }}
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'activities' }" class="nav-link link-body-emphasis">
|
||||
<!-- Corrected route name -->
|
||||
<font-awesome-icon :icon="['fas', 'fa-person-running']" />
|
||||
<br />
|
||||
{{ $t('navbarBottomMobileComponent.activities') }}
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'gears' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'fa-bicycle']" />
|
||||
<br>
|
||||
{{ $t("navbarBottomMobileComponent.gear") }}
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'health' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'fa-heart']" />
|
||||
<br>
|
||||
{{ $t("navbarBottomMobileComponent.health") }}
|
||||
</router-link>
|
||||
<button class="nav-link link-body-emphasis" id="offcanvasNavbarButton" type="button" data-bs-toggle="offcanvas" data-bs-target="#offcanvasNavbar" aria-controls="offcanvasNavbar" aria-label="Toggle navigation">
|
||||
<font-awesome-icon :icon="['fas', 'bars']" />
|
||||
<nav class="navbar bg-body-tertiary text-center" v-if="authStore.isAuthenticated">
|
||||
<div class="container-fluid justify-content-around">
|
||||
<router-link :to="{ name: 'home' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'fa-home']" />
|
||||
<br />
|
||||
{{ $t('navbarBottomMobileComponent.home') }}
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'activities' }" class="nav-link link-body-emphasis">
|
||||
<!-- Corrected route name -->
|
||||
<font-awesome-icon :icon="['fas', 'fa-person-running']" />
|
||||
<br />
|
||||
{{ $t('navbarBottomMobileComponent.activities') }}
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'summary' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'fa-calendar-alt']" />
|
||||
<br />
|
||||
{{ $t('navbarBottomMobileComponent.summary') }}
|
||||
</router-link>
|
||||
<router-link :to="{ name: 'gears' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'fa-bicycle']" />
|
||||
<br />
|
||||
{{ $t('navbarBottomMobileComponent.gear') }}
|
||||
</router-link>
|
||||
<!--<router-link :to="{ name: 'menu' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'bars']" size="2x"/>
|
||||
<br>
|
||||
{{ $t("navbarBottomMobileComponent.menu") }}
|
||||
</button>
|
||||
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvasNavbar" aria-labelledby="offcanvasNavbarLabel">
|
||||
<div class="offcanvas-header">
|
||||
<h3 class="offcanvas-title" id="offcanvasNavbarLabel">{{ $t("navbarBottomMobileComponent.menu") }}</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="offcanvas-body">
|
||||
<ul class="navbar-nav justify-content-end flex-grow-1 pe-3">
|
||||
<li class="nav-item">
|
||||
<router-link
|
||||
:to="{ name: 'search' }"
|
||||
class="nav-link link-body-emphasis w-100 py-3 fs-5"
|
||||
@click="closeOffcanvas"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'magnifying-glass']" />
|
||||
<span class="ms-1">{{ $t('navbarComponent.search') }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link :to="{ name: 'settings' }" class="nav-link link-body-emphasis w-100 py-3 fs-5" @click="closeOffcanvas">
|
||||
<font-awesome-icon :icon="['fas', 'fa-gear']" />
|
||||
<span class="ms-1">{{ $t("navbarComponent.settings") }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link :to="{ name: 'user', params: { id: authStore.user.id } }" class="nav-link link-body-emphasis w-100 py-3 fs-5" @click="closeOffcanvas">
|
||||
<UserAvatarComponent :user="authStore.user" :width=24 :height=24 :alignTop=2 />
|
||||
<span class="ms-2">{{ $t("navbarComponent.profile") }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<hr>
|
||||
</li>
|
||||
<li>
|
||||
<a class="nav-link link-body-emphasis w-100 py-3 fs-5" href="#" @click="handleLogout">
|
||||
<font-awesome-icon :icon="['fas', 'fa-sign-out-alt']" />
|
||||
<span class="ms-2">{{ $t("navbarComponent.logout") }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</router-link>-->
|
||||
<button
|
||||
class="nav-link link-body-emphasis"
|
||||
id="offcanvasNavbarButton"
|
||||
type="button"
|
||||
data-bs-toggle="offcanvas"
|
||||
data-bs-target="#offcanvasNavbar"
|
||||
aria-controls="offcanvasNavbar"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'bars']" />
|
||||
<br />
|
||||
{{ $t('navbarBottomMobileComponent.menu') }}
|
||||
</button>
|
||||
<div
|
||||
class="offcanvas offcanvas-end"
|
||||
tabindex="-1"
|
||||
id="offcanvasNavbar"
|
||||
aria-labelledby="offcanvasNavbarLabel"
|
||||
>
|
||||
<div class="offcanvas-header">
|
||||
<h3 class="offcanvas-title" id="offcanvasNavbarLabel">
|
||||
{{ $t('navbarBottomMobileComponent.menu') }}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
data-bs-dismiss="offcanvas"
|
||||
aria-label="Close"
|
||||
></button>
|
||||
</div>
|
||||
</nav>
|
||||
<FooterComponent v-else/>
|
||||
<div class="offcanvas-body">
|
||||
<ul class="navbar-nav justify-content-end flex-grow-1 pe-3">
|
||||
<li class="nav-item">
|
||||
<router-link
|
||||
:to="{ name: 'health' }"
|
||||
class="nav-link link-body-emphasis w-100 py-3 fs-5"
|
||||
@click="closeOffcanvas"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'fa-heart']" />
|
||||
<span class="ms-1">{{ $t('navbarComponent.health') }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link
|
||||
:to="{ name: 'search' }"
|
||||
class="nav-link link-body-emphasis w-100 py-3 fs-5"
|
||||
@click="closeOffcanvas"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'magnifying-glass']" />
|
||||
<span class="ms-1">{{ $t('navbarComponent.search') }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link
|
||||
:to="{ name: 'settings' }"
|
||||
class="nav-link link-body-emphasis w-100 py-3 fs-5"
|
||||
@click="closeOffcanvas"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'fa-gear']" />
|
||||
<span class="ms-1">{{ $t('navbarComponent.settings') }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<router-link
|
||||
:to="{ name: 'user', params: { id: authStore.user.id } }"
|
||||
class="nav-link link-body-emphasis w-100 py-3 fs-5"
|
||||
@click="closeOffcanvas"
|
||||
>
|
||||
<UserAvatarComponent
|
||||
:user="authStore.user"
|
||||
:width="24"
|
||||
:height="24"
|
||||
:alignTop="2"
|
||||
/>
|
||||
<span class="ms-2">{{ $t('navbarComponent.profile') }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<hr />
|
||||
</li>
|
||||
<li>
|
||||
<a class="nav-link link-body-emphasis w-100 py-3 fs-5" href="#" @click="handleLogout">
|
||||
<font-awesome-icon :icon="['fas', 'fa-sign-out-alt']" />
|
||||
<span class="ms-2">{{ $t('navbarComponent.logout') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<FooterComponent v-else />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
// Importing the i18n
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
// import the stores
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
// Import the components
|
||||
import FooterComponent from "@/components/FooterComponent.vue";
|
||||
import UserAvatarComponent from "@/components/Users/UserAvatarComponent.vue";
|
||||
import FooterComponent from '@/components/FooterComponent.vue'
|
||||
import UserAvatarComponent from '@/components/Users/UserAvatarComponent.vue'
|
||||
// Import Notivue push
|
||||
import { push } from "notivue";
|
||||
import { push } from 'notivue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
UserAvatarComponent,
|
||||
FooterComponent,
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const { locale, t } = useI18n();
|
||||
components: {
|
||||
UserAvatarComponent,
|
||||
FooterComponent
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await authStore.logoutUser(router, locale);
|
||||
} catch (error) {
|
||||
push.error(`${t("navbarComponent.errorLogout")} - ${error}`);
|
||||
}
|
||||
}
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await authStore.logoutUser(router, locale)
|
||||
} catch (error) {
|
||||
push.error(`${t('navbarComponent.errorLogout')} - ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
function closeOffcanvas() {
|
||||
const navbarToggler = document.querySelector("#offcanvasNavbarButton");
|
||||
const navbarCollapse = document.querySelector("#offcanvasNavbar");
|
||||
if (navbarToggler && navbarCollapse.classList.contains("show")) {
|
||||
navbarToggler.click();
|
||||
}
|
||||
}
|
||||
function closeOffcanvas() {
|
||||
const navbarToggler = document.querySelector('#offcanvasNavbarButton')
|
||||
const navbarCollapse = document.querySelector('#offcanvasNavbar')
|
||||
if (navbarToggler && navbarCollapse.classList.contains('show')) {
|
||||
navbarToggler.click()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authStore,
|
||||
closeOffcanvas,
|
||||
handleLogout,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
return {
|
||||
authStore,
|
||||
closeOffcanvas,
|
||||
handleLogout
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,136 +1,154 @@
|
||||
<template>
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
||||
<div class="container-fluid">
|
||||
<!-- Navbar brand + search in the left -->
|
||||
<router-link :to="{ name: 'home' }" class="navbar-brand d-flex align-items-center">
|
||||
<img src="/logo/logo.svg" alt="Logo" width="24" height="24" class="me-2 rounded">
|
||||
Endurain
|
||||
</router-link>
|
||||
|
||||
<div class="d-none d-lg-flex w-100 justify-content-between">
|
||||
<div class="navbar-nav me-auto" v-if="authStore.isAuthenticated">
|
||||
<NavbarPipeComponent />
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
||||
<div class="container-fluid">
|
||||
<!-- Navbar brand + search in the left -->
|
||||
<router-link :to="{ name: 'home' }" class="navbar-brand d-flex align-items-center">
|
||||
<img src="/logo/logo.svg" alt="Logo" width="24" height="24" class="me-2 rounded" />
|
||||
Endurain
|
||||
</router-link>
|
||||
|
||||
<!-- Search -->
|
||||
<router-link :to="{ name: 'search' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'magnifying-glass']" />
|
||||
<span class="ms-1">
|
||||
{{ $t("navbarComponent.search") }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="d-none d-lg-flex w-100 justify-content-between">
|
||||
<div class="navbar-nav me-auto" v-if="authStore.isAuthenticated">
|
||||
<NavbarPipeComponent />
|
||||
|
||||
<!-- Navigation middle -->
|
||||
<div class="navbar-nav mx-auto" v-if="authStore.isAuthenticated">
|
||||
<!-- if is logged in show activities button -->
|
||||
<router-link :to="{ name: 'activities' }" class="nav-link link-body-emphasis">
|
||||
<!-- Corrected route name -->
|
||||
<font-awesome-icon :icon="['fas', 'fa-person-running']" />
|
||||
<span class="ms-1">
|
||||
{{ $t('navbarComponent.activities') }}
|
||||
</span>
|
||||
</router-link>
|
||||
<!-- if is logged in show gears button -->
|
||||
<router-link :to="{ name: 'gears' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'fa-bicycle']" />
|
||||
<span class="ms-1">
|
||||
{{ $t("navbarComponent.gear") }}
|
||||
</span>
|
||||
</router-link>
|
||||
<!-- if is logged in show health button -->
|
||||
<router-link :to="{ name: 'health' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'fa-heart']" />
|
||||
<span class="ms-1">
|
||||
{{ $t("navbarComponent.health") }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Navigation end -->
|
||||
<div class="navbar-nav ms-auto" v-if="authStore.isAuthenticated">
|
||||
<NavbarLanguageSwitcherComponent />
|
||||
|
||||
<NavbarThemeSwitcherComponent />
|
||||
|
||||
<!-- Settings button -->
|
||||
<router-link :to="{ name: 'settings' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'fa-gear']" />
|
||||
<span class="ms-1 d-lg-none">{{ $t("navbarComponent.settings") }}</span>
|
||||
</router-link>
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<UserAvatarComponent :user="authStore.user" :width=24 :height=24 :alignTop=2 />
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<router-link :to="{ name: 'user', params: { id: authStore.user.id } }" class="dropdown-item">
|
||||
<font-awesome-icon :icon="['fas', 'circle-user']" />
|
||||
<span class="ms-2">{{ $t("navbarComponent.profile") }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="dropdown-divider">
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click="handleLogout">
|
||||
<font-awesome-icon :icon="['fas', 'fa-sign-out-alt']" />
|
||||
<span class="ms-2">{{ $t("navbarComponent.logout") }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-nav" v-if="!authStore.isAuthenticated">
|
||||
<router-link :to="{ name: 'login' }" class="nav-link link-body-emphasis d-flex align-items-center">
|
||||
<font-awesome-icon :icon="['fas', 'fa-sign-in-alt']" />
|
||||
<span class="ms-1">{{ $t("navbarComponent.login") }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
<!-- Search -->
|
||||
<router-link :to="{ name: 'search' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'magnifying-glass']" />
|
||||
<span class="ms-1">
|
||||
{{ $t('navbarComponent.search') }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Navigation middle -->
|
||||
<div class="navbar-nav mx-auto" v-if="authStore.isAuthenticated">
|
||||
<!-- if is logged in show activities button -->
|
||||
<router-link :to="{ name: 'activities' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'fa-person-running']" />
|
||||
<span class="ms-1">
|
||||
{{ $t('navbarComponent.activities') }}
|
||||
</span>
|
||||
</router-link>
|
||||
<!-- Summary link -->
|
||||
<router-link :to="{ name: 'summary' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'fa-calendar-alt']" />
|
||||
<span class="ms-1">
|
||||
{{ $t('navbarComponent.summary') }}
|
||||
</span>
|
||||
</router-link>
|
||||
<!-- if is logged in show gears button -->
|
||||
<router-link :to="{ name: 'gears' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'fa-bicycle']" />
|
||||
<span class="ms-1">
|
||||
{{ $t('navbarComponent.gear') }}
|
||||
</span>
|
||||
</router-link>
|
||||
<!-- if is logged in show health button -->
|
||||
<router-link :to="{ name: 'health' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'fa-heart']" />
|
||||
<span class="ms-1">
|
||||
{{ $t('navbarComponent.health') }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Navigation end -->
|
||||
<div class="navbar-nav ms-auto" v-if="authStore.isAuthenticated">
|
||||
<NavbarLanguageSwitcherComponent />
|
||||
|
||||
<NavbarThemeSwitcherComponent />
|
||||
|
||||
<!-- Settings button -->
|
||||
<router-link :to="{ name: 'settings' }" class="nav-link link-body-emphasis">
|
||||
<font-awesome-icon :icon="['fas', 'fa-gear']" />
|
||||
<span class="ms-1 d-lg-none">{{ $t('navbarComponent.settings') }}</span>
|
||||
</router-link>
|
||||
|
||||
<li class="nav-item dropdown">
|
||||
<a
|
||||
class="nav-link dropdown-toggle"
|
||||
href="#"
|
||||
role="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<UserAvatarComponent :user="authStore.user" :width="24" :height="24" :alignTop="2" />
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<router-link
|
||||
:to="{ name: 'user', params: { id: authStore.user.id } }"
|
||||
class="dropdown-item"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'circle-user']" />
|
||||
<span class="ms-2">{{ $t('navbarComponent.profile') }}</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li>
|
||||
<hr class="dropdown-divider" />
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click="handleLogout">
|
||||
<font-awesome-icon :icon="['fas', 'fa-sign-out-alt']" />
|
||||
<span class="ms-2">{{ $t('navbarComponent.logout') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</div>
|
||||
</div>
|
||||
<div class="navbar-nav" v-if="!authStore.isAuthenticated">
|
||||
<router-link
|
||||
:to="{ name: 'login' }"
|
||||
class="nav-link link-body-emphasis d-flex align-items-center"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'fa-sign-in-alt']" />
|
||||
<span class="ms-1">{{ $t('navbarComponent.login') }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
// Importing the i18n
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useI18n } from 'vue-i18n'
|
||||
// import the stores
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
// Import Notivue push
|
||||
import { push } from "notivue";
|
||||
import { push } from 'notivue'
|
||||
|
||||
import UserAvatarComponent from "@/components/Users/UserAvatarComponent.vue";
|
||||
import NavbarPipeComponent from "@/components/Navbar/NavbarPipeComponent.vue";
|
||||
import NavbarThemeSwitcherComponent from "@/components/Navbar/NavbarThemeSwitcherComponent.vue";
|
||||
import NavbarLanguageSwitcherComponent from "@/components/Navbar/NavbarLanguageSwitcherComponent.vue";
|
||||
import UserAvatarComponent from '@/components/Users/UserAvatarComponent.vue'
|
||||
import NavbarPipeComponent from '@/components/Navbar/NavbarPipeComponent.vue'
|
||||
import NavbarThemeSwitcherComponent from '@/components/Navbar/NavbarThemeSwitcherComponent.vue'
|
||||
import NavbarLanguageSwitcherComponent from '@/components/Navbar/NavbarLanguageSwitcherComponent.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
UserAvatarComponent,
|
||||
NavbarPipeComponent,
|
||||
NavbarThemeSwitcherComponent,
|
||||
NavbarLanguageSwitcherComponent,
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const { locale, t } = useI18n();
|
||||
components: {
|
||||
UserAvatarComponent,
|
||||
NavbarPipeComponent,
|
||||
NavbarThemeSwitcherComponent,
|
||||
NavbarLanguageSwitcherComponent
|
||||
},
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await authStore.logoutUser(router, locale);
|
||||
} catch (error) {
|
||||
push.error(`${t("navbarComponent.errorLogout")} - ${error}`);
|
||||
}
|
||||
}
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await authStore.logoutUser(router, locale)
|
||||
} catch (error) {
|
||||
push.error(`${t('navbarComponent.errorLogout')} - ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authStore,
|
||||
handleLogout,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
return {
|
||||
authStore,
|
||||
handleLogout
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"unitsMph": "mph",
|
||||
"unitsLbs": "lliures",
|
||||
"labelWeightInLbs": "Pes en lliures",
|
||||
"unitsCalories": "cal",
|
||||
"unitsCalories": "kcal",
|
||||
"unitsBpm": "bpm",
|
||||
"labelHRinBpm": "Bategs en bpm",
|
||||
"unitsWattsShort": "W",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"unitsMph": "mph",
|
||||
"unitsLbs": "lb",
|
||||
"labelWeightInLbs": "Gewicht in lbs",
|
||||
"unitsCalories": "cal",
|
||||
"unitsCalories": "kcal",
|
||||
"unitsBpm": "bpm",
|
||||
"labelHRinBpm": "Herzfrequenz in bpm",
|
||||
"unitsWattsShort": "W",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"unitsMph": "mph",
|
||||
"unitsLbs": "lbs",
|
||||
"labelWeightInLbs": "Peso en lbs",
|
||||
"unitsCalories": "cal",
|
||||
"unitsCalories": "kcal",
|
||||
"unitsBpm": "ppm",
|
||||
"labelHRinBpm": "Frecuencia cardíaca en ppm",
|
||||
"unitsWattsShort": "W",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"unitsMph": "mile/h",
|
||||
"unitsLbs": "livres",
|
||||
"labelWeightInLbs": "Poids en livres",
|
||||
"unitsCalories": "cal",
|
||||
"unitsCalories": "kcal",
|
||||
"unitsBpm": "bpm",
|
||||
"labelHRinBpm": "Fréquence cardiaque en bpm",
|
||||
"unitsWattsShort": "W",
|
||||
|
||||
@@ -409,6 +409,7 @@ import usNotFoundView from './us/notFoundView.json';
|
||||
import usSearchView from './us/searchView.json';
|
||||
import usSettingsView from './us/settingsView.json';
|
||||
import usUserView from './us/userView.json';
|
||||
import usSummaryView from './us/summaryView.json';
|
||||
|
||||
// Constructing the messages structure
|
||||
const messages = {
|
||||
@@ -817,6 +818,7 @@ const messages = {
|
||||
searchView: usSearchView,
|
||||
settingsView: usSettingsView,
|
||||
userView: usUserView,
|
||||
summaryView: usSummaryView,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"unitsMph": "mph",
|
||||
"unitsLbs": "lbs",
|
||||
"labelWeightInLbs": "Gewicht in lbs",
|
||||
"unitsCalories": "cal",
|
||||
"unitsCalories": "kcal",
|
||||
"unitsBpm": "bpm",
|
||||
"labelHRinBpm": "Hartslag in bpm",
|
||||
"unitsWattsShort": "W",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"unitsMph": "mph",
|
||||
"unitsLbs": "las",
|
||||
"labelWeightInLbs": "Peso em libras",
|
||||
"unitsCalories": "cal",
|
||||
"unitsCalories": "kcal",
|
||||
"unitsBpm": "bpm",
|
||||
"labelHRinBpm": "Frequência cardíaca em bpm",
|
||||
"unitsWattsShort": "W",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"home": "Home",
|
||||
"activities": "Activities",
|
||||
"search": "Search",
|
||||
"gear": "Gear",
|
||||
"health": "Health",
|
||||
"menu": "Menu"
|
||||
}
|
||||
"home": "Home",
|
||||
"activities": "Activities",
|
||||
"summary": "Summary",
|
||||
"search": "Search",
|
||||
"gear": "Gear",
|
||||
"health": "Health",
|
||||
"menu": "Menu"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"search": "Search",
|
||||
"activities": "Activities",
|
||||
"summary": "Summary",
|
||||
"gear": "Gear",
|
||||
"health": "Health",
|
||||
"profile": "Profile",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"unitsMph": "mph",
|
||||
"unitsLbs": "lbs",
|
||||
"labelWeightInLbs": "Weight in lbs",
|
||||
"unitsCalories": "cal",
|
||||
"unitsCalories": "kcal",
|
||||
"unitsBpm": "bpm",
|
||||
"labelHRinBpm": "Heart rate in bpm",
|
||||
"unitsWattsShort": "W",
|
||||
|
||||
46
frontend/app/src/i18n/us/summaryView.json
Normal file
46
frontend/app/src/i18n/us/summaryView.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"title": "Activity Summary",
|
||||
"filterLabelActivityType": "Type",
|
||||
"filterOptionAllTypes": "All Types",
|
||||
"labelViewType": "View By",
|
||||
"optionWeekly": "Weekly",
|
||||
"optionMonthly": "Monthly",
|
||||
"optionYearly": "Yearly",
|
||||
"optionLifetime": "Lifetime",
|
||||
"labelSelectWeek": "Week",
|
||||
"labelSelectMonth": "Month",
|
||||
"labelSelectYear": "Year",
|
||||
"labelSelectLifetime": "Lifetime",
|
||||
"labelSelectPeriod": "Period",
|
||||
"loadingSummary": "Loading summary...",
|
||||
"loadingActivities": "Loading activities...",
|
||||
"headerSummaryFor": "Summary for {period}",
|
||||
"headerBreakdown": "Breakdown",
|
||||
"headerActivitiesInPeriod": "Activities in Period",
|
||||
"errorLoadingSummary": "Error loading summary: {error}",
|
||||
"errorLoadingActivities": "Error loading activities: {error}",
|
||||
"noDataForPeriod": "No data for this period.",
|
||||
"noActivitiesFound": "No activities found for the selected period.",
|
||||
"colDay": "Day",
|
||||
"colWeekNum": "Week #",
|
||||
"colMonth": "Month",
|
||||
"colDistance": "Distance",
|
||||
"colDuration": "Duration",
|
||||
"colElevation": "Elevation",
|
||||
"colCalories": "Calories",
|
||||
"colActivities": "Activities",
|
||||
"metricTotalDistance": "Total Distance",
|
||||
"metricTotalDuration": "Total Duration",
|
||||
"metricTotalElevation": "Total Elevation",
|
||||
"metricTotalCalories": "Total Calories",
|
||||
"metricTotalActivities": "Total Activities",
|
||||
"invalidDateSelected": "Invalid Date Selected",
|
||||
"invalidYearSelected": "Invalid year selected.",
|
||||
"noDateSelected": "No date selected.",
|
||||
"headerTypeBreakdown": "Breakdown by Type",
|
||||
"colActivityType": "Type",
|
||||
"headerYear": "Year {year}",
|
||||
"headerWeekStarting": "Week of {date}",
|
||||
"colYear": "Year",
|
||||
"invalidInputFormat": "Invalid input format. Please use YYYY-MM."
|
||||
}
|
||||
@@ -34,7 +34,12 @@ const router = createRouter({
|
||||
path: '/activities',
|
||||
name: 'activities',
|
||||
component: () => import('../views/ActivitiesView.vue'),
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/summary',
|
||||
name: 'summary',
|
||||
component: () => import('../views/SummaryView.vue'),
|
||||
},
|
||||
{
|
||||
path: "/health",
|
||||
name: "health",
|
||||
|
||||
25
frontend/app/src/services/summaryService.js
Normal file
25
frontend/app/src/services/summaryService.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { fetchGetRequest } from '@/utils/serviceUtils'
|
||||
|
||||
export const summaryService = {
|
||||
/**
|
||||
* Fetches activity summary data for a user based on view type and period.
|
||||
* @param {number} userId - The ID of the user.
|
||||
* @param {string} viewType - The type of summary ('week', 'month', 'year').
|
||||
* @param {object} params - Query parameters (e.g., { date: 'YYYY-MM-DD' } or { year: YYYY }).
|
||||
* @param {string | null} activityType - Optional activity type name to filter by.
|
||||
* @returns {Promise<object>} - The summary data.
|
||||
*/
|
||||
getSummary(userId, viewType, params = {}, activityType = null) { // Added activityType parameter
|
||||
const url = `summaries/user/${userId}/${viewType}`;
|
||||
const queryParams = new URLSearchParams(params); // Create params object
|
||||
|
||||
// Add activity type filter if provided
|
||||
if (activityType) {
|
||||
queryParams.append('type', activityType);
|
||||
}
|
||||
|
||||
const queryString = queryParams.toString();
|
||||
const fullUrl = queryString ? `${url}?${queryString}` : url;
|
||||
return fetchGetRequest(fullUrl);
|
||||
}
|
||||
}
|
||||
@@ -388,7 +388,24 @@ export function formatLocation(activity) {
|
||||
} else {
|
||||
locationParts.push(country);
|
||||
}
|
||||
}
|
||||
|
||||
return locationParts.join(""); // Join without extra spaces, comma is handled above
|
||||
}
|
||||
|
||||
return locationParts.join(""); // Join without extra spaces, comma is handled above
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a raw distance in meters based on the unit system.
|
||||
*
|
||||
* @param {number|null|undefined} meters - The distance in meters.
|
||||
* @param {number|string} unitSystem - The unit system to use (1 for metric, otherwise imperial).
|
||||
* @returns {string} The formatted distance string with appropriate units or a "No Data" label.
|
||||
*/
|
||||
export function formatRawDistance(meters, unitSystem) {
|
||||
if (meters === null || meters === undefined || meters < 0) {
|
||||
return i18n.global.t("generalItems.labelNoData");
|
||||
}
|
||||
if (Number(unitSystem) === 1) {
|
||||
return `${metersToKm(meters)} ${i18n.global.t("generalItems.unitsKm")}`;
|
||||
}
|
||||
return `${metersToMiles(meters)} ${i18n.global.t("generalItems.unitsMiles")}`;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ export function formatDateShort(dateString) {
|
||||
return date.toLocaleString(DateTime.DATE_SHORT);
|
||||
}
|
||||
|
||||
|
||||
export function formatDateMed(dateString) {
|
||||
// Create a DateTime object from the date string
|
||||
const date = DateTime.fromISO(dateString, { setZone: true });
|
||||
@@ -82,4 +81,139 @@ export function formatSecondsToMinutes(totalSeconds) {
|
||||
return `${hours}:${formattedMinutes}:${formattedSeconds}`;
|
||||
}
|
||||
return `${minutes}:${formattedSeconds}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the start date (Monday) of the week for a given JavaScript Date object, in UTC.
|
||||
* @param {Date} jsDate - The input JavaScript Date object.
|
||||
* @returns {Date} - The JavaScript Date object for the Monday of that week (UTC).
|
||||
*/
|
||||
export function getWeekStartDate(jsDate) {
|
||||
return DateTime.fromJSDate(jsDate, { zone: 'utc' }).startOf('week').toJSDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the end date (start of next week) for a given JavaScript Date object's week, in UTC.
|
||||
* This means it's the first day of the next week, making the range exclusive for the end date.
|
||||
* @param {Date} jsDate - The input JavaScript Date object.
|
||||
* @returns {Date} - The JavaScript Date object for the start of the next week (UTC).
|
||||
*/
|
||||
export function getWeekEndDate(jsDate) {
|
||||
return DateTime.fromJSDate(jsDate, { zone: 'utc' }).startOf('week').plus({ days: 7 }).toJSDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the start date (1st) of the month for a given JavaScript Date object, in UTC.
|
||||
* @param {Date} jsDate - The input JavaScript Date object.
|
||||
* @returns {Date} - The JavaScript Date object for the first day of that month (UTC).
|
||||
*/
|
||||
export function getMonthStartDate(jsDate) {
|
||||
return DateTime.fromJSDate(jsDate, { zone: 'utc' }).startOf('month').toJSDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the end date (start of next month) for a given JavaScript Date object's month, in UTC.
|
||||
* This means it's the first day of the next month, making the range exclusive for the end date.
|
||||
* @param {Date} jsDate - The input JavaScript Date object.
|
||||
* @returns {Date} - The JavaScript Date object for the start of the next month (UTC).
|
||||
*/
|
||||
export function getMonthEndDate(jsDate) {
|
||||
return DateTime.fromJSDate(jsDate, { zone: 'utc' }).startOf('month').plus({ months: 1 }).toJSDate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a JavaScript Date object into YYYY-MM-DD string (UTC).
|
||||
* Handles non-Date inputs by attempting to parse them.
|
||||
* @param {Date | string | number} jsDateInput - The input JavaScript Date object or a value parseable into a Date.
|
||||
* @returns {string} - The formatted date string (YYYY-MM-DD), or an empty string if input is invalid.
|
||||
*/
|
||||
export function formatDateToISOString(jsDateInput) {
|
||||
let dt;
|
||||
if (jsDateInput instanceof Date && !isNaN(jsDateInput.getTime())) {
|
||||
dt = DateTime.fromJSDate(jsDateInput, { zone: 'utc' });
|
||||
} else {
|
||||
// Attempt to parse if not a valid Date object (e.g., could be a date string)
|
||||
const parsedDate = new Date(jsDateInput);
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
console.error("formatDateToISOString received invalid date input:", jsDateInput);
|
||||
return "";
|
||||
}
|
||||
dt = DateTime.fromJSDate(parsedDate, { zone: 'utc' });
|
||||
}
|
||||
|
||||
if (dt.isValid) {
|
||||
return dt.toISODate();
|
||||
}
|
||||
console.error("formatDateToISOString failed to create valid DateTime:", jsDateInput);
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a "YYYY-MM" string into the JavaScript Date object for the 1st of that month (UTC).
|
||||
* @param {string} monthString - The month string (e.g., "2023-10").
|
||||
* @returns {Date | null} - The JavaScript Date object for the start of the month (UTC), or null if invalid.
|
||||
*/
|
||||
export function parseMonthString(monthString) {
|
||||
if (typeof monthString !== 'string') {
|
||||
console.error("parseMonthString expects a string input. Received:", monthString);
|
||||
return null;
|
||||
}
|
||||
const dt = DateTime.fromFormat(monthString, 'yyyy-MM', { zone: 'utc' });
|
||||
if (dt.isValid) {
|
||||
return dt.startOf('month').toJSDate();
|
||||
}
|
||||
// Do not log error here if SummaryView handles it, to avoid console spam for tentative inputs
|
||||
// console.error(`Invalid month string format for : ${monthString}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a JavaScript Date object into a "YYYY-MM" string (UTC).
|
||||
* Handles non-Date inputs by attempting to parse them.
|
||||
* @param {Date | string | number} jsDateInput - The input JavaScript Date object or a value parseable into a Date.
|
||||
* @returns {string} - The formatted month string (YYYY-MM), or an empty string if input is invalid.
|
||||
*/
|
||||
export function formatDateToMonthString(jsDateInput) {
|
||||
let dt;
|
||||
if (jsDateInput instanceof Date && !isNaN(jsDateInput.getTime())) {
|
||||
dt = DateTime.fromJSDate(jsDateInput, { zone: 'utc' });
|
||||
} else {
|
||||
const parsedDate = new Date(jsDateInput);
|
||||
if (isNaN(parsedDate.getTime())) {
|
||||
console.error("formatDateToMonthString received invalid date input:", jsDateInput);
|
||||
return "";
|
||||
}
|
||||
dt = DateTime.fromJSDate(parsedDate, { zone: 'utc' });
|
||||
}
|
||||
|
||||
if (dt.isValid) {
|
||||
return dt.toFormat('yyyy-MM');
|
||||
}
|
||||
console.error("formatDateToMonthString failed to create valid DateTime:", jsDateInput);
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Date object into YYYY-MM-DD string.
|
||||
* @param {Date} date - The input date.
|
||||
* @returns {string} - The formatted date string.
|
||||
*/
|
||||
export function formatDateISO(date) {
|
||||
// Ensure input is a Date object
|
||||
if (!(date instanceof Date)) {
|
||||
console.error("formatDateISO received non-Date object:", date);
|
||||
// Attempt to parse if it's a valid date string, otherwise return empty or throw
|
||||
try {
|
||||
date = new Date(date);
|
||||
if (isNaN(date.getTime())) throw new Error("Invalid date input");
|
||||
} catch (e) {
|
||||
return ""; // Or handle error appropriately
|
||||
}
|
||||
}
|
||||
// Check for invalid date after potential parsing
|
||||
if (isNaN(date.getTime())) {
|
||||
console.error("formatDateISO received invalid Date object:", date);
|
||||
return "";
|
||||
}
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -52,11 +52,11 @@
|
||||
</div>
|
||||
<!-- End Filter Section -->
|
||||
|
||||
<LoadingComponent v-if="isLoading" />
|
||||
<LoadingComponent v-if="isLoading" />
|
||||
|
||||
<div class="p-3 bg-body-tertiary rounded shadow-sm" v-else-if="activities && activities.length">
|
||||
<!-- Activities Table -->
|
||||
<ActivitiesTableComponent
|
||||
<div class="p-3 bg-body-tertiary rounded shadow-sm" v-else-if="activities && activities.length" ref="activitiesListRef">
|
||||
<!-- Activities Table -->
|
||||
<ActivitiesTableComponent
|
||||
:activities="activities"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@@ -71,7 +71,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { ref, onMounted, watch, nextTick } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
// import lodash
|
||||
import { debounce } from "lodash";
|
||||
@@ -103,10 +103,14 @@ export default {
|
||||
const pageNumber = ref(1);
|
||||
const numRecords = 20;
|
||||
const totalPages = ref(1);
|
||||
const isLoading = ref(true);
|
||||
const isLoading = ref(true);
|
||||
|
||||
// Filter state
|
||||
const selectedType = ref("");
|
||||
// Scroll position preservation
|
||||
const activitiesListRef = ref(null);
|
||||
let storedScrollPositionActivities = null;
|
||||
|
||||
// Filter state
|
||||
const selectedType = ref("");
|
||||
const startDate = ref("");
|
||||
const endDate = ref("");
|
||||
const nameSearch = ref("");
|
||||
@@ -133,23 +137,26 @@ export default {
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Fetch available activity types for the filter dropdown
|
||||
async function fetchActivityTypes() {
|
||||
try {
|
||||
activityTypes.value = await activitiesService.getActivityTypes();
|
||||
} catch (error) {
|
||||
push.error(
|
||||
`${t("activitiesView.errorFailedFetchActivityTypes")} - ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Fetch available activity types for the filter dropdown
|
||||
async function fetchActivityTypes() {
|
||||
try {
|
||||
activityTypes.value = await activitiesService.getActivityTypes();
|
||||
} catch (error) {
|
||||
push.error(
|
||||
`${t("activitiesView.errorFailedFetchActivityTypes")} - ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function setPageNumber(page) {
|
||||
// Set the page number.
|
||||
pageNumber.value = page;
|
||||
}
|
||||
function setPageNumber(page) {
|
||||
if (activitiesListRef.value) {
|
||||
storedScrollPositionActivities = activitiesListRef.value.getBoundingClientRect().top;
|
||||
}
|
||||
// Set the page number.
|
||||
pageNumber.value = page;
|
||||
}
|
||||
|
||||
async function updateActivities() {
|
||||
async function updateActivities() {
|
||||
try {
|
||||
// Set the loading variable to true.
|
||||
isLoading.value = true;
|
||||
@@ -158,12 +165,20 @@ export default {
|
||||
await fetchActivities();
|
||||
} catch (error) {
|
||||
// If there is an error, set the error message and show the error alert.
|
||||
push.error(`${t("activitiesView.errorUpdatingActivities")} - ${error}`);
|
||||
} finally {
|
||||
// Set the loading variable to false.
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
push.error(`${t("activitiesView.errorUpdatingActivities")} - ${error}`);
|
||||
} finally {
|
||||
// Set the loading variable to false.
|
||||
isLoading.value = false;
|
||||
nextTick(() => {
|
||||
if (storedScrollPositionActivities !== null && activitiesListRef.value) {
|
||||
const newTop = activitiesListRef.value.getBoundingClientRect().top;
|
||||
const scrollDifference = newTop - storedScrollPositionActivities;
|
||||
window.scrollBy(0, scrollDifference);
|
||||
storedScrollPositionActivities = null; // Reset after use
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch activities with pagination, filters, and sorting
|
||||
async function fetchActivities() {
|
||||
@@ -221,19 +236,22 @@ export default {
|
||||
await applyFilters();
|
||||
}
|
||||
|
||||
// Function to handle sorting changes from table component
|
||||
async function handleSort(columnName) {
|
||||
if (sortBy.value === columnName) {
|
||||
// Toggle sort order if same column is clicked
|
||||
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
// Set new column and default to descending order
|
||||
sortBy.value = columnName;
|
||||
sortOrder.value = "desc"; // Or 'asc' if preferred as default
|
||||
}
|
||||
// Fetch data with new sorting, reset to page 1
|
||||
await updateActivities();
|
||||
}
|
||||
// Function to handle sorting changes from table component
|
||||
async function handleSort(columnName) {
|
||||
if (activitiesListRef.value) {
|
||||
storedScrollPositionActivities = activitiesListRef.value.getBoundingClientRect().top;
|
||||
}
|
||||
if (sortBy.value === columnName) {
|
||||
// Toggle sort order if same column is clicked
|
||||
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
// Set new column and default to descending order
|
||||
sortBy.value = columnName;
|
||||
sortOrder.value = "desc"; // Or 'asc' if preferred as default
|
||||
}
|
||||
// Fetch data with new sorting, reset to page 1
|
||||
await updateActivities();
|
||||
}
|
||||
|
||||
// Watch the search name variable.
|
||||
watch(nameSearch, performNameSearch, { immediate: false });
|
||||
@@ -249,15 +267,16 @@ export default {
|
||||
activityTypes,
|
||||
selectedType,
|
||||
startDate,
|
||||
endDate,
|
||||
nameSearch,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
setPageNumber,
|
||||
applyFilters,
|
||||
clearFilters,
|
||||
handleSort,
|
||||
};
|
||||
},
|
||||
endDate,
|
||||
nameSearch,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
activitiesListRef,
|
||||
setPageNumber,
|
||||
applyFilters,
|
||||
clearFilters,
|
||||
handleSort,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
764
frontend/app/src/views/SummaryView.vue
Normal file
764
frontend/app/src/views/SummaryView.vue
Normal file
@@ -0,0 +1,764 @@
|
||||
<template>
|
||||
<div class="container mt-4">
|
||||
<h2>{{ t('summaryView.title') }}</h2>
|
||||
|
||||
<!-- Controls Section -->
|
||||
<div class="row mb-3 align-items-end">
|
||||
<!-- Activity Type Filter -->
|
||||
<div class="col-md-3">
|
||||
<label for="activityTypeFilter" class="form-label">{{ t('summaryView.filterLabelActivityType') }}</label>
|
||||
<select id="activityTypeFilter" class="form-select" v-model="selectedActivityType" :disabled="loadingTypes">
|
||||
<option value="">{{ t('summaryView.filterOptionAllTypes') }}</option>
|
||||
<option v-for="(name, id) in activityTypes" :key="id" :value="id">{{ name }}</option>
|
||||
</select>
|
||||
<div v-if="loadingTypes" class="form-text">{{ t('generalItems.labelLoading') }}...</div>
|
||||
<div v-if="errorTypes" class="form-text text-danger">{{ errorTypes }}</div>
|
||||
</div>
|
||||
<!-- View Type Filter -->
|
||||
<div class="col-md-3">
|
||||
<label for="viewType" class="form-label">{{ t('summaryView.labelViewType') }}</label>
|
||||
<select id="viewType" class="form-select" v-model="selectedViewType">
|
||||
<option value="week">{{ t('summaryView.optionWeekly') }}</option>
|
||||
<option value="month">{{ t('summaryView.optionMonthly') }}</option>
|
||||
<option value="year">{{ t('summaryView.optionYearly') }}</option>
|
||||
<option value="lifetime">{{ t('summaryView.optionLifetime') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3" v-if="selectedViewType !== 'lifetime'">
|
||||
<label :for="periodInputId" class="form-label">{{ periodLabel }}</label>
|
||||
<input type="date" :id="periodInputId" class="form-control" v-if="selectedViewType === 'week'" v-model="selectedDate" @change="handleDateInputChange">
|
||||
<input type="month" :id="periodInputId" class="form-control" v-else-if="selectedViewType === 'month'" v-model="selectedPeriodString">
|
||||
<input type="number" :id="periodInputId" class="form-control" v-else-if="selectedViewType === 'year'" v-model.number="selectedYear" placeholder="YYYY" min="1900" max="2100">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end" v-if="selectedViewType !== 'lifetime'">
|
||||
<button class="btn btn-outline-secondary me-1" @click="navigatePeriod(-1)" :disabled="loadingSummary || loadingActivities"><</button>
|
||||
<button class="btn btn-outline-secondary" @click="navigatePeriod(1)" :disabled="loadingSummary || loadingActivities">></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Display Section -->
|
||||
<div v-if="loadingSummary" class="text-center">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">{{ t('summaryView.loadingSummary') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="summaryData" class="card mb-4">
|
||||
<div class="card-header">
|
||||
{{ t('summaryView.headerSummaryFor', { period: summaryPeriodText }) }}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- New Highlighted Summary Totals Section -->
|
||||
<div class="row text-center justify-content-around mb-3">
|
||||
<div class="col-lg col-md-4 col-sm-6 mb-3">
|
||||
<div class="card shadow-sm h-100 bg-primary text-white">
|
||||
<div class="card-body d-flex flex-column justify-content-center">
|
||||
<h6 class="card-subtitle mb-2 text-white-50">{{ t('summaryView.metricTotalDistance') }}</h6>
|
||||
<p class="card-text h4 mb-0">{{ formatRawDistance(summaryData.total_distance, authStore.user.units) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg col-md-4 col-sm-6 mb-3">
|
||||
<div class="card shadow-sm h-100 bg-primary text-white">
|
||||
<div class="card-body d-flex flex-column justify-content-center">
|
||||
<h6 class="card-subtitle mb-2 text-white-50">{{ t('summaryView.metricTotalDuration') }}</h6>
|
||||
<p class="card-text h4 mb-0">{{ formatDuration(summaryData.total_duration) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg col-md-4 col-sm-6 mb-3">
|
||||
<div class="card shadow-sm h-100 bg-primary text-white">
|
||||
<div class="card-body d-flex flex-column justify-content-center">
|
||||
<h6 class="card-subtitle mb-2 text-white-50">{{ t('summaryView.metricTotalElevation') }}</h6>
|
||||
<p class="card-text h4 mb-0">{{ formatElevation(summaryData.total_elevation_gain, authStore.user.units) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg col-md-6 col-sm-6 mb-3"> <!-- Adjusted col-md for potentially wider content -->
|
||||
<div class="card shadow-sm h-100 bg-primary text-white">
|
||||
<div class="card-body d-flex flex-column justify-content-center">
|
||||
<h6 class="card-subtitle mb-2 text-white-50">{{ t('summaryView.metricTotalCalories') }}</h6>
|
||||
<p class="card-text h4 mb-0">{{ formatCalories(summaryData.total_calories) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg col-md-6 col-sm-12 mb-3"> <!-- Adjusted col-md and col-sm for last item -->
|
||||
<div class="card shadow-sm h-100 bg-primary text-white">
|
||||
<div class="card-body d-flex flex-column justify-content-center">
|
||||
<h6 class="card-subtitle mb-2 text-white-50">{{ t('summaryView.metricTotalActivities') }}</h6>
|
||||
<p class="card-text h4 mb-0">{{ summaryData.activity_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<h5>{{ t('summaryView.headerBreakdown') }}</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped responsive-summary-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-main-header">{{ breakdownHeader }}</th>
|
||||
<th>{{ t('summaryView.colDistance') }}</th>
|
||||
<th>{{ t('summaryView.colDuration') }}</th>
|
||||
<th v-if="showElevation">{{ t('summaryView.colElevation') }}</th>
|
||||
<th v-if="showCalories">{{ t('summaryView.colCalories') }}</th>
|
||||
<th v-if="showActivityCount">{{ t('summaryView.colActivities') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in summaryData.breakdown" :key="getBreakdownKey(item)">
|
||||
<td class="col-main-header">{{ getBreakdownLabel(item) }}</td>
|
||||
<td>{{ formatRawDistance(item.total_distance, authStore.user.units) }}</td>
|
||||
<td>{{ formatDuration(item.total_duration) }}</td>
|
||||
<td v-if="showElevation">{{ formatElevation(item.total_elevation_gain, authStore.user.units) }}</td>
|
||||
<td v-if="showCalories">{{ formatCalories(item.total_calories) }}</td>
|
||||
<td v-if="showActivityCount">{{ item.activity_count }}</td>
|
||||
</tr>
|
||||
<tr v-if="!summaryData.breakdown || summaryData.breakdown.length === 0">
|
||||
<td :colspan="mainBreakdownVisibleCols" class="text-center">{{ t('summaryView.noDataForPeriod') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="typeBreakdownData && !selectedActivityType" class="mt-4">
|
||||
<hr>
|
||||
<h5>{{ t('summaryView.headerTypeBreakdown') }}</h5>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped responsive-summary-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-activity-type">{{ t('summaryView.colActivityType') }}</th>
|
||||
<th>{{ t('summaryView.colDistance') }}</th>
|
||||
<th>{{ t('summaryView.colDuration') }}</th>
|
||||
<th v-if="showElevation">{{ t('summaryView.colElevation') }}</th>
|
||||
<th v-if="showCalories">{{ t('summaryView.colCalories') }}</th>
|
||||
<th v-if="showActivityCount">{{ t('summaryView.colActivities') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in typeBreakdownData" :key="item.activity_type_id">
|
||||
<td class="col-activity-type text-center"><font-awesome-icon :icon="getIcon(item.activity_type_id)" /></td>
|
||||
<td>{{ formatRawDistance(item.total_distance, authStore.user.units) }}</td>
|
||||
<td>{{ formatDuration(item.total_duration) }}</td>
|
||||
<td v-if="showElevation">{{ formatElevation(item.total_elevation_gain, authStore.user.units) }}</td>
|
||||
<td v-if="showCalories">{{ formatCalories(item.total_calories) }}</td>
|
||||
<td v-if="showActivityCount">{{ item.activity_count }}</td>
|
||||
</tr>
|
||||
<tr v-if="!typeBreakdownData || typeBreakdownData.length === 0">
|
||||
<td :colspan="typeBreakdownVisibleCols" class="text-center">{{ t('summaryView.noDataForPeriod') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="errorSummary" class="alert alert-danger">
|
||||
{{ t('summaryView.errorLoadingSummary', { error: errorSummary }) }}
|
||||
</div>
|
||||
|
||||
<!-- Activities in Period Section - Conditionally rendered -->
|
||||
<div v-if="selectedViewType !== 'lifetime'">
|
||||
<h3 class="mt-4">{{ t('summaryView.headerActivitiesInPeriod') }}</h3>
|
||||
<div v-if="loadingActivities" class="text-center">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">{{ t('summaryView.loadingActivities') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="errorActivities" class="alert alert-danger">
|
||||
{{ t('summaryView.errorLoadingActivities', { error: errorActivities }) }}
|
||||
</div>
|
||||
<div v-else ref="activitiesSectionRef">
|
||||
<ActivitiesTableComponent
|
||||
:activities="activities"
|
||||
:sort-by="sortBy"
|
||||
:sort-order="sortOrder"
|
||||
@sort-changed="handleSort"
|
||||
/>
|
||||
<PaginationComponent
|
||||
v-if="totalActivities > 0"
|
||||
:pageNumber="currentPage"
|
||||
:total-pages="calculatedTotalPages"
|
||||
@pageNumberChanged="handlePageChange"
|
||||
/>
|
||||
<p v-if="activities.length === 0 && !loadingActivities">{{ t('summaryView.noActivitiesFound') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { summaryService } from '@/services/summaryService';
|
||||
import { activities as activitiesService } from '@/services/activitiesService';
|
||||
import ActivitiesTableComponent from '@/components/Activities/ActivitiesTableComponent.vue';
|
||||
import PaginationComponent from '@/components/GeneralComponents/PaginationComponent.vue';
|
||||
import { getIcon, formatRawDistance, formatDuration, formatElevation, formatCalories } from '@/utils/activityUtils';
|
||||
import {
|
||||
getWeekStartDate, getWeekEndDate, getMonthStartDate, getMonthEndDate, formatDateISO,
|
||||
parseMonthString, formatDateToMonthString
|
||||
} from '@/utils/dateTimeUtils';
|
||||
|
||||
const { t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// Filter and View State
|
||||
const selectedViewType = ref('week');
|
||||
const selectedActivityType = ref('');
|
||||
const activityTypes = ref({});
|
||||
const initialDate = new Date();
|
||||
const selectedDate = ref(formatDateISO(initialDate));
|
||||
const selectedYear = ref(initialDate.getFullYear());
|
||||
const selectedPeriodString = ref(formatDateToMonthString(initialDate));
|
||||
|
||||
// Data State
|
||||
const summaryData = ref(null);
|
||||
const typeBreakdownData = ref(null);
|
||||
const activities = ref([]);
|
||||
const totalActivities = ref(0);
|
||||
const currentPage = ref(1);
|
||||
const activitiesPerPage = ref(10);
|
||||
|
||||
// Loading and Error State
|
||||
const loadingSummary = ref(false);
|
||||
const errorSummary = ref(null);
|
||||
const loadingActivities = ref(false);
|
||||
const errorActivities = ref(null);
|
||||
const loadingTypes = ref(false);
|
||||
const errorTypes = ref(null);
|
||||
|
||||
// Scroll position preservation
|
||||
const activitiesSectionRef = ref(null);
|
||||
let storedScrollPosition = null;
|
||||
|
||||
// Sorting State
|
||||
const sortBy = ref('start_time');
|
||||
const sortOrder = ref('desc');
|
||||
|
||||
// Responsive column visibility
|
||||
const showElevation = ref(true);
|
||||
const showCalories = ref(true);
|
||||
const showActivityCount = ref(true);
|
||||
|
||||
const updateColumnVisibility = () => {
|
||||
const width = window.innerWidth;
|
||||
const screenMd = 768; // Bootstrap 'md' breakpoint
|
||||
const screenSm = 576; // Bootstrap 'sm' breakpoint
|
||||
const screenXs = 480; // Custom extra-small, or a bit less than sm
|
||||
|
||||
// Start by assuming all are visible
|
||||
showElevation.value = true;
|
||||
showCalories.value = true;
|
||||
showActivityCount.value = true;
|
||||
|
||||
// Then, hide them progressively as the screen gets narrower
|
||||
if (width < screenMd) { // Below 768px
|
||||
showActivityCount.value = false;
|
||||
}
|
||||
if (width < screenSm) { // Below 576px
|
||||
showCalories.value = false;
|
||||
}
|
||||
if (width < screenXs) { // Below 480px (or your chosen smallest breakpoint)
|
||||
showElevation.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Computed property for dynamic input ID
|
||||
const periodInputId = computed(() => {
|
||||
switch (selectedViewType.value) {
|
||||
case 'week': return 'periodPickerWeek';
|
||||
case 'month': return 'periodPickerMonth';
|
||||
case 'year': return 'periodPickerYear';
|
||||
default: return 'periodPicker';
|
||||
}
|
||||
});
|
||||
|
||||
const periodLabel = computed(() => {
|
||||
switch (selectedViewType.value) {
|
||||
case 'week': return t('summaryView.labelSelectWeek');
|
||||
case 'month': return t('summaryView.labelSelectMonth');
|
||||
case 'year': return t('summaryView.labelSelectYear');
|
||||
case 'lifetime': return '';
|
||||
default: return t('summaryView.labelSelectPeriod');
|
||||
}
|
||||
});
|
||||
|
||||
const summaryPeriodText = computed(() => {
|
||||
if (selectedViewType.value === 'lifetime') {
|
||||
return t('summaryView.optionLifetime');
|
||||
}
|
||||
if (!summaryData.value && !loadingSummary.value) return t('summaryView.labelSelectPeriod');
|
||||
if (loadingSummary.value) return t('generalItems.labelLoading');
|
||||
|
||||
try {
|
||||
if (selectedViewType.value === 'year') {
|
||||
return t('summaryView.headerYear', { year: selectedYear.value });
|
||||
}
|
||||
const date = new Date(selectedDate.value + 'T00:00:00Z');
|
||||
if (isNaN(date.getTime())) return t('summaryView.invalidDateSelected');
|
||||
|
||||
if (selectedViewType.value === 'month') {
|
||||
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', timeZone: 'UTC' });
|
||||
}
|
||||
if (selectedViewType.value === 'week') {
|
||||
const weekStart = getWeekStartDate(date);
|
||||
return t('summaryView.headerWeekStarting', { date: formatDateISO(weekStart) });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error formatting summary period text:", e);
|
||||
return t('summaryView.labelSelectPeriod');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const breakdownHeader = computed(() => {
|
||||
switch (selectedViewType.value) {
|
||||
case 'week': return t('summaryView.colDay');
|
||||
case 'month': return t('summaryView.colWeekNum');
|
||||
case 'year': return t('summaryView.colMonth');
|
||||
case 'lifetime': return t('summaryView.colYear');
|
||||
default: return 'Period';
|
||||
}
|
||||
});
|
||||
|
||||
const getBreakdownKey = (item) => {
|
||||
switch (selectedViewType.value) {
|
||||
case 'week': return item.day_of_week;
|
||||
case 'month': return item.week_number;
|
||||
case 'year': return item.month_number;
|
||||
case 'lifetime': return item.year_number;
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getBreakdownLabel = (item) => {
|
||||
switch (selectedViewType.value) {
|
||||
case 'week':
|
||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
return days[item.day_of_week] || 'Unknown Day';
|
||||
case 'month':
|
||||
return `${t('summaryView.colWeekNum')} ${item.week_number}`;
|
||||
case 'year':
|
||||
const monthDate = new Date(Date.UTC(selectedYear.value, item.month_number - 1, 1));
|
||||
return monthDate.toLocaleDateString(undefined, { month: 'long', timeZone: 'UTC' });
|
||||
case 'lifetime':
|
||||
return item.year_number;
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
const fetchActivityTypes = async () => {
|
||||
if (!authStore.user?.id) return;
|
||||
loadingTypes.value = true;
|
||||
errorTypes.value = null;
|
||||
try {
|
||||
activityTypes.value = await activitiesService.getActivityTypes();
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch activity types:', err);
|
||||
errorTypes.value = t('generalItems.labelError');
|
||||
} finally {
|
||||
loadingTypes.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSummaryData = async () => {
|
||||
if (!authStore.user?.id) return;
|
||||
|
||||
loadingSummary.value = true;
|
||||
errorSummary.value = null;
|
||||
summaryData.value = null;
|
||||
typeBreakdownData.value = null;
|
||||
|
||||
// Reset activities only if they are supposed to be fetched for the current view
|
||||
if (selectedViewType.value !== 'lifetime') {
|
||||
activities.value = [];
|
||||
totalActivities.value = 0;
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const params = {};
|
||||
if (selectedViewType.value === 'year') {
|
||||
if (!selectedYear.value || selectedYear.value < 1900 || selectedYear.value > 2100) {
|
||||
throw new Error(t('summaryView.invalidYearSelected'));
|
||||
}
|
||||
params.year = selectedYear.value;
|
||||
} else if (selectedViewType.value === 'week' || selectedViewType.value === 'month') {
|
||||
if (!selectedDate.value) {
|
||||
throw new Error(t('summaryView.noDateSelected'));
|
||||
}
|
||||
params.date = selectedDate.value;
|
||||
}
|
||||
// For 'lifetime', params remains empty.
|
||||
|
||||
const activityTypeName = selectedActivityType.value ? activityTypes.value[selectedActivityType.value] : null;
|
||||
|
||||
const response = await summaryService.getSummary(
|
||||
authStore.user.id,
|
||||
selectedViewType.value,
|
||||
params,
|
||||
activityTypeName
|
||||
);
|
||||
summaryData.value = response;
|
||||
typeBreakdownData.value = response.type_breakdown;
|
||||
|
||||
if (selectedViewType.value !== 'lifetime') {
|
||||
fetchActivitiesForPeriod();
|
||||
} else {
|
||||
// For lifetime view, ensure activities list is and stays empty
|
||||
activities.value = [];
|
||||
totalActivities.value = 0;
|
||||
currentPage.value = 1;
|
||||
loadingActivities.value = false; // Ensure loading state is false
|
||||
errorActivities.value = null; // Clear any previous activity errors
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching summary:', err);
|
||||
errorSummary.value = err.message || (err.response?.data?.detail || t('generalItems.labelError'));
|
||||
} finally {
|
||||
loadingSummary.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchActivitiesForPeriod = async (page = currentPage.value) => {
|
||||
if (!authStore.user?.id || selectedViewType.value === 'lifetime') { // Do not fetch for lifetime
|
||||
activities.value = [];
|
||||
totalActivities.value = 0;
|
||||
loadingActivities.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loadingActivities.value = true;
|
||||
errorActivities.value = null;
|
||||
if (page === 1) {
|
||||
activities.value = [];
|
||||
totalActivities.value = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const filters = {
|
||||
type: selectedActivityType.value || null
|
||||
};
|
||||
|
||||
if (selectedViewType.value === 'year') {
|
||||
if (!selectedYear.value || selectedYear.value < 1900 || selectedYear.value > 2100) {
|
||||
throw new Error(t('summaryView.invalidYearSelected'));
|
||||
}
|
||||
filters.start_date = `${selectedYear.value}-01-01`;
|
||||
filters.end_date = `${selectedYear.value + 1}-01-01`;
|
||||
} else if (selectedViewType.value === 'week' || selectedViewType.value === 'month') {
|
||||
if (!selectedDate.value) {
|
||||
throw new Error(t('summaryView.noDateSelected'));
|
||||
}
|
||||
const date = new Date(selectedDate.value + 'T00:00:00Z');
|
||||
if (isNaN(date.getTime())) throw new Error(t('summaryView.invalidDateSelected'));
|
||||
|
||||
if (selectedViewType.value === 'week') {
|
||||
const weekStart = getWeekStartDate(date);
|
||||
const weekEnd = getWeekEndDate(date);
|
||||
filters.start_date = formatDateISO(weekStart);
|
||||
filters.end_date = formatDateISO(weekEnd);
|
||||
} else { // month
|
||||
const monthStart = getMonthStartDate(date);
|
||||
const monthEnd = getMonthEndDate(date);
|
||||
filters.start_date = formatDateISO(monthStart);
|
||||
filters.end_date = formatDateISO(monthEnd);
|
||||
}
|
||||
}
|
||||
// For 'lifetime', no date filters are added (already handled by the guard clause at the start)
|
||||
|
||||
Object.keys(filters).forEach(key => (filters[key] == null || filters[key] === '') && delete filters[key]);
|
||||
|
||||
const response = await activitiesService.getUserActivitiesWithPagination(
|
||||
authStore.user.id,
|
||||
page,
|
||||
activitiesPerPage.value,
|
||||
filters,
|
||||
sortBy.value,
|
||||
sortOrder.value
|
||||
);
|
||||
activities.value = Array.isArray(response) ? response : [];
|
||||
currentPage.value = page;
|
||||
|
||||
if (authStore.user?.id) {
|
||||
try {
|
||||
const countResponse = await activitiesService.getUserNumberOfActivities(filters);
|
||||
totalActivities.value = countResponse || 0;
|
||||
} catch (countErr) {
|
||||
console.error('Error fetching total activities count:', countErr);
|
||||
totalActivities.value = 0;
|
||||
}
|
||||
} else {
|
||||
totalActivities.value = 0;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error fetching activities:', err);
|
||||
errorActivities.value = err.message || (err.response?.data?.detail || t('generalItems.labelError'));
|
||||
activities.value = []; // Clear activities on error
|
||||
totalActivities.value = 0; // Reset count on error
|
||||
} finally {
|
||||
loadingActivities.value = false;
|
||||
nextTick(() => {
|
||||
if (storedScrollPosition !== null && activitiesSectionRef.value) {
|
||||
const newTop = activitiesSectionRef.value.getBoundingClientRect().top;
|
||||
const scrollDifference = newTop - storedScrollPosition;
|
||||
window.scrollBy(0, scrollDifference);
|
||||
storedScrollPosition = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function handlePageChange(newPage) {
|
||||
if (activitiesSectionRef.value) {
|
||||
storedScrollPosition = activitiesSectionRef.value.getBoundingClientRect().top;
|
||||
}
|
||||
fetchActivitiesForPeriod(newPage);
|
||||
}
|
||||
|
||||
function handleSort(columnName) {
|
||||
if (activitiesSectionRef.value) {
|
||||
storedScrollPosition = activitiesSectionRef.value.getBoundingClientRect().top;
|
||||
}
|
||||
if (sortBy.value === columnName) {
|
||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortBy.value = columnName;
|
||||
sortOrder.value = 'desc';
|
||||
}
|
||||
fetchActivitiesForPeriod(1);
|
||||
}
|
||||
|
||||
function triggerDataFetch() {
|
||||
currentPage.value = 1;
|
||||
let shouldFetchSummary = false;
|
||||
errorSummary.value = null;
|
||||
|
||||
if (selectedViewType.value === 'lifetime') {
|
||||
shouldFetchSummary = true;
|
||||
} else if (selectedViewType.value === 'year') {
|
||||
if (selectedYear.value && selectedYear.value >= 1900 && selectedYear.value <= 2100) {
|
||||
shouldFetchSummary = true;
|
||||
} else {
|
||||
if (selectedYear.value) {
|
||||
errorSummary.value = t('summaryView.invalidYearSelected');
|
||||
} else {
|
||||
console.warn("Year is not selected or invalid, not fetching summary.");
|
||||
}
|
||||
}
|
||||
} else { // 'week' or 'month'
|
||||
if (selectedDate.value) {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(selectedDate.value)) {
|
||||
shouldFetchSummary = true;
|
||||
} else {
|
||||
errorSummary.value = t('summaryView.invalidDateSelected');
|
||||
}
|
||||
} else {
|
||||
errorSummary.value = t('summaryView.noDateSelected');
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldFetchSummary) {
|
||||
fetchSummaryData(); // This will conditionally call fetchActivitiesForPeriod
|
||||
} else {
|
||||
console.warn("Skipping summary fetch due to invalid period selection for view type:", selectedViewType.value);
|
||||
summaryData.value = null;
|
||||
typeBreakdownData.value = null;
|
||||
activities.value = [];
|
||||
totalActivities.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDateInputChange() {
|
||||
triggerDataFetch();
|
||||
}
|
||||
|
||||
watch(selectedPeriodString, (newString) => {
|
||||
if (selectedViewType.value !== 'month' || !newString) return;
|
||||
try {
|
||||
const newDate = parseMonthString(newString);
|
||||
if (newDate && !isNaN(newDate.getTime())) {
|
||||
const newDateISO = formatDateISO(newDate);
|
||||
if (newDateISO !== selectedDate.value) {
|
||||
selectedDate.value = newDateISO;
|
||||
triggerDataFetch();
|
||||
}
|
||||
} else if (newString) {
|
||||
errorSummary.value = t('summaryView.invalidInputFormat');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing month string ${newString}:', e);
|
||||
errorSummary.value = t('summaryView.invalidInputFormat');
|
||||
}
|
||||
});
|
||||
|
||||
watch(selectedYear, (newYear, oldYear) => {
|
||||
if (selectedViewType.value !== 'year') return;
|
||||
|
||||
if (newYear && newYear >= 1900 && newYear <= 2100) {
|
||||
errorSummary.value = null;
|
||||
triggerDataFetch();
|
||||
} else if (newYear) {
|
||||
errorSummary.value = t('summaryView.invalidYearSelected');
|
||||
summaryData.value = null;
|
||||
typeBreakdownData.value = null;
|
||||
activities.value = [];
|
||||
totalActivities.value = 0;
|
||||
} else if (!newYear && oldYear) {
|
||||
errorSummary.value = t('summaryView.invalidYearSelected');
|
||||
summaryData.value = null;
|
||||
typeBreakdownData.value = null;
|
||||
activities.value = [];
|
||||
totalActivities.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
watch(selectedActivityType, () => {
|
||||
triggerDataFetch();
|
||||
});
|
||||
|
||||
watch(selectedViewType, (newType, oldType) => {
|
||||
if (newType === oldType) return;
|
||||
|
||||
try {
|
||||
errorSummary.value = null;
|
||||
errorActivities.value = null;
|
||||
|
||||
if (newType !== 'lifetime') {
|
||||
let baseDate;
|
||||
try {
|
||||
baseDate = new Date(selectedDate.value + 'T00:00:00Z');
|
||||
if (isNaN(baseDate.getTime())) throw new Error("Current selectedDate is invalid, defaulting to today.");
|
||||
} catch(e) {
|
||||
console.warn(e.message);
|
||||
baseDate = new Date();
|
||||
selectedDate.value = formatDateISO(baseDate);
|
||||
}
|
||||
|
||||
if (newType === 'month') {
|
||||
selectedPeriodString.value = formatDateToMonthString(baseDate);
|
||||
} else if (newType === 'year') {
|
||||
selectedYear.value = baseDate.getUTCFullYear();
|
||||
}
|
||||
} else { // Switching to lifetime
|
||||
activities.value = []; // Clear activities immediately
|
||||
totalActivities.value = 0;
|
||||
currentPage.value = 1;
|
||||
loadingActivities.value = false;
|
||||
errorActivities.value = null;
|
||||
}
|
||||
triggerDataFetch();
|
||||
|
||||
} catch (e) {
|
||||
console.error("Error handling view type change:", e);
|
||||
errorSummary.value = t('generalItems.labelError');
|
||||
const today = new Date();
|
||||
selectedViewType.value = 'week';
|
||||
selectedDate.value = formatDateISO(today);
|
||||
selectedPeriodString.value = formatDateToMonthString(today);
|
||||
selectedYear.value = today.getUTCFullYear();
|
||||
triggerDataFetch();
|
||||
}
|
||||
});
|
||||
|
||||
function navigatePeriod(direction) {
|
||||
if (selectedViewType.value === 'lifetime') return;
|
||||
|
||||
try {
|
||||
errorSummary.value = null;
|
||||
|
||||
if (selectedViewType.value === 'year') {
|
||||
const currentYear = selectedYear.value || new Date().getUTCFullYear();
|
||||
selectedYear.value = currentYear + direction;
|
||||
// triggerDataFetch() is called by the watcher on selectedYear
|
||||
} else {
|
||||
let currentDate;
|
||||
try {
|
||||
currentDate = new Date(selectedDate.value + 'T00:00:00Z');
|
||||
if (isNaN(currentDate.getTime())) throw new Error("Invalid date for navigation");
|
||||
} catch {
|
||||
currentDate = new Date();
|
||||
}
|
||||
|
||||
if (selectedViewType.value === 'week') {
|
||||
currentDate.setUTCDate(currentDate.getUTCDate() + (7 * direction));
|
||||
} else { // 'month'
|
||||
currentDate.setUTCMonth(currentDate.getUTCMonth() + direction, 1);
|
||||
}
|
||||
selectedDate.value = formatDateISO(currentDate);
|
||||
|
||||
if (selectedViewType.value === 'month') {
|
||||
selectedPeriodString.value = formatDateToMonthString(currentDate); // This will trigger its watcher
|
||||
triggerDataFetch();
|
||||
} else { // week
|
||||
triggerDataFetch(); // For week, date change needs to trigger fetch directly
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error navigating period:", e);
|
||||
errorSummary.value = t('generalItems.labelError');
|
||||
}
|
||||
}
|
||||
|
||||
const calculatedTotalPages = computed(() => {
|
||||
return totalActivities.value > 0 ? Math.ceil(totalActivities.value / activitiesPerPage.value) : 1;
|
||||
});
|
||||
|
||||
const mainBreakdownVisibleCols = computed(() => {
|
||||
let count = 3; // Base: Header, Distance, Duration
|
||||
if (showElevation.value) count++;
|
||||
if (showCalories.value) count++;
|
||||
if (showActivityCount.value) count++;
|
||||
return count;
|
||||
});
|
||||
|
||||
const typeBreakdownVisibleCols = computed(() => {
|
||||
let count = 3; // Base: Activity Type, Distance, Duration
|
||||
if (showElevation.value) count++;
|
||||
if (showCalories.value) count++;
|
||||
if (showActivityCount.value) count++;
|
||||
return count;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchActivityTypes();
|
||||
updateColumnVisibility();
|
||||
window.addEventListener('resize', updateColumnVisibility);
|
||||
triggerDataFetch();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateColumnVisibility);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table-responsive {
|
||||
max-height: 400px;
|
||||
}
|
||||
.card .row.text-center p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.align-items-end {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.responsive-summary-table th,
|
||||
.responsive-summary-table td {
|
||||
min-width: 75px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.responsive-summary-table .col-main-header {
|
||||
min-width: 100px;
|
||||
}
|
||||
.responsive-summary-table .col-activity-type {
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user