diff --git a/src/intervalssync/gui/app.py b/src/intervalssync/gui/app.py index f72e924..5e890c5 100644 --- a/src/intervalssync/gui/app.py +++ b/src/intervalssync/gui/app.py @@ -18,6 +18,7 @@ from .settings_view import build_settings_view from .sync_view import build_sync_view from . import profile_sync_ui +from . import support_gamification from . import theme @@ -222,11 +223,17 @@ def _header() -> ft.Container: ), ], ), - ft.IconButton( - icon=ft.Icons.INFO_OUTLINE, - tooltip="About", - icon_color=colors["text_muted"], - on_click=show_about, + ft.Row( + spacing=0, + controls=[ + support_gamification.kofi_header_button(page), + ft.IconButton( + icon=ft.Icons.INFO_OUTLINE, + tooltip="About", + icon_color=colors["text_muted"], + on_click=show_about, + ), + ], ), ], ), diff --git a/src/intervalssync/gui/config.py b/src/intervalssync/gui/config.py index d052b6f..bea9ac9 100644 --- a/src/intervalssync/gui/config.py +++ b/src/intervalssync/gui/config.py @@ -61,6 +61,15 @@ class AppConfig: profile_sync_declined_fingerprint: str = "" # Prompt on launch when FTP, LTHR, or max HR differ from intervals.icu. profile_sync_check_on_launch: bool = True + # Lifetime sync stats (GUI gamification). + lifetime_activities_uploaded: int = 0 + lifetime_workouts_uploaded: int = 0 + celebrated_milestones: list[int] = field(default_factory=list) + stats_seeded: bool = False + + +def total_uploads(config: AppConfig) -> int: + return config.lifetime_activities_uploaded + config.lifetime_workouts_uploaded def any_source_enabled(config: AppConfig) -> bool: @@ -79,6 +88,12 @@ def load() -> AppConfig: if activity_source == "bryton": cfg.enable_bryton = True cfg.enable_igpsport = False + if not cfg.stats_seeded: + cfg.lifetime_workouts_uploaded = len(cfg.uploaded_workouts) + len( + cfg.uploaded_bryton_workouts + ) + cfg.stats_seeded = True + save(cfg) return cfg diff --git a/src/intervalssync/gui/support_gamification.py b/src/intervalssync/gui/support_gamification.py new file mode 100644 index 0000000..7737d37 --- /dev/null +++ b/src/intervalssync/gui/support_gamification.py @@ -0,0 +1,658 @@ +"""Lifetime sync stats, milestone celebrations, and Ko-fi support UI.""" + +from __future__ import annotations + +import asyncio +import math +import os +import random +from dataclasses import dataclass + +import flet as ft + +from . import config as config_module +from . import theme + +KOFI_URL = "https://ko-fi.com/jorge_huxley" + +MILESTONES = [5, 25, 50, 100, 250, 500, 1000] + +_MILESTONE_TITLES: dict[int, str] = { + 5: "Domestique status!", + 25: "Quarter century!", + 50: "Half century!", + 100: "Century!", + 250: "Grand tour stage!", + 500: "Monument ride!", + 1000: "Legend status!", +} + +_MILESTONE_MESSAGES: dict[int, str] = { + 5: "Five activities synced on autopilot. Your training log just got a lot easier.", + 25: "Twenty-five rides across, hands-free. You're in a proper rhythm now.", + 50: "Fifty transfers done — that's a serious stack of saved clicks.", + 100: "One hundred activities on autopilot. Every ride, right where it belongs.", + 250: "250 syncs deep. Your data flows like a freshly-oiled drivetrain.", + 500: "Five hundred transfers. That's real dedication — and a lot of saved time.", + 1000: "One thousand syncs. You've officially reached legend status.", +} + +_KOFI_LINE = ( + "Intervals Sync is free and built in spare time. " + "A small Ko-fi keeps it rolling." +) + +_CONFETTI_COLORS = ( + theme.ACCENT, + theme.ACCENT_LIGHT, + "#F2C94C", + "#27AE60", + "#2D9CDB", + "#BB6BD9", +) + +_CELEBRATION_WIDTH = 320 +_CELEBRATION_HEIGHT = 340 + + +@dataclass +class StatsCardRefs: + number: ft.Ref[ft.Text] + rank: ft.Ref[ft.Text] + breakdown: ft.Ref[ft.Text] + target: ft.Ref[ft.Text] + remaining: ft.Ref[ft.Text] + progress_bar: ft.Ref[ft.ProgressBar] + + +def total_uploads(config: config_module.AppConfig) -> int: + return config_module.total_uploads(config) + + +def rank_for(total: int) -> str: + if total <= 0: + return "Rookie" + if total < 5: + return "Warm-up lap" + if total < 10: + return "Domestique" + if total < 25: + return "Breakaway" + if total < 50: + return "Climber" + if total < 100: + return "Sprinter" + if total < 250: + return "Century rider" + return "Grand tourer" + + +def next_milestone(total: int) -> int | None: + for milestone in MILESTONES: + if total < milestone: + return milestone + return None + + +def _previous_milestone(total: int) -> int: + previous = 0 + for milestone in MILESTONES: + if total >= milestone: + previous = milestone + else: + break + return previous + + +def progress_fraction(total: int) -> float: + nxt = next_milestone(total) + if nxt is None: + return 1.0 + prev = _previous_milestone(total) + span = nxt - prev + if span <= 0: + return 0.0 + return (total - prev) / span + + +def milestone_title(milestone: int) -> str: + return _MILESTONE_TITLES.get(milestone, f"{milestone} transfers!") + + +def milestone_message(milestone: int) -> str: + return _MILESTONE_MESSAGES.get( + milestone, + f"{milestone} activities synced on autopilot. Nice work keeping your " + "training data flowing.", + ) + + +def _newly_crossed_milestone(old_total: int, new_total: int) -> int | None: + crossed: int | None = None + for milestone in MILESTONES: + if old_total < milestone <= new_total: + crossed = milestone + return crossed + + +def record_uploads( + config: config_module.AppConfig, + *, + activities: int = 0, + workouts: int = 0, +) -> int | None: + if activities <= 0 and workouts <= 0: + return None + + old_total = total_uploads(config) + config.lifetime_activities_uploaded += activities + config.lifetime_workouts_uploaded += workouts + new_total = total_uploads(config) + + milestone = _newly_crossed_milestone(old_total, new_total) + if milestone is not None and milestone not in config.celebrated_milestones: + config.celebrated_milestones.append(milestone) + config_module.save(config) + return milestone + + config_module.save(config) + return None + + +def _breakdown_text(config: config_module.AppConfig) -> str: + activities = config.lifetime_activities_uploaded + workouts = config.lifetime_workouts_uploaded + activity_word = "activity" if activities == 1 else "activities" + workout_word = "workout" if workouts == 1 else "workouts" + return f"{activities} {activity_word} · {workouts} {workout_word}" + + +def _target_text(total: int) -> str: + nxt = next_milestone(total) + if nxt is None: + return "Every milestone cleared" + return f"Next milestone · {nxt}" + + +def _remaining_text(total: int) -> str: + nxt = next_milestone(total) + if nxt is None: + return "Legend" + remaining = nxt - total + word = "sync" if remaining == 1 else "syncs" + return f"{remaining} {word} to go" + + +def update_stats_display( + page: ft.Page, + config: config_module.AppConfig, + refs: StatsCardRefs, +) -> None: + total = total_uploads(config) + if refs.number.current: + refs.number.current.value = str(total) + if refs.rank.current: + refs.rank.current.value = rank_for(total).upper() + if refs.breakdown.current: + refs.breakdown.current.value = _breakdown_text(config) + if refs.target.current: + refs.target.current.value = _target_text(total) + if refs.remaining.current: + refs.remaining.current.value = _remaining_text(total) + if refs.progress_bar.current: + refs.progress_bar.current.value = progress_fraction(total) + page.update() + + +def build_stats_card( + page: ft.Page, + config: config_module.AppConfig, + refs: StatsCardRefs, +) -> ft.Container: + colors = theme.palette(page) + total = total_uploads(config) + + # Hero: a bike-computer-style metric readout — big number, tiny eyebrow. + number = ft.Text( + str(total), + ref=refs.number, + size=46, + color=colors["accent"], + weight=ft.FontWeight.BOLD, + font_family=theme.FONT_DISPLAY, + ) + eyebrow = ft.Text( + "LIFETIME SYNCS", + size=10, + weight=ft.FontWeight.W_700, + color=colors["text_muted"], + style=ft.TextStyle(letter_spacing=1.6), + ) + hero = ft.Column(spacing=0, tight=True, controls=[number, eyebrow]) + + rank_chip = ft.Container( + content=ft.Text( + rank_for(total).upper(), + ref=refs.rank, + size=11, + weight=ft.FontWeight.W_700, + color=colors["accent"], + style=ft.TextStyle(letter_spacing=0.8), + ), + padding=ft.Padding(theme.SPACE_SM, 5, theme.SPACE_SM, 5), + bgcolor=colors["accent_soft"], + border_radius=999, + ) + breakdown = ft.Text( + _breakdown_text(config), + ref=refs.breakdown, + size=11, + color=colors["text_muted"], + text_align=ft.TextAlign.RIGHT, + ) + meta = ft.Column( + spacing=theme.SPACE_XS, + horizontal_alignment=ft.CrossAxisAlignment.END, + controls=[rank_chip, breakdown], + ) + + top_row = ft.Row( + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=ft.CrossAxisAlignment.START, + controls=[hero, meta], + ) + + target = ft.Text( + _target_text(total), + ref=refs.target, + size=11, + weight=ft.FontWeight.W_600, + color=colors["text_muted"], + font_family=f"{theme.FONT_BODY}Medium", + ) + remaining = ft.Text( + _remaining_text(total), + ref=refs.remaining, + size=11, + color=colors["text_muted"], + ) + progress_header = ft.Row( + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + controls=[target, remaining], + ) + progress_bar = ft.ProgressBar( + ref=refs.progress_bar, + value=progress_fraction(total), + color=colors["accent"], + bgcolor=colors["surface_alt"], + bar_height=6, + border_radius=3, + ) + progress = ft.Column( + spacing=theme.SPACE_XS, + controls=[progress_header, progress_bar], + ) + + return ft.Container( + content=ft.Column( + spacing=theme.SPACE_MD, + controls=[ + top_row, + progress, + ft.TextButton( + "Saved you time? Buy me a coffee", + url=KOFI_URL, + style=ft.ButtonStyle( + color=colors["text_muted"], + padding=ft.Padding(0, 0, 0, 0), + ), + ), + ], + ), + padding=theme.SPACE_LG, + bgcolor=colors["surface"], + border=ft.Border.all(1, colors["border"]), + border_radius=theme.RADIUS_MD, + ) + + +async def show_kofi_dialog(page: ft.Page) -> None: + colors = theme.palette(page) + + page.show_dialog( + ft.AlertDialog( + modal=True, + shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_MD), + title=theme.display_text("Support development", size=22), + content=ft.Text( + "If Intervals Sync saves you time, consider supporting development on Ko-fi. " + "Tips help fund bug fixes, releases, and new features.", + size=13, + color=colors["text_muted"], + ), + actions=[ + ft.TextButton("Not now", on_click=lambda _: page.pop_dialog()), + ft.TextButton("Support on Ko-fi", url=KOFI_URL), + ], + ) + ) + page.update() + + +def kofi_header_button(page: ft.Page) -> ft.IconButton: + colors = theme.palette(page) + + async def on_click(_: ft.ControlEvent) -> None: + await show_kofi_dialog(page) + + return ft.IconButton( + icon=ft.Icons.LOCAL_CAFE_OUTLINED, + tooltip="Support on Ko-fi", + icon_color=colors["text_muted"], + on_click=on_click, + ) + + +def _build_confetti() -> list[ft.Container]: + """Small colored squares/circles that start near the top of the card.""" + particles: list[ft.Container] = [] + for _ in range(30): + size = random.randint(6, 12) + start_left = random.uniform(0, _CELEBRATION_WIDTH) + duration = random.randint(950, 1500) + particle = ft.Container( + width=size, + height=size, + bgcolor=random.choice(_CONFETTI_COLORS), + border_radius=size / 2 if random.random() > 0.5 else 2, + left=start_left, + top=random.uniform(-24, 8), + opacity=1.0, + rotate=ft.Rotate(0, alignment=ft.Alignment.CENTER), + ignore_interactions=True, + animate_position=ft.Animation( + duration=ft.Duration(milliseconds=duration), + curve=ft.AnimationCurve.EASE_IN, + ), + animate_opacity=ft.Animation( + duration=ft.Duration(milliseconds=duration), + curve=ft.AnimationCurve.EASE_IN, + ), + animate_rotation=ft.Animation( + duration=ft.Duration(milliseconds=duration), + curve=ft.AnimationCurve.LINEAR, + ), + ) + particle.data = { + "left": start_left + random.uniform(-50, 50), + "top": _CELEBRATION_HEIGHT + random.uniform(0, 60), + "angle": random.uniform(-math.pi * 3, math.pi * 3), + } + particles.append(particle) + return particles + + +async def _play_celebration( + page: ft.Page, + number_ref: ft.Ref[ft.Container], + particles: list[ft.Container], +) -> None: + await asyncio.sleep(0.05) + if number_ref.current is not None: + number_ref.current.scale = 1.0 + number_ref.current.opacity = 1.0 + for particle in particles: + target = particle.data + particle.left = target["left"] + particle.top = target["top"] + particle.opacity = 0.0 + particle.rotate = ft.Rotate(target["angle"], alignment=ft.Alignment.CENTER) + try: + page.update() + except Exception: + pass + + +async def show_milestone_dialog(page: ft.Page, milestone: int) -> None: + colors = theme.palette(page) + title = milestone_title(milestone) + message = milestone_message(milestone) + + number_ref: ft.Ref[ft.Container] = ft.Ref() + big_number = ft.Container( + ref=number_ref, + content=theme.display_text( + str(milestone), + size=76, + color=colors["accent"], + weight=ft.FontWeight.BOLD, + ), + alignment=ft.Alignment.CENTER, + opacity=0.0, + scale=0.5, + animate_scale=ft.Animation( + duration=ft.Duration(milliseconds=520), + curve=ft.AnimationCurve.EASE_OUT_BACK, + ), + animate_opacity=ft.Animation( + duration=ft.Duration(milliseconds=360), + curve=ft.AnimationCurve.EASE_OUT, + ), + ) + + content_column = ft.Column( + tight=True, + spacing=theme.SPACE_XS, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + controls=[ + big_number, + ft.Text( + "SYNCS COMPLETED", + size=11, + weight=ft.FontWeight.W_600, + color=colors["text_muted"], + text_align=ft.TextAlign.CENTER, + ), + ft.Container(height=theme.SPACE_SM), + theme.display_text(title, size=24, color=colors["text"]), + ft.Container(height=2), + ft.Text( + message, + size=13, + color=colors["text_muted"], + text_align=ft.TextAlign.CENTER, + ), + ft.Container(height=theme.SPACE_MD), + ft.Text( + _KOFI_LINE, + size=12, + color=colors["text_muted"], + text_align=ft.TextAlign.CENTER, + ), + ], + ) + + particles = _build_confetti() + content = ft.Container( + width=_CELEBRATION_WIDTH, + height=_CELEBRATION_HEIGHT, + content=ft.Stack( + controls=[ + ft.Container( + width=_CELEBRATION_WIDTH, + height=_CELEBRATION_HEIGHT, + alignment=ft.Alignment.CENTER, + content=content_column, + ), + *particles, + ], + ), + ) + + page.show_dialog( + ft.AlertDialog( + modal=True, + shape=ft.RoundedRectangleBorder(radius=theme.RADIUS_MD), + content=content, + actions=[ + ft.TextButton("Buy me a coffee", url=KOFI_URL), + ft.FilledButton( + "Keep rolling", + on_click=lambda _: page.pop_dialog(), + ), + ], + ) + ) + page.update() + + page.run_task(_play_celebration, page, number_ref, particles) + + +# --- Dev-only milestone testing (INTERVALSSYNC_DEV_GAMIFICATION=1) --- + + +def dev_mode_enabled() -> bool: + return os.environ.get("INTERVALSSYNC_DEV_GAMIFICATION", "").strip().lower() in { + "1", + "true", + "yes", + } + + +def dev_reset_stats(config: config_module.AppConfig) -> None: + config.lifetime_activities_uploaded = 0 + config.lifetime_workouts_uploaded = 0 + config.celebrated_milestones = [] + config.stats_seeded = True + config_module.save(config) + + +def dev_set_total(config: config_module.AppConfig, total: int) -> None: + total = max(0, total) + config.lifetime_activities_uploaded = total + config.lifetime_workouts_uploaded = 0 + config.celebrated_milestones = [milestone for milestone in MILESTONES if milestone <= total] + config.stats_seeded = True + config_module.save(config) + + +def dev_bump_and_maybe_celebrate( + page: ft.Page, + config: config_module.AppConfig, + stats_refs: StatsCardRefs, + *, + activities: int = 0, + workouts: int = 0, +) -> None: + milestone = record_uploads(config, activities=activities, workouts=workouts) + update_stats_display(page, config, stats_refs) + if milestone is None: + page.update() + return + + async def _celebrate() -> None: + await show_milestone_dialog(page, milestone) + + page.run_task(_celebrate) + + +def build_dev_milestone_panel( + page: ft.Page, + config: config_module.AppConfig, + stats_refs: StatsCardRefs, +) -> ft.Container: + colors = theme.palette(page) + total_field = ft.TextField( + label="Total transfers", + value=str(total_uploads(config)), + keyboard_type=ft.KeyboardType.NUMBER, + width=140, + dense=True, + ) + + def _refresh_total_field() -> None: + total_field.value = str(total_uploads(config)) + + def on_reset(_: ft.ControlEvent) -> None: + dev_reset_stats(config) + update_stats_display(page, config, stats_refs) + _refresh_total_field() + page.update() + + def on_bump(activities: int = 0, workouts: int = 0): + def handler(_: ft.ControlEvent) -> None: + dev_bump_and_maybe_celebrate( + page, + config, + stats_refs, + activities=activities, + workouts=workouts, + ) + _refresh_total_field() + page.update() + + return handler + + def on_apply_total(_: ft.ControlEvent) -> None: + try: + total = int((total_field.value or "0").strip()) + except ValueError: + return + dev_set_total(config, total) + update_stats_display(page, config, stats_refs) + _refresh_total_field() + page.update() + + def on_preview(milestone: int): + async def handler(_: ft.ControlEvent) -> None: + await show_milestone_dialog(page, milestone) + + return handler + + preview_buttons = [ + ft.TextButton(str(milestone), on_click=on_preview(milestone)) + for milestone in MILESTONES + ] + + return ft.Container( + content=ft.Column( + spacing=theme.SPACE_SM, + controls=[ + ft.Text( + "DEV milestone testing (INTERVALSSYNC_DEV_GAMIFICATION=1)", + size=11, + weight=ft.FontWeight.W_600, + color=ft.Colors.ORANGE_400, + ), + ft.Row( + wrap=True, + spacing=theme.SPACE_SM, + controls=[ + ft.OutlinedButton("+1 activity", on_click=on_bump(activities=1)), + ft.OutlinedButton("+1 workout", on_click=on_bump(workouts=1)), + ft.OutlinedButton("+5 transfers", on_click=on_bump(activities=5)), + ft.OutlinedButton("Reset stats", on_click=on_reset), + ], + ), + ft.Row( + spacing=theme.SPACE_SM, + controls=[ + total_field, + ft.OutlinedButton("Set total", on_click=on_apply_total), + ], + ), + ft.Row( + wrap=True, + spacing=0, + controls=[ + ft.Text("Preview popup:", size=11, color=colors["text_muted"]), + *preview_buttons, + ], + ), + ], + ), + padding=theme.SPACE_MD, + bgcolor=colors["surface_alt"], + border=ft.Border.all(1, ft.Colors.ORANGE_400), + border_radius=theme.RADIUS_SM, + ) diff --git a/src/intervalssync/gui/sync_view.py b/src/intervalssync/gui/sync_view.py index cb6a516..54a1b13 100644 --- a/src/intervalssync/gui/sync_view.py +++ b/src/intervalssync/gui/sync_view.py @@ -18,6 +18,7 @@ from ..igpsport.core import SyncConfig as IgpSyncConfig, SyncError, sync as igpsport_sync from ..dropbox_client import get_dropbox_app_key from ..igpsport.workout import WorkoutUploadConfig, apply_uploaded_workout_map, upload_workouts +from . import support_gamification from . import theme @@ -36,6 +37,15 @@ def build_sync_view( border_radius=2, ) log = ft.ListView(spacing=4, auto_scroll=True, height=280, expand=False) + stats_refs = support_gamification.StatsCardRefs( + number=ft.Ref[ft.Text](), + rank=ft.Ref[ft.Text](), + breakdown=ft.Ref[ft.Text](), + target=ft.Ref[ft.Text](), + remaining=ft.Ref[ft.Text](), + progress_bar=ft.Ref[ft.ProgressBar](), + ) + stats_card = support_gamification.build_stats_card(page, config, stats_refs) def _action_button(label: str, icon: str, *, outlined: bool = False) -> ft.FilledButton | ft.OutlinedButton: label_size = 13 if mobile else 14 @@ -156,6 +166,20 @@ def append_log(message: str) -> None: ) page.update() + def _on_sync_complete(*, activities: int = 0, workouts: int = 0) -> None: + milestone = support_gamification.record_uploads( + config, + activities=activities, + workouts=workouts, + ) + support_gamification.update_stats_display(page, config, stats_refs) + if milestone is not None: + + async def _celebrate() -> None: + await support_gamification.show_milestone_dialog(page, milestone) + + page.run_task(_celebrate) + def run_igpsport_sync( igp_password: str, api_key: str | None, @@ -183,8 +207,10 @@ def run_igpsport_sync( dropbox_date_filenames=config.dropbox_date_filenames, ) + uploaded_count = 0 try: result = igpsport_sync(sync_config, progress=append_log) + uploaded_count = result.uploaded append_log( f"\nDone — intervals uploaded {result.uploaded}, " f"Dropbox uploaded {result.uploaded_dropbox}, " @@ -197,6 +223,8 @@ def run_igpsport_sync( except Exception as exc: # noqa: BLE001 — surface any failure to the user append_log(f"✗ Unexpected error: {exc}") finally: + if uploaded_count > 0: + _on_sync_complete(activities=uploaded_count) progress.visible = False set_buttons_enabled(True) page.update() @@ -226,8 +254,10 @@ def run_bryton_sync( dropbox_date_filenames=config.dropbox_date_filenames, ) + uploaded_count = 0 try: result = bryton_sync(sync_config, progress=append_log) + uploaded_count = result.uploaded append_log( f"\nDone — intervals uploaded {result.uploaded}, " f"Dropbox uploaded {result.uploaded_dropbox}, " @@ -240,6 +270,8 @@ def run_bryton_sync( except Exception as exc: # noqa: BLE001 — surface any failure to the user append_log(f"✗ Unexpected error: {exc}") finally: + if uploaded_count > 0: + _on_sync_complete(activities=uploaded_count) progress.visible = False set_buttons_enabled(True) page.update() @@ -255,11 +287,13 @@ def run_upload_igp_workouts(igp_password: str, api_key: str) -> None: force_resync=config.force_resync, ) + uploaded_count = 0 try: result = upload_workouts(upload_config, progress=append_log) if result.uploaded_map or result.pruned_keys: apply_uploaded_workout_map(config.uploaded_workouts, result) config_module.save(config) + uploaded_count = result.uploaded append_log( f"\nDone — uploaded {result.uploaded}, " f"skipped {result.skipped}, no steps {result.no_steps}, " @@ -270,6 +304,8 @@ def run_upload_igp_workouts(igp_password: str, api_key: str) -> None: except Exception as exc: # noqa: BLE001 — surface any failure to the user append_log(f"✗ Unexpected error: {exc}") finally: + if uploaded_count > 0: + _on_sync_complete(workouts=uploaded_count) progress.visible = False set_buttons_enabled(True) page.update() @@ -284,11 +320,13 @@ def run_upload_bryton_workouts(bryton_password: str, api_key: str) -> None: force_resync=config.force_resync, ) + uploaded_count = 0 try: result = bryton_upload_workouts(upload_config, progress=append_log) if result.uploaded_map or result.pruned_keys: apply_uploaded_bryton_workout_map(config.uploaded_bryton_workouts, result) config_module.save(config) + uploaded_count = result.uploaded append_log( f"\nDone — uploaded {result.uploaded}, " f"skipped {result.skipped}, no steps {result.no_steps}, " @@ -299,6 +337,8 @@ def run_upload_bryton_workouts(bryton_password: str, api_key: str) -> None: except Exception as exc: # noqa: BLE001 — surface any failure to the user append_log(f"✗ Unexpected error: {exc}") finally: + if uploaded_count > 0: + _on_sync_complete(workouts=uploaded_count) progress.visible = False set_buttons_enabled(True) page.update() @@ -435,6 +475,12 @@ async def on_upload_bryton_workouts_click(_: ft.ControlEvent) -> None: action_area, progress, log_panel, + stats_card, + *( + [support_gamification.build_dev_milestone_panel(page, config, stats_refs)] + if support_gamification.dev_mode_enabled() + else [] + ), ft.Container(height=theme.SPACE_MD), ], ) diff --git a/tests/test_config.py b/tests/test_config.py index c54f03d..4e63522 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -22,6 +22,11 @@ def test_defaults(): assert cfg.uploaded_workouts == {} assert cfg.uploaded_bryton_workouts == {} assert cfg.workout_days_ahead == 1 + assert cfg.lifetime_activities_uploaded == 0 + assert cfg.lifetime_workouts_uploaded == 0 + assert cfg.celebrated_milestones == [] + assert cfg.stats_seeded is False + assert config_module.total_uploads(cfg) == 0 assert config_module.any_source_enabled(cfg) is True @@ -110,6 +115,50 @@ def test_load_migrates_activity_source_igpsport(tmp_path, monkeypatch): assert loaded.igp_user == "u@x.com" +def test_load_seeds_workout_stats_from_maps(tmp_path, monkeypatch): + path = tmp_path / "config.json" + path.write_text( + json.dumps( + { + "uploaded_workouts": {"evt1": 101, "evt2": 102}, + "uploaded_bryton_workouts": {"evt3": "file-a"}, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(config_module, "CONFIG_PATH", path) + + loaded = config_module.load() + assert loaded.lifetime_workouts_uploaded == 3 + assert loaded.lifetime_activities_uploaded == 0 + assert loaded.stats_seeded is True + saved = json.loads(path.read_text(encoding="utf-8")) + assert saved["lifetime_workouts_uploaded"] == 3 + assert saved["stats_seeded"] is True + + +def test_save_and_load_lifetime_stats(tmp_path, monkeypatch): + path = tmp_path / "config.json" + monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(config_module, "CONFIG_PATH", path) + + cfg = config_module.AppConfig( + lifetime_activities_uploaded=42, + lifetime_workouts_uploaded=8, + celebrated_milestones=[1, 5, 10], + stats_seeded=True, + ) + config_module.save(cfg) + + loaded = config_module.load() + assert loaded.lifetime_activities_uploaded == 42 + assert loaded.lifetime_workouts_uploaded == 8 + assert loaded.celebrated_milestones == [1, 5, 10] + assert loaded.stats_seeded is True + assert config_module.total_uploads(loaded) == 50 + + def test_save_and_load_igp_region(tmp_path, monkeypatch): path = tmp_path / "config.json" monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path) diff --git a/tests/test_support_gamification.py b/tests/test_support_gamification.py new file mode 100644 index 0000000..8f6f8e1 --- /dev/null +++ b/tests/test_support_gamification.py @@ -0,0 +1,92 @@ +"""Tests for sync milestone gamification logic.""" + +from __future__ import annotations + +from intervalssync.gui import config as config_module +from intervalssync.gui import support_gamification as gamification + + +def test_rank_for_tiers(): + assert gamification.rank_for(0) == "Rookie" + assert gamification.rank_for(1) == "Warm-up lap" + assert gamification.rank_for(4) == "Warm-up lap" + assert gamification.rank_for(5) == "Domestique" + assert gamification.rank_for(10) == "Breakaway" + assert gamification.rank_for(25) == "Climber" + assert gamification.rank_for(50) == "Sprinter" + assert gamification.rank_for(100) == "Century rider" + assert gamification.rank_for(250) == "Grand tourer" + assert gamification.rank_for(1000) == "Grand tourer" + + +def test_next_milestone_and_progress(): + assert gamification.next_milestone(0) == 5 + assert gamification.next_milestone(1) == 5 + assert gamification.next_milestone(9) == 25 + assert gamification.next_milestone(1000) is None + + assert gamification.progress_fraction(0) == 0.0 + assert gamification.progress_fraction(1) == 0.2 + assert gamification.progress_fraction(3) == 0.6 + assert gamification.progress_fraction(5) == 0.0 + assert gamification.progress_fraction(1000) == 1.0 + + +def test_record_uploads_increments_and_saves(tmp_path, monkeypatch): + path = tmp_path / "config.json" + monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(config_module, "CONFIG_PATH", path) + + cfg = config_module.AppConfig( + lifetime_activities_uploaded=1, + celebrated_milestones=[1], + stats_seeded=True, + ) + assert gamification.record_uploads(cfg, activities=3) is None + assert cfg.lifetime_activities_uploaded == 4 + assert config_module.total_uploads(cfg) == 4 + + milestone = gamification.record_uploads(cfg, activities=1) + assert milestone == 5 + assert cfg.lifetime_activities_uploaded == 5 + assert cfg.celebrated_milestones == [1, 5] + + assert gamification.record_uploads(cfg, workouts=1) is None + assert cfg.lifetime_workouts_uploaded == 1 + assert config_module.total_uploads(cfg) == 6 + + +def test_record_uploads_skips_zero_and_repeat_milestones(tmp_path, monkeypatch): + path = tmp_path / "config.json" + monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(config_module, "CONFIG_PATH", path) + + cfg = config_module.AppConfig( + lifetime_activities_uploaded=24, + celebrated_milestones=[5], + stats_seeded=True, + ) + milestone = gamification.record_uploads(cfg, activities=1) + assert milestone == 25 + assert cfg.celebrated_milestones == [5, 25] + + assert gamification.record_uploads(cfg, activities=0) is None + assert gamification.record_uploads(cfg, activities=1) is None + + +def test_record_uploads_returns_highest_crossed_milestone(tmp_path, monkeypatch): + path = tmp_path / "config.json" + monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path) + monkeypatch.setattr(config_module, "CONFIG_PATH", path) + + cfg = config_module.AppConfig(stats_seeded=True) + milestone = gamification.record_uploads(cfg, activities=25) + assert milestone == 25 + assert cfg.lifetime_activities_uploaded == 25 + assert cfg.celebrated_milestones == [25] + + +def test_milestone_title(): + assert gamification.milestone_title(5) == "Domestique status!" + assert gamification.milestone_title(100) == "Century!" + assert gamification.milestone_title(999) == "999 transfers!"