summary view added + some fixes

This commit is contained in:
Maks Mržek
2025-05-18 10:37:44 +02:00
parent d1fc8764b2
commit 2fae883686
25 changed files with 1858 additions and 300 deletions

11
.gitignore vendored
View File

@@ -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/

View 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

View 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'.",
)

View 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

View File

@@ -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",

View File

@@ -1 +1 @@
VITE_ENDURAIN_HOST=MY_APP_ENDURAIN_HOST
VITE_ENDURAIN_HOST=http://localhost:8080

View File

@@ -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>

View File

@@ -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>

View File

@@ -28,7 +28,7 @@
"unitsMph": "mph",
"unitsLbs": "lliures",
"labelWeightInLbs": "Pes en lliures",
"unitsCalories": "cal",
"unitsCalories": "kcal",
"unitsBpm": "bpm",
"labelHRinBpm": "Bategs en bpm",
"unitsWattsShort": "W",

View File

@@ -28,7 +28,7 @@
"unitsMph": "mph",
"unitsLbs": "lb",
"labelWeightInLbs": "Gewicht in lbs",
"unitsCalories": "cal",
"unitsCalories": "kcal",
"unitsBpm": "bpm",
"labelHRinBpm": "Herzfrequenz in bpm",
"unitsWattsShort": "W",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,
},
};

View File

@@ -28,7 +28,7 @@
"unitsMph": "mph",
"unitsLbs": "lbs",
"labelWeightInLbs": "Gewicht in lbs",
"unitsCalories": "cal",
"unitsCalories": "kcal",
"unitsBpm": "bpm",
"labelHRinBpm": "Hartslag in bpm",
"unitsWattsShort": "W",

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -1,6 +1,7 @@
{
"search": "Search",
"activities": "Activities",
"summary": "Summary",
"gear": "Gear",
"health": "Health",
"profile": "Profile",

View File

@@ -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",

View 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."
}

View File

@@ -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",

View 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);
}
}

View File

@@ -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")}`;
}

View File

@@ -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);
}

View File

@@ -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>

View 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>