### Install and Run Web Dashboard Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Install the package and start the local web dashboard. Access it at localhost:8123 for setup and syncing. Ensure Python is installed. ```bash pip install hevy2garmin hevy2garmin serve ``` -------------------------------- ### Install from Source Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md If not yet on PyPI, install directly from the source repository. This requires Git to clone the repository first. ```bash git clone https://github.com/drkostas/hevy2garmin.git && cd hevy2garmin && pip install . ``` -------------------------------- ### Clone Repository and Set Up Development Environment Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Clone the hevy2garmin repository, create and activate a virtual environment, and install development dependencies. This is the initial setup for contributing to the project. ```bash git clone https://github.com/drkostas/hevy2garmin.git cd hevy2garmin python -m venv .venv && source .venv/bin/activate pip install -e ".[dev]" pytest tests/ -v ``` -------------------------------- ### Clone and Set Up Development Environment Source: https://github.com/drkostas/hevy2garmin/blob/main/CONTRIBUTING.md Clone the repository, navigate into the directory, create and activate a virtual environment, and install development dependencies. ```bash git clone https://github.com/drkostas/hevy2garmin.git cd hevy2garmin python -m venv .venv && source .venv/bin/activate pip install -e ".[dev,cloud]" ``` -------------------------------- ### Install hevy2garmin locally with Git clone Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Instructions for cloning the repository and installing the hevy2garmin package in editable mode locally. ```bash cd hevy2garmin git pull origin main pip install -e . ``` -------------------------------- ### CLI Initialization and Sync Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Install the CLI tool and initialize it with Hevy API key and Garmin credentials. Use 'sync' to transfer workouts. ```bash pip install hevy2garmin # Interactive setup (Hevy API key + Garmin credentials) hevy2garmin init # Sync your 10 most recent workouts hevy2garmin sync # List recent workouts (checkmark = already synced) hevy2garmin list # Check sync status hevy2garmin status # Dry run (generate FIT files without uploading) hevy2garmin sync --dry-run # Sync last 5 workouts only hevy2garmin sync -n 5 ``` -------------------------------- ### Install with Cloud Support Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Install hevy2garmin with additional dependencies for cloud deployments, including Postgres support. This enables automatic backend detection via the DATABASE_URL environment variable. ```bash pip install hevy2garmin[cloud] ``` -------------------------------- ### Install/Upgrade hevy2garmin with pip Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Use this command to install or upgrade the hevy2garmin package using pip. ```bash pip install --upgrade hevy2garmin ``` -------------------------------- ### Example hevy2garmin activity description Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md This is an example of the text description that hevy2garmin adds to Garmin activities, summarizing the workout session. The format varies slightly for cardio exercises. ```text πŸ‹οΈ Push Day ⏱️ 52 min πŸ”₯ 387 kcal ❀️ avg 118 bpm β€’ Bench Press (Barbell): 3 sets Β· 80.0kg Γ— 8 β€’ Incline Dumbbell Press: 3 sets Β· 28.0kg Γ— 10 β€’ Cable Fly: 3 sets Β· 15.0kg Γ— 12 β€” synced by hevy2garmin ``` -------------------------------- ### Test Postgres Backend Locally Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Install additional dependencies for cloud features and set the DATABASE_URL environment variable to test the PostgreSQL backend. This command runs all tests with the specified database configuration. ```bash pip install -e ".[dev,cloud]" DATABASE_URL=postgresql://user:pass@localhost:5432/hevy2garmin pytest tests/ -v ``` -------------------------------- ### Run Local Dashboard Server Source: https://github.com/drkostas/hevy2garmin/blob/main/CONTRIBUTING.md Start the hevy2garmin dashboard locally on a specified port. The development server will be accessible at http://localhost:8123. ```bash PYTHONPATH=src python -m hevy2garmin.cli serve --port 8123 ``` -------------------------------- ### systemd Service File for Linux Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Configuration for a systemd service to manage the hevy2garmin dashboard. Set environment variables for API keys and credentials. Enable and start the service using systemctl. ```ini [Unit] Description=hevy2garmin dashboard After=network.target [Service] ExecStart=hevy2garmin serve Restart=always User=your-username Environment=HEVY_API_KEY=your-key Environment=GARMIN_EMAIL=your-email [Install] WantedBy=multi-user.target ``` -------------------------------- ### Add Custom Exercise Mapping Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Add custom exercise mappings to Garmin categories via the command line. Ensure you have the 'hevy2garmin' tool installed and configured. ```bash hevy2garmin map "My Custom Exercise" --category 28 --subcategory 0 ``` -------------------------------- ### JavaScript Sync Logic for Hevy2Garmin Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/dashboard.html Handles the synchronization process, including starting, stopping, and displaying progress. It makes API calls to sync workouts one by one and updates the UI accordingly. Use this for manual sync initiation. ```javascript let _syncStopped = false; function stopSync() { _syncStopped = true; document.getElementById('sync-stop-btn').style.display = 'none'; } async function syncNow() { _syncStopped = false; const btn = document.getElementById('sync-now-btn'); const stopBtn = document.getElementById('sync-stop-btn'); const progress = document.getElementById('sync-progress'); const text = document.getElementById('sync-progress-text'); const bar = document.getElementById('sync-progress-bar'); const result = document.getElementById('sync-result'); btn.disabled = true; btn.style.opacity = '0.5'; stopBtn.style.display = ''; progress.style.display = 'block'; result.innerHTML = ''; text.textContent = 'Starting sync...'; bar.style.width = '0%'; let total = 0; let synced = 0; let errors = 0; while (!_syncStopped) { try { const resp = await fetch('/api/sync-one', { method: 'POST' }); const data = await resp.json(); if (data.busy) { text.textContent = 'Another sync is already running. Please wait.'; break; } if (data.error && !data.title) { errors++; // Show user-friendly error messages const err = data.error; if (data.eu_consent) { text.textContent = 'Garmin requires upload consent.'; result.innerHTML = '
Enable Device Upload in Garmin Settings > Data, then try again.
'; } else if (err.includes('Hevy API key')) { text.textContent = 'Hevy API key expired. Enter a new key in Setup.'; result.innerHTML = '
Hevy API key invalid. Go to Setup
'; } else if (err.includes('Login failed') || err.includes('auth') || err.includes('OAuth') || err.includes('token')) { text.textContent = 'Garmin connection expired. Please reconnect from Setup.'; result.innerHTML = '
Garmin connection expired. Reconnect
'; } else { text.textContent = 'Sync error. ' + (synced > 0 ? synced + ' synced before error.' : 'Try again.'); } break; } if (data.done || (data.synced === 0 && data.remaining <= 0)) { text.textContent = synced > 0 ? 'All caught up!' : 'Everything is already synced.'; bar.style.width = '100%'; break; } if (data.synced > 0) { synced++; if (total === 0) total = synced + (data.remaining || 0); const pct = total > 0 ? Math.round((synced / total) * 100) : 0; const verb = data.skipped_error ? 'Skipped (error)' : data.skipped_duplicate ? 'Skipped' : 'Synced'; text.textContent = verb + ' "' + data.title + '" (' + synced + '/' + total + ')'; bar.style.width = pct + '%'; // Update stats live if (data.remaining >= 0) { const onGarminEl = document.getElementById('stat-on-garmin'); const pendingEl = document.getElementById('stat-pending'); if (pendingEl) pendingEl.textContent = data.remaining; if (onGarminEl) onGarminEl.textContent = total - data.remaining; } } else if (data.remaining > 0) { continue; } else { break; } } catch (e) { errors++; text.textContent = 'Network error. ' + (synced > 0 ? synced + ' synced so far. ' : '') + 'Try again.'; break; } } if (_syncStopped && synced > 0) { text.textContent = 'Paused after ' + synced + ' workouts. Click Sync Now to resume.'; } progress.style.display = 'none'; stopBtn.style.display = 'none'; btn.disabled = false; btn.style.opacity = '1'; if (synced > 0 && !_syncStopped) { result.innerHTML = '
' + synced + ' workout' + (synced > 1 ? 's' : '') + ' synced to Garmin.
'; setTimeout(function() { location.reload(); }, 2000); } else if (synced > 0 && _syncStopped) { result.innerHTML = '
' + synced + ' synced. Resume anytime.
'; setTimeout(function() { location.reload(); }, 2000); } else if (errors > 0 && !result.innerHTML) { result.innerHTML = '
' + text.textContent + '
'; } else { result.innerHTML = '
Everything is already synced.
'; } } ``` -------------------------------- ### Docker Initialization Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Initialize Garmin authentication tokens within a Docker container. This can be done interactively or by mounting existing configuration. ```bash docker run -it -v ~/.garminconnect:/root/.garminconnect hevy2garmin init ``` -------------------------------- ### Build hevy2garmin Docker image Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Steps to build the hevy2garmin Docker image locally. Ensure you are in the project directory and have pulled the latest changes. ```bash cd hevy2garmin git pull origin main docker build -t hevy2garmin . ``` -------------------------------- ### Docker Build Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Build the hevy2garmin Docker image from the source code. This prepares the image for running in a Docker environment. ```bash git clone https://github.com/drkostas/hevy2garmin.git cd hevy2garmin docker build -t hevy2garmin . ``` -------------------------------- ### Python API Sync Workouts Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Use the Python API to sync workouts. It can use existing configuration files or accept credentials directly as arguments. ```python from hevy2garmin.sync import sync # Uses config from ~/.hevy2garmin/config.json (or env vars) result = sync() print(f"Synced: {result['synced']}, Skipped: {result['skipped']}") # Or pass credentials directly (no config file needed) result = sync(hevy_api_key="...", garmin_email="...", garmin_password="...") ``` -------------------------------- ### One-off Sync in Docker Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Perform a single workout sync using Docker. This command runs the sync process and then exits, cleaning up the container. ```bash docker run --rm \ -v ~/.hevy2garmin:/root/.hevy2garmin \ -v ~/.garminconnect:/root/.garminconnect \ -e HEVY_API_KEY=... \ -e GARMIN_EMAIL=... \ hevy2garmin sync ``` -------------------------------- ### Scheduled Sync via Crontab Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Set up a cron job to automatically sync workouts every 2 hours using the credentials saved by 'hevy2garmin init'. ```bash # Sync every 2 hours (uses credentials saved by hevy2garmin init) 0 */2 * * * hevy2garmin sync ``` -------------------------------- ### Run Web Dashboard in Docker Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Run the hevy2garmin web dashboard as a Docker container. Mounts necessary volumes for configuration and logs, and sets environment variables. Enables auto-sync via the dashboard. ```bash docker run -d -p 8123:8123 --restart unless-stopped \ -v ~/.hevy2garmin:/root/.hevy2garmin \ -v ~/.garminconnect:/root/.garminconnect \ -e HEVY_API_KEY=... \ -e GARMIN_EMAIL=... \ hevy2garmin serve ``` -------------------------------- ### Architecture Diagram Source: https://github.com/drkostas/hevy2garmin/blob/main/docs/AI_HANDOVER.md Illustrates the data flow and components involved in the hevy2garmin synchronization process. ```text Hevy API β†’ HevyClient β†’ workout JSON ↓ ExerciseMapper (438 mappings) ↓ FIT Generator (fit-tool SDK) ↓ Garmin Upload (garmin-auth + garminconnect) ↓ SQLite (track what's synced) ``` -------------------------------- ### Run Web Dashboard in Background Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Run the hevy2garmin server in the background using nohup to keep it running after closing the terminal. Redirects output to prevent logs from filling up. ```bash nohup hevy2garmin serve > /dev/null 2>&1 & ``` -------------------------------- ### Python API FIT File Generation Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Generate FIT files for workouts using the Python API. Requires a workout dictionary from the Hevy API and optionally accepts heart rate samples. ```python # Just FIT generation (see Hevy API docs for workout dict format: # https://docs.hevy.com/#tag/workout/operation/workout) from hevy2garmin.fit import generate_fit result = generate_fit(hevy_workout_dict, hr_samples=None, output_path="workout.fit") ``` -------------------------------- ### Garmin Connection Logic Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/setup.html Handles the initial connection to Garmin using email and password. It displays status messages and manages the enable/disable state of the connect button. ```javascript const GARMIN_WORKER_BASE = 'https://hevy2garmin-exchange-di.gkos.workers.dev'; // ── Garmin inline login (preferred UX, no copy-paste) ────────────────────── let garminMfaSessionId = null; async function garminConnect() { const email = document.getElementById('garmin_email').value.trim(); const password = document.getElementById('garmin_password').value; const result = document.getElementById('garmin_connect_result'); const btn = document.getElementById('garmin_connect_btn'); if (!email || !password) { result.innerHTML = 'Enter email and password first'; return; } btn.disabled = true; result.innerHTML = 'Connecting to Garmin...'; try { const resp = await fetch(GARMIN_WORKER_BASE + '/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: password }), }); const data = await resp.json(); await handleGarminLoginResponse(data); } catch (e) { result.innerHTML = '✗ Network error, try again'; } finally { btn.disabled = false; } } ``` -------------------------------- ### Run Tests with Pytest Source: https://github.com/drkostas/hevy2garmin/blob/main/CONTRIBUTING.md Execute all tests using pytest. The PYTHONPATH is set to include the src directory. ```bash PYTHONPATH=src pytest tests/ -q ``` -------------------------------- ### Load Categories from API and Populate Selects Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/mappings.html Fetches category data from '/api/garmin-categories', sorts them by ID (excluding '65534'), and populates all elements with the class 'cat-select' with these categories as options. ```javascript let catData = {}; fetch('/api/garmin-categories').then(r => r.json()).then(data => { catData = data; const selects = document.querySelectorAll('.cat-select'); const sorted = Object.entries(data) .filter(([id]) => id !== '65534') .sort((a, b) => parseInt(a[0]) - parseInt(b[0])); selects.forEach(sel => { sel.innerHTML = sorted.map(([id, name]) => '').join(''); }); }); ``` -------------------------------- ### Unsync All Workouts Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/workouts.html Unsyncs all workouts after a confirmation prompt. Resets sync status for all entries, preparing them for re-import. Handles potential errors and network issues. ```javascript async function unsyncAll(btn) { if (!confirm('Clear the sync status of ALL workouts?\n\nThe next sync will re-import every workout. This does not delete anything from Garmin. If a workout is still on Garmin it is skipped as a duplicate, so delete it from Garmin first if you want a fresh upload (for example after a mapping fix).')) return; const original = btn.textContent.trim(); btn.disabled = true; btn.textContent = 'Working…'; try { const form = new FormData(); form.append('confirm', 'RESET'); const resp = await fetch('/api/unsync-all', { method: 'POST', body: form }); const data = await resp.json(); if (data.ok) { window.location.reload(); } else { alert('Failed: ' + (data.error || 'Unknown error')); btn.disabled = false; btn.textContent = original; } } catch (e) { alert('Network error: ' + e.message); btn.disabled = false; btn.textContent = original; } } ``` -------------------------------- ### Load and Render Heart Rate Chart Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/workouts.html This JavaScript function fetches heart rate data for a given workout ID and renders it as a line chart. It handles cases with no HR data, displays summary statistics (average HR, max HR, calories), and includes exercise segment highlighting on the chart. The chart uses the Chart.js library. ```javascript const hrChartCache = {}; async function loadHRChart(hevyId, summaryEl) { const container = document.getElementById('hr-chart-' + hevyId); if (hrChartCache[hevyId]) return; // already loaded try { const resp = await fetch('/api/workout/' + hevyId + '/hr'); if (!resp.ok) { const err = await resp.json(); container.innerHTML = '

No HR data available' + (err.error ? ': ' + err.error : '') + '

'; return; } const data = await resp.json(); if (!data.hr_samples || data.hr_samples.length === 0) { container.innerHTML = '

No HR samples found for this workout.

'; return; } // Build summary line let summary = ''; if (data.avg_hr) summary += 'avg ' + data.avg_hr + ''; if (data.max_hr) summary += ' Β· max ' + data.max_hr + ''; if (data.calories) summary += ' Β· ' + data.calories + ' kcal'; summary += ' bpm'; // Exercise legend let legend = '
'; data.segments.forEach(seg => { legend += '' + '' + seg.name + ''; }); legend += '
'; container.innerHTML = '
' + summary + '
' + '
' + legend; // Render chart const canvas = document.getElementById('hr-canvas-' + hevyId); const ctx = canvas.getContext('2d'); // Convert HR samples to chart data const labels = data.hr_samples.map(s => { const min = Math.floor(s.time / 60); return min + 'm'; }); const hrValues = data.hr_samples.map(s => s.hr); // Build segment background colors per sample const bgColors = data.hr_samples.map(s => { for (const seg of data.segments) { if (s.time >= seg.start && s.time <= seg.end) { return seg.color + '18'; // low opacity } } return 'transparent'; }); // Segment annotations as vertical bands const segmentPlugin = { id: 'segmentBands', beforeDraw(chart) { const {ctx, chartArea: {left, right, top, bottom}, scales: {x}} = chart; const totalTime = data.hr_samples[data.hr_samples.length - 1].time; data.segments.forEach(seg => { const x1 = left + (seg.start / totalTime) * (right - left); const x2 = left + (seg.end / totalTime) * (right - left); ctx.fillStyle = seg.color + '12'; ctx.fillRect(x1, top, x2 - x1, bottom - top); // Separator line if (seg.start > 0) { ctx.strokeStyle = '#27272a'; ctx.lineWidth = 1; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(x1, top); ctx.lineTo(x1, bottom); ctx.stroke(); ctx.setLineDash([]); } }); } }; new Chart(ctx, { type: 'line', data: { labels: labels, datasets: [{ data: hrValues, borderColor: '#f43f5e', borderWidth: 1.5, backgroundColor: 'rgba(244, 63, 94, 0.08)', fill: true, pointRadius: 0, tension: 0.3, }] }, plugins: [segmentPlugin], options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { title: (items) => { const idx = items[0].dataIndex; const secs = data.hr_samples[idx].time; const min = Math.floor(secs / 60); const sec = Math.floor(secs % 60); // Find exercise let exName = ''; for (const seg of data.segments) { if (secs >= seg.start && secs <= seg.end) { exName = seg.name; break; } } return min + ':' + String(sec).padStart(2, '0') + (exName ? ' β€” ' + exName : ''); }, label: (item) => item.raw + ' bpm' } } }, scales: { x: { display: true, ticks: { color: '#64748b', font: { size: 10 }, maxTicksLimit: 8 }, grid: { display: false } }, y: { display: true, ticks: { color: '#64748b', font: { size: 10 }, callback: v => v + '' }, grid: { color: 'rgba(255,255,255,0.04)' }, min: Math.max(40, Math.min(...hrValues) - 10), } }, interaction: { mode: 'index', intersect: false, }, } }); hrChartCache[hevyId] = true; } catch (e) { container.innerHTML = '

Failed to load HR data: ' + e.message + '

'; } } ``` -------------------------------- ### Configure Additional Mergeable Activity Types in config.json Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Define an array of Garmin activity types in your `config.json` that should be eligible for merging with Hevy workouts. This includes 'strength_training' by default, plus any additional types you specify. ```json "merge_activity_types": ["strength_training", "bouldering", "indoor_climbing"] ``` -------------------------------- ### Configure Additional Mergeable Activity Types Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Specify additional Garmin activity types to be merged with Hevy workouts by listing their internal names, comma-separated. This can be done in the settings UI or directly in the `config.json` file. ```text bouldering, indoor_climbing ``` -------------------------------- ### Python API Exercise Mapper Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Utilize the exercise mapper from the Python API to translate Hevy exercise names into Garmin-compatible categories. ```python # Just the exercise mapper from hevy2garmin.mapper import lookup_exercise cat, subcat, name = lookup_exercise("Bench Press (Barbell)") # (0, 1, "Bench Press (Barbell)") ``` -------------------------------- ### Re-sync Workout with Force Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/workouts.html Re-syncs a workout with a force parameter to bypass duplicate checks. Reloads the page on success or shows an alert on failure. Handles network errors. ```javascript fetch('/api/unsync/' + hevyId, { method: 'POST', body: form }); const unsyncData = await unsyncResp.json(); if (!unsyncData.ok) { alert('Unsync failed: ' + (unsyncData.error || 'Unknown')); btn.disabled = false; btn.textContent = 'Re-sync'; return; } // Step 2: re-sync with force=1 (bypass dedup check) const syncResp = await fetch('/api/sync/' + hevyId + '?force=1', { method: 'POST' }); if (syncResp.ok) { window.location.reload(); } else { alert('Re-sync upload failed. The workout is now pending β€” try syncing from the dashboard.'); window.location.reload(); } } catch (e) { alert('Network error: ' + e.message); btn.disabled = false; btn.textContent = 'Re-sync'; } ``` -------------------------------- ### Show Unmapped Sub-IDs Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/mappings.html Displays unmapped sub-IDs for a given category selection in a specific context (identified by 'idx'). It renders chips for these sub-IDs and shows the relevant helper element. ```javascript function showUnmappedSubs(select, idx) { const catId = select.value; const helper = document.getElementById('unmapped-sub-helper-' + idx); const chips = helper.querySelector('.sub-id-chips'); const entries = subIdIndex[catId] || []; if (entries.length === 0) { helper.style.display = 'none'; return; } chips.innerHTML = _renderSubChips(entries, 'unmapped-sub-' + idx); helper.style.display = 'block'; } ``` -------------------------------- ### Build Sub-ID Index from Mapping Table Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/mappings.html This script builds an index of sub-IDs categorized by their respective categories from an HTML table. It's used to quickly look up sub-IDs associated with a category. ```javascript const subIdIndex = {}; document.querySelectorAll('#mappings-table tr.mapping-row').forEach(row => { const cells = row.querySelectorAll('td'); if (cells.length >= 4) { const name = cells[0].textContent.trim(); const cat = cells[2].textContent.trim(); const sub = cells[3].textContent.trim(); if (!subIdIndex[cat]) subIdIndex[cat] = []; subIdIndex[cat].push({ name, sub }); } }); ``` -------------------------------- ### Show Sub-IDs for a Category Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/mappings.html Displays a list of sub-ID chips for a selected category. If no sub-IDs are found for the category, the helper element is hidden. ```javascript function showSubIds(catId) { const helper = document.getElementById('sub-id-helper'); const list = document.getElementById('sub-id-list'); const entries = subIdIndex[catId] || []; if (entries.length === 0) { helper.style.display = 'none'; return; } list.innerHTML = _renderSubChips(entries, 'map-subid'); helper.style.display = 'block'; } ``` -------------------------------- ### Re-synchronize Workout with Garmin Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/workouts.html This asynchronous JavaScript function initiates a re-synchronization process for a workout. It first prompts the user for confirmation and then proceeds to unsync and delete the old Garmin activity before preparing to upload fresh data from Hevy. The button used to trigger this action is disabled and its text is updated during the process. ```javascript async function resyncWorkout(hevyId, btn) { if (!confirm('Re-sync this workout? The old Garmin activity will be deleted and a fresh one uploaded with the latest Hevy data.')) return; btn.disabled = true; btn.textContent = 'Re-syncing…'; try { // Step 1: unsync + delete old Garmin activity const form = new FormData(); form.append('delete_garmin', 'true'); const unsyncResp = await ``` -------------------------------- ### Garmin Login Response Handler Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/setup.html Processes the response from Garmin's login and MFA attempts. It handles success, MFA requests, and various error conditions, including rate limiting and CAPTCHA requirements. ```javascript async function handleGarminLoginResponse(data) { const result = document.getElementById('garmin_connect_result'); const mfaRow = document.getElementById('garmin_mfa_row'); if (data.status === 'success') { // Hide MFA row if it was shown mfaRow.style.display = 'none'; garminMfaSessionId = null; // Persist tokens on our server const storeResp = await fetch('/api/garmin-ticket', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tokens: { di_token: data.di_token, di_refresh_token: data.di_refresh_token, di_client_id: data.di_client_id, }}), }); const storeData = await storeResp.json(); if (storeData.ok) { result.innerHTML = '✓ Connected to Garmin'; } else { result.innerHTML = '✗ ' + (storeData.error || 'Failed to save tokens') + ''; } return; } if (data.status === 'needs_mfa') { garminMfaSessionId = data.session_id; mfaRow.style.display = ''; const method = data.mfa_method || 'email'; result.innerHTML = 'Garmin sent a verification code to your ' + method + '. Enter it below.'; document.getElementById('garmin_mfa_code').focus(); return; } if (data.status === 'mfa_failed') { result.innerHTML = '✗ ' + (data.message || 'Code rejected, try again') + ''; document.getElementById('garmin_mfa_code').select(); return; } if (data.status === 'invalid_credentials') { result.innerHTML = '✗ Invalid email or password'; return; } if (data.status === 'rate_limited') { result.innerHTML = '✗ Garmin rate-limited this account. Wait a few minutes and try again.'; return; } if (data.status === 'needs_captcha') { // Worker returns needs_captcha for both actual captcha challenges AND // Garmin's 427 response which appears when MFA-enabled accounts try the // direct portal API flow (Garmin forces those through the embed widget // browser flow instead). Either way, the fix is the same: show the // manual sign-in UI so the user can complete setup via their browser. const detail = data.message || 'Garmin requires the browser sign-in flow for this account.'; result.innerHTML = '⚠ ' + detail + ' Use the manual sign-in below:'; revealManualFallback(); return; } // Unknown / error status β€” reveal the manual fallback so the user has a path forward. const msg = (data && (data.message || data.error)) || 'Unexpected error'; result.innerHTML = '✗ ' + msg + '. ' } ``` -------------------------------- ### Render Sub-ID Chips Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/mappings.html Generates clickable 'chips' for sub-IDs within a given category. Each chip displays the sub-ID and name, and clicking it sets the value of a specified input field to the sub-ID. ```javascript function _renderSubChips(entries, targetInput) { const seen = new Set(); return entries.filter(e => { if (seen.has(e.sub)) return false; seen.add(e.sub); return true; }) .sort((a, b) => parseInt(a.sub) - parseInt(b.sub)) .map(e => '' + '' + e.sub + ' ' + e.name + '') .join(''); } ``` -------------------------------- ### Save Hevy Tokens Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/setup.html Saves Hevy API tokens to the server. Redirects to the homepage on success or displays an error message. Includes a timeout for redirection. ```javascript async function saveHevyTokens() { const hevyApiKey = document.getElementById('hevy_api_key').value.trim(); const garminUsername = document.getElementById('garmin_username').value.trim(); const garminPassword = document.getElementById('garmin_password').value.trim(); const result = document.getElementById('hevy-save-result'); result.innerHTML = 'Saving...'; try { const resp = await fetch('/api/hevy/tokens', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hevy_api_key: hevyApiKey, garmin_username: garminUsername, garmin_password: garminPassword }) }); const storeData = await resp.json(); if (resp.ok) { result.innerHTML = '✓ Tokens saved successfully!'; // This is a one-time requirement for some accounts.

'; setTimeout(function() { window.location.href = '/'; }, 5000); } else { result.innerHTML = '✗ ' + (storeData.error || 'Failed to save tokens') + ''; } } catch (e) { result.innerHTML = '✗ Network error β€” try again'; } } ``` -------------------------------- ### Enable Activity Merging in Hevy2Garmin Source: https://github.com/drkostas/hevy2garmin/blob/main/README.md Set `merge_mode` to `true` in your configuration to enable Hevy2Garmin to merge workout data into matching Garmin watch activities. This provides benefits like 1-second HR sampling and accurate training effect calculations. ```json { "merge_mode": true } ``` -------------------------------- ### Edit Mapping Functionality Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/mappings.html Populates the mapping form with existing data for editing. It opens the details card, sets input values, and triggers the display of relevant sub-IDs for the selected category. ```javascript function editMapping(name, cat, subcat) { const details = document.querySelector('details.card'); if (details) details.open = true; document.getElementById('map-hevy-name').value = name; document.getElementById('map-category').value = cat; document.getElementById('map-subid').value = subcat; showSubIds(String(cat)); document.getElementById('mapping-form').scrollIntoView({ behavior: 'smooth', block: 'center' }); } ``` -------------------------------- ### Unsync Individual Workout Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/workouts.html Unsyncs a specific workout after user confirmation. Disables the button during the operation and provides feedback on success or failure. Catches network errors. ```javascript async function unsyncWorkout(hevyId, btn) { if (!confirm('Remove this sync record? The workout will re-appear as "pending" and can be synced again.\n\nThe Garmin activity won\'t be deleted β€” manage that from Garmin Connect if needed.')) return; btn.disabled = true; btn.textContent = 'Removing…'; try { const form = new FormData(); const resp = await fetch('/api/unsync/' + hevyId, { method: 'POST', body: form }); const data = await resp.json(); if (data.ok) { window.location.reload(); } else { alert('Failed: ' + (data.error || 'Unknown error')); btn.disabled = false; btn.textContent = 'Unsync'; } } catch (e) { alert('Network error: ' + e.message); btn.disabled = false; btn.textContent = 'Unsync'; } } ``` -------------------------------- ### Garmin MFA Submission Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/setup.html Submits the Multi-Factor Authentication (MFA) code to Garmin. It validates the input and updates the UI to show the verification status. ```javascript async function garminSubmitMfa() { const code = document.getElementById('garmin_mfa_code').value.trim(); const result = document.getElementById('garmin_connect_result'); const btn = document.getElementById('garmin_mfa_btn'); if (!code) { result.innerHTML = 'Enter the code first'; return; } if (!garminMfaSessionId) { result.innerHTML = 'Session expired, click Connect again'; return; } btn.disabled = true; result.innerHTML = 'Verifying code...'; try { const resp = await fetch(GARMIN_WORKER_BASE + '/login-mfa', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: garminMfaSessionId, mfa_code: code }), }); const data = await resp.json(); await handleGarminLoginResponse(data); } catch (e) { result.innerHTML = '✗ Network error, try again'; } finally { btn.disabled = false; } } ``` -------------------------------- ### Toggle Detail Row Visibility Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/mappings.html Toggles the display of a detail row associated with a mapping row. It adds or removes an 'open' class and changes the display style accordingly. ```javascript function toggleDetail(row) { const detail = row.nextElementSibling; if (detail.classList.contains('open')) { detail.classList.remove('open'); detail.style.display = 'none'; } else { detail.classList.add('open'); detail.style.display = 'table-row'; } } ``` -------------------------------- ### Filter Mappings Table Source: https://github.com/drkostas/hevy2garmin/blob/main/src/hevy2garmin/templates/mappings.html Filters the rows in the mappings table based on a search query. Rows that do not contain the query (case-insensitive) are hidden. ```javascript function filterMappings(query) { const q = query.toLowerCase(); document.querySelectorAll('#mappings-table tr.mapping-row').forEach(row => { const show = row.textContent.toLowerCase().includes(q); row.style.display = show ? '' : 'none'; row.nextElementSibling.style.display = 'none'; row.nextElementSibling.classList.remove('open'); }); } ```