From 2fae8836867c83cfca6b62bf679a804c54013c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maks=20Mr=C5=BEek?= Date: Sun, 18 May 2025 10:37:44 +0200 Subject: [PATCH] summary view added + some fixes --- .gitignore | 11 +- backend/app/activity_summaries/crud.py | 339 ++++++++ backend/app/activity_summaries/router.py | 76 ++ backend/app/activity_summaries/schema.py | 47 ++ backend/app/core/routes.py | 7 + frontend/app/.env | 2 +- .../Navbar/NavbarBottomMobileComponent.vue | 260 +++--- .../src/components/Navbar/NavbarComponent.vue | 260 +++--- frontend/app/src/i18n/ca/generalItems.json | 2 +- frontend/app/src/i18n/de/generalItems.json | 2 +- frontend/app/src/i18n/es/generalItems.json | 2 +- frontend/app/src/i18n/fr/generalItems.json | 2 +- frontend/app/src/i18n/index.js | 2 + frontend/app/src/i18n/nl/generalItems.json | 2 +- frontend/app/src/i18n/pt/generalItems.json | 2 +- .../navbar/navbarBottomMobileComponent.json | 15 +- .../us/components/navbar/navbarComponent.json | 1 + frontend/app/src/i18n/us/generalItems.json | 2 +- frontend/app/src/i18n/us/summaryView.json | 46 ++ frontend/app/src/router/index.js | 7 +- frontend/app/src/services/summaryService.js | 25 + frontend/app/src/utils/activityUtils.js | 23 +- frontend/app/src/utils/dateTimeUtils.js | 136 +++- frontend/app/src/views/ActivitiesView.vue | 123 +-- frontend/app/src/views/SummaryView.vue | 764 ++++++++++++++++++ 25 files changed, 1858 insertions(+), 300 deletions(-) create mode 100644 backend/app/activity_summaries/crud.py create mode 100644 backend/app/activity_summaries/router.py create mode 100644 backend/app/activity_summaries/schema.py create mode 100644 frontend/app/src/i18n/us/summaryView.json create mode 100644 frontend/app/src/services/summaryService.js create mode 100644 frontend/app/src/views/SummaryView.vue diff --git a/.gitignore b/.gitignore index 5fcf51307..84aa0b943 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file +site/ + +# LLM supporting files +memory-bank/ +.clinerules + +# local postgres +postgres/ \ No newline at end of file diff --git a/backend/app/activity_summaries/crud.py b/backend/app/activity_summaries/crud.py new file mode 100644 index 000000000..3d2e27430 --- /dev/null +++ b/backend/app/activity_summaries/crud.py @@ -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 diff --git a/backend/app/activity_summaries/router.py b/backend/app/activity_summaries/router.py new file mode 100644 index 000000000..bfad81d89 --- /dev/null +++ b/backend/app/activity_summaries/router.py @@ -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'.", + ) diff --git a/backend/app/activity_summaries/schema.py b/backend/app/activity_summaries/schema.py new file mode 100644 index 000000000..26754262a --- /dev/null +++ b/backend/app/activity_summaries/schema.py @@ -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 diff --git a/backend/app/core/routes.py b/backend/app/core/routes.py index 04c17be8b..c66b0f40e 100644 --- a/backend/app/core/routes.py +++ b/backend/app/core/routes.py @@ -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", diff --git a/frontend/app/.env b/frontend/app/.env index d6fe9559c..6988c647c 100644 --- a/frontend/app/.env +++ b/frontend/app/.env @@ -1 +1 @@ -VITE_ENDURAIN_HOST=MY_APP_ENDURAIN_HOST \ No newline at end of file +VITE_ENDURAIN_HOST=http://localhost:8080 \ No newline at end of file diff --git a/frontend/app/src/components/Navbar/NavbarBottomMobileComponent.vue b/frontend/app/src/components/Navbar/NavbarBottomMobileComponent.vue index 468da7547..30f71d013 100644 --- a/frontend/app/src/components/Navbar/NavbarBottomMobileComponent.vue +++ b/frontend/app/src/components/Navbar/NavbarBottomMobileComponent.vue @@ -1,122 +1,170 @@ \ No newline at end of file + return { + authStore, + closeOffcanvas, + handleLogout + } + } +} + diff --git a/frontend/app/src/components/Navbar/NavbarComponent.vue b/frontend/app/src/components/Navbar/NavbarComponent.vue index 764bebf2d..5aa57297a 100644 --- a/frontend/app/src/components/Navbar/NavbarComponent.vue +++ b/frontend/app/src/components/Navbar/NavbarComponent.vue @@ -1,136 +1,154 @@ \ No newline at end of file + return { + authStore, + handleLogout + } + } +} + diff --git a/frontend/app/src/i18n/ca/generalItems.json b/frontend/app/src/i18n/ca/generalItems.json index b6dd21f42..b99826a39 100644 --- a/frontend/app/src/i18n/ca/generalItems.json +++ b/frontend/app/src/i18n/ca/generalItems.json @@ -28,7 +28,7 @@ "unitsMph": "mph", "unitsLbs": "lliures", "labelWeightInLbs": "Pes en lliures", - "unitsCalories": "cal", + "unitsCalories": "kcal", "unitsBpm": "bpm", "labelHRinBpm": "Bategs en bpm", "unitsWattsShort": "W", diff --git a/frontend/app/src/i18n/de/generalItems.json b/frontend/app/src/i18n/de/generalItems.json index 8803c719a..d5246753b 100644 --- a/frontend/app/src/i18n/de/generalItems.json +++ b/frontend/app/src/i18n/de/generalItems.json @@ -28,7 +28,7 @@ "unitsMph": "mph", "unitsLbs": "lb", "labelWeightInLbs": "Gewicht in lbs", - "unitsCalories": "cal", + "unitsCalories": "kcal", "unitsBpm": "bpm", "labelHRinBpm": "Herzfrequenz in bpm", "unitsWattsShort": "W", diff --git a/frontend/app/src/i18n/es/generalItems.json b/frontend/app/src/i18n/es/generalItems.json index 29f832271..253edb56d 100644 --- a/frontend/app/src/i18n/es/generalItems.json +++ b/frontend/app/src/i18n/es/generalItems.json @@ -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", diff --git a/frontend/app/src/i18n/fr/generalItems.json b/frontend/app/src/i18n/fr/generalItems.json index 8033c1d61..d05b3e34c 100644 --- a/frontend/app/src/i18n/fr/generalItems.json +++ b/frontend/app/src/i18n/fr/generalItems.json @@ -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", diff --git a/frontend/app/src/i18n/index.js b/frontend/app/src/i18n/index.js index 4ac15069c..34579fbd2 100644 --- a/frontend/app/src/i18n/index.js +++ b/frontend/app/src/i18n/index.js @@ -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, }, }; diff --git a/frontend/app/src/i18n/nl/generalItems.json b/frontend/app/src/i18n/nl/generalItems.json index c8e27a910..e2afb86f9 100644 --- a/frontend/app/src/i18n/nl/generalItems.json +++ b/frontend/app/src/i18n/nl/generalItems.json @@ -28,7 +28,7 @@ "unitsMph": "mph", "unitsLbs": "lbs", "labelWeightInLbs": "Gewicht in lbs", - "unitsCalories": "cal", + "unitsCalories": "kcal", "unitsBpm": "bpm", "labelHRinBpm": "Hartslag in bpm", "unitsWattsShort": "W", diff --git a/frontend/app/src/i18n/pt/generalItems.json b/frontend/app/src/i18n/pt/generalItems.json index 2d170e1fb..9b32178a7 100644 --- a/frontend/app/src/i18n/pt/generalItems.json +++ b/frontend/app/src/i18n/pt/generalItems.json @@ -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", diff --git a/frontend/app/src/i18n/us/components/navbar/navbarBottomMobileComponent.json b/frontend/app/src/i18n/us/components/navbar/navbarBottomMobileComponent.json index ed2f7a09f..1aea44f36 100644 --- a/frontend/app/src/i18n/us/components/navbar/navbarBottomMobileComponent.json +++ b/frontend/app/src/i18n/us/components/navbar/navbarBottomMobileComponent.json @@ -1,8 +1,9 @@ { - "home": "Home", - "activities": "Activities", - "search": "Search", - "gear": "Gear", - "health": "Health", - "menu": "Menu" -} \ No newline at end of file + "home": "Home", + "activities": "Activities", + "summary": "Summary", + "search": "Search", + "gear": "Gear", + "health": "Health", + "menu": "Menu" +} diff --git a/frontend/app/src/i18n/us/components/navbar/navbarComponent.json b/frontend/app/src/i18n/us/components/navbar/navbarComponent.json index 1560c6e28..b67895a06 100644 --- a/frontend/app/src/i18n/us/components/navbar/navbarComponent.json +++ b/frontend/app/src/i18n/us/components/navbar/navbarComponent.json @@ -1,6 +1,7 @@ { "search": "Search", "activities": "Activities", + "summary": "Summary", "gear": "Gear", "health": "Health", "profile": "Profile", diff --git a/frontend/app/src/i18n/us/generalItems.json b/frontend/app/src/i18n/us/generalItems.json index 5eb100ed2..ad7c0fd23 100644 --- a/frontend/app/src/i18n/us/generalItems.json +++ b/frontend/app/src/i18n/us/generalItems.json @@ -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", diff --git a/frontend/app/src/i18n/us/summaryView.json b/frontend/app/src/i18n/us/summaryView.json new file mode 100644 index 000000000..191702fa7 --- /dev/null +++ b/frontend/app/src/i18n/us/summaryView.json @@ -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." +} diff --git a/frontend/app/src/router/index.js b/frontend/app/src/router/index.js index 10d4a75c7..b08cf840c 100644 --- a/frontend/app/src/router/index.js +++ b/frontend/app/src/router/index.js @@ -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", diff --git a/frontend/app/src/services/summaryService.js b/frontend/app/src/services/summaryService.js new file mode 100644 index 000000000..37f2d95a0 --- /dev/null +++ b/frontend/app/src/services/summaryService.js @@ -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} - 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); + } +} diff --git a/frontend/app/src/utils/activityUtils.js b/frontend/app/src/utils/activityUtils.js index 9e590132b..dcc4f6242 100644 --- a/frontend/app/src/utils/activityUtils.js +++ b/frontend/app/src/utils/activityUtils.js @@ -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")}`; } diff --git a/frontend/app/src/utils/dateTimeUtils.js b/frontend/app/src/utils/dateTimeUtils.js index 1e83f7983..9569a87f5 100644 --- a/frontend/app/src/utils/dateTimeUtils.js +++ b/frontend/app/src/utils/dateTimeUtils.js @@ -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); } \ No newline at end of file diff --git a/frontend/app/src/views/ActivitiesView.vue b/frontend/app/src/views/ActivitiesView.vue index f83d6be7b..aeadd0a60 100644 --- a/frontend/app/src/views/ActivitiesView.vue +++ b/frontend/app/src/views/ActivitiesView.vue @@ -52,11 +52,11 @@ - + -
- - + + diff --git a/frontend/app/src/views/SummaryView.vue b/frontend/app/src/views/SummaryView.vue new file mode 100644 index 000000000..6b6966c12 --- /dev/null +++ b/frontend/app/src/views/SummaryView.vue @@ -0,0 +1,764 @@ + + + + +