fintess: WIP: interval setup and TTS
All checks were successful
CI / update (push) Successful in 2m17s

This commit is contained in:
2026-03-26 14:11:07 +01:00
parent 3349187ebf
commit c41a916947
5 changed files with 852 additions and 10 deletions

View File

@@ -97,6 +97,11 @@ class AndroidBridge(private val context: Context) {
LocationForegroundService.instance?.doResume() LocationForegroundService.instance?.doResume()
} }
@JavascriptInterface
fun getIntervalState(): String {
return LocationForegroundService.getIntervalState()
}
/** Returns true if at least one TTS engine is installed on the device. */ /** Returns true if at least one TTS engine is installed on the device. */
@JavascriptInterface @JavascriptInterface
fun hasTtsEngine(): Boolean { fun hasTtsEngine(): Boolean {

View File

@@ -48,13 +48,27 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
private var splitDistanceAtLastAnnouncement: Double = 0.0 private var splitDistanceAtLastAnnouncement: Double = 0.0
private var splitTimeAtLastAnnouncement: Long = 0L private var splitTimeAtLastAnnouncement: Long = 0L
// Interval tracking
private var intervalSteps: List<IntervalStep> = emptyList()
private var currentIntervalIdx: Int = 0
private var intervalAccumulatedDistanceKm: Double = 0.0
private var intervalStartTimeMs: Long = 0L
private var intervalsComplete: Boolean = false
data class IntervalStep(
val label: String,
val durationType: String, // "distance" or "time"
val durationValue: Double // meters (distance) or seconds (time)
)
data class TtsConfig( data class TtsConfig(
val enabled: Boolean = false, val enabled: Boolean = false,
val triggerType: String = "distance", // "distance" or "time" val triggerType: String = "distance", // "distance" or "time"
val triggerValue: Double = 1.0, // km or minutes val triggerValue: Double = 1.0, // km or minutes
val metrics: List<String> = listOf("totalTime", "totalDistance", "avgPace"), val metrics: List<String> = listOf("totalTime", "totalDistance", "avgPace"),
val language: String = "en", val language: String = "en",
val voiceId: String? = null val voiceId: String? = null,
val intervals: List<IntervalStep> = emptyList()
) { ) {
companion object { companion object {
fun fromJson(json: String): TtsConfig { fun fromJson(json: String): TtsConfig {
@@ -66,13 +80,27 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
} else { } else {
listOf("totalTime", "totalDistance", "avgPace") listOf("totalTime", "totalDistance", "avgPace")
} }
val intervalsArr = obj.optJSONArray("intervals")
val intervals = if (intervalsArr != null) {
(0 until intervalsArr.length()).map { i ->
val step = intervalsArr.getJSONObject(i)
IntervalStep(
label = step.optString("label", ""),
durationType = step.optString("durationType", "time"),
durationValue = step.optDouble("durationValue", 0.0)
)
}
} else {
emptyList()
}
TtsConfig( TtsConfig(
enabled = obj.optBoolean("enabled", false), enabled = obj.optBoolean("enabled", false),
triggerType = obj.optString("triggerType", "distance"), triggerType = obj.optString("triggerType", "distance"),
triggerValue = obj.optDouble("triggerValue", 1.0), triggerValue = obj.optDouble("triggerValue", 1.0),
metrics = metrics, metrics = metrics,
language = obj.optString("language", "en"), language = obj.optString("language", "en"),
voiceId = obj.optString("voiceId", null) voiceId = obj.optString("voiceId", null),
intervals = intervals
) )
} catch (_: Exception) { } catch (_: Exception) {
TtsConfig() TtsConfig()
@@ -97,6 +125,35 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
var totalDistanceKm: Double = 0.0 var totalDistanceKm: Double = 0.0
private set private set
fun getIntervalState(): String {
val svc = instance ?: return "{}"
if (svc.intervalSteps.isEmpty()) return "{}"
val obj = JSONObject()
obj.put("currentIndex", svc.currentIntervalIdx)
obj.put("totalSteps", svc.intervalSteps.size)
obj.put("complete", svc.intervalsComplete)
if (!svc.intervalsComplete && svc.currentIntervalIdx < svc.intervalSteps.size) {
val step = svc.intervalSteps[svc.currentIntervalIdx]
obj.put("currentLabel", step.label)
val progress = when (step.durationType) {
"distance" -> {
val target = step.durationValue / 1000.0
if (target > 0) (svc.intervalAccumulatedDistanceKm / target).coerceIn(0.0, 1.0) else 0.0
}
"time" -> {
val target = step.durationValue * 1000.0
if (target > 0) ((System.currentTimeMillis() - svc.intervalStartTimeMs) / target).coerceIn(0.0, 1.0) else 0.0
}
else -> 0.0
}
obj.put("progress", progress)
} else {
obj.put("currentLabel", "")
obj.put("progress", 1.0)
}
return obj.toString()
}
fun drainPoints(): String { fun drainPoints(): String {
val drained: List<JSONObject> val drained: List<JSONObject>
synchronized(pointBuffer) { synchronized(pointBuffer) {
@@ -144,6 +201,19 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
ttsConfig = TtsConfig.fromJson(configJson) ttsConfig = TtsConfig.fromJson(configJson)
Log.d(TAG, "TTS enabled=${ttsConfig?.enabled}, trigger=${ttsConfig?.triggerType}/${ttsConfig?.triggerValue}, metrics=${ttsConfig?.metrics}") Log.d(TAG, "TTS enabled=${ttsConfig?.enabled}, trigger=${ttsConfig?.triggerType}/${ttsConfig?.triggerValue}, metrics=${ttsConfig?.metrics}")
// Initialize interval tracking
intervalSteps = ttsConfig?.intervals ?: emptyList()
currentIntervalIdx = 0
intervalAccumulatedDistanceKm = 0.0
intervalStartTimeMs = startTimeMs
intervalsComplete = false
if (intervalSteps.isNotEmpty()) {
Log.d(TAG, "Intervals configured: ${intervalSteps.size} steps")
intervalSteps.forEachIndexed { i, step ->
Log.d(TAG, " Step $i: ${step.label} ${step.durationValue} ${step.durationType}")
}
}
val notifIntent = Intent(this, MainActivity::class.java).apply { val notifIntent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
} }
@@ -216,6 +286,24 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
ttsReady = true ttsReady = true
Log.d(TAG, "TTS ready! triggerType=${config.triggerType}, triggerValue=${config.triggerValue}") Log.d(TAG, "TTS ready! triggerType=${config.triggerType}, triggerValue=${config.triggerValue}")
// Announce first interval step if intervals are configured
if (intervalSteps.isNotEmpty() && !intervalsComplete) {
val first = intervalSteps[0]
val durationText = if (first.durationType == "distance") {
"${first.durationValue.toInt()} meters"
} else {
val secs = first.durationValue.toInt()
if (secs >= 60) {
val m = secs / 60
val s = secs % 60
if (s > 0) "$m minutes $s seconds" else "$m minutes"
} else {
"$secs seconds"
}
}
announceIntervalTransition("${first.label}. $durationText")
}
// Set up time-based trigger if configured // Set up time-based trigger if configured
if (config.triggerType == "time") { if (config.triggerType == "time") {
startTimeTrigger(config.triggerValue) startTimeTrigger(config.triggerValue)
@@ -289,6 +377,60 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
} }
} }
private fun checkIntervalProgress(segmentKm: Double) {
if (intervalsComplete || intervalSteps.isEmpty()) return
if (currentIntervalIdx >= intervalSteps.size) return
val step = intervalSteps[currentIntervalIdx]
val now = System.currentTimeMillis()
val complete = when (step.durationType) {
"distance" -> {
intervalAccumulatedDistanceKm += segmentKm
intervalAccumulatedDistanceKm >= step.durationValue / 1000.0
}
"time" -> {
(now - intervalStartTimeMs) >= step.durationValue * 1000
}
else -> false
}
if (complete) {
currentIntervalIdx++
intervalAccumulatedDistanceKm = 0.0
intervalStartTimeMs = now
if (currentIntervalIdx >= intervalSteps.size) {
intervalsComplete = true
Log.d(TAG, "All intervals complete!")
announceIntervalTransition("Intervals complete")
} else {
val next = intervalSteps[currentIntervalIdx]
val durationText = if (next.durationType == "distance") {
"${next.durationValue.toInt()} meters"
} else {
val secs = next.durationValue.toInt()
if (secs >= 60) {
val m = secs / 60
val s = secs % 60
if (s > 0) "$m minutes $s seconds" else "$m minutes"
} else {
"$secs seconds"
}
}
Log.d(TAG, "Interval transition: step ${currentIntervalIdx}/${intervalSteps.size}${next.label} $durationText")
announceIntervalTransition("${next.label}. $durationText")
}
updateNotification()
}
}
private fun announceIntervalTransition(text: String) {
if (!ttsReady) return
Log.d(TAG, "Interval announcement: $text")
tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, "interval_announcement")
}
private fun announceMetrics() { private fun announceMetrics() {
if (!ttsReady) return if (!ttsReady) return
val config = ttsConfig ?: return val config = ttsConfig ?: return
@@ -411,10 +553,18 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
} }
private fun updateNotification() { private fun updateNotification() {
val paceStr = if (intervalSteps.isNotEmpty() && !intervalsComplete && currentIntervalIdx < intervalSteps.size) {
val step = intervalSteps[currentIntervalIdx]
"${step.label} (${currentIntervalIdx + 1}/${intervalSteps.size})"
} else if (intervalsComplete) {
"Intervals done"
} else {
formatPace(currentPaceMinKm)
}
val notification = buildNotification( val notification = buildNotification(
formatElapsed(), formatElapsed(),
"%.2f km".format(totalDistanceKm), "%.2f km".format(totalDistanceKm),
formatPace(currentPaceMinKm) paceStr
) )
notificationManager?.notify(NOTIFICATION_ID, notification) notificationManager?.notify(NOTIFICATION_ID, notification)
} }
@@ -449,6 +599,11 @@ class LocationForegroundService : Service(), TextToSpeech.OnInitListener {
val dtMin = (now - lastTimestamp) / 60000.0 val dtMin = (now - lastTimestamp) / 60000.0
currentPaceMinKm = dtMin / segmentKm currentPaceMinKm = dtMin / segmentKm
} }
// Check interval progress with this segment's distance
checkIntervalProgress(segmentKm)
} else {
// First point — check time-based intervals even with no distance
checkIntervalProgress(0.0)
} }
lastLat = lat lastLat = lat
lastLng = lng lastLng = lng

View File

@@ -235,6 +235,34 @@ const translations: Translations = {
workouts_per_week_goal: { en: 'workouts / week', de: 'Trainings / Woche' }, workouts_per_week_goal: { en: 'workouts / week', de: 'Trainings / Woche' },
set_goal: { en: 'Set Goal', de: 'Ziel setzen' }, set_goal: { en: 'Set Goal', de: 'Ziel setzen' },
goal_set: { en: 'Goal set', de: 'Ziel gesetzt' }, goal_set: { en: 'Goal set', de: 'Ziel gesetzt' },
// Intervals
intervals: { en: 'Intervals', de: 'Intervalle' },
no_intervals: { en: 'None', de: 'Keine' },
new_interval: { en: 'New Interval', de: 'Neues Intervall' },
edit_interval: { en: 'Edit Interval', de: 'Intervall bearbeiten' },
delete_interval: { en: 'Delete', de: 'Löschen' },
delete_interval_confirm: { en: 'Delete this interval template?', de: 'Diese Intervallvorlage löschen?' },
add_step: { en: '+ Add Step', de: '+ Schritt hinzufügen' },
step_label: { en: 'Label', de: 'Bezeichnung' },
meters: { en: 'meters', de: 'Meter' },
seconds: { en: 'seconds', de: 'Sekunden' },
intervals_complete: { en: 'Intervals complete', de: 'Intervalle abgeschlossen' },
select_interval: { en: 'Select Interval', de: 'Intervall wählen' },
custom: { en: 'Custom', de: 'Eigene' },
steps_count: { en: 'steps', de: 'Schritte' },
save_interval: { en: 'Save Interval', de: 'Intervall speichern' },
interval_name_placeholder: { en: 'Interval name', de: 'Intervallname' },
// Preset labels
label_easy: { en: 'Easy', de: 'Leicht' },
label_moderate: { en: 'Moderate', de: 'Moderat' },
label_hard: { en: 'Hard', de: 'Hart' },
label_sprint: { en: 'Sprint', de: 'Sprint' },
label_recovery: { en: 'Recovery', de: 'Erholung' },
label_hill_sprints: { en: 'Hill Sprints', de: 'Bergsprints' },
label_tempo: { en: 'Tempo', de: 'Tempo' },
label_warm_up: { en: 'Warm Up', de: 'Aufwärmen' },
label_cool_down: { en: 'Cool Down', de: 'Abkühlen' },
}; };
/** Get a translated string */ /** Get a translated string */

View File

@@ -13,6 +13,12 @@ export interface GpsPoint {
timestamp: number; timestamp: number;
} }
export interface IntervalStep {
label: string;
durationType: 'distance' | 'time';
durationValue: number; // meters (distance) or seconds (time)
}
export interface VoiceGuidanceConfig { export interface VoiceGuidanceConfig {
enabled: boolean; enabled: boolean;
triggerType: 'distance' | 'time'; triggerType: 'distance' | 'time';
@@ -20,6 +26,15 @@ export interface VoiceGuidanceConfig {
metrics: string[]; metrics: string[];
language: string; language: string;
voiceId?: string; voiceId?: string;
intervals?: IntervalStep[];
}
export interface IntervalState {
currentIndex: number;
totalSteps: number;
currentLabel: string;
progress: number; // 0.01.0
complete: boolean;
} }
interface AndroidBridge { interface AndroidBridge {
@@ -32,6 +47,7 @@ interface AndroidBridge {
installTtsEngine(): void; installTtsEngine(): void;
pauseTracking(): void; pauseTracking(): void;
resumeTracking(): void; resumeTracking(): void;
getIntervalState(): string;
} }
function checkTauri(): boolean { function checkTauri(): boolean {
@@ -95,6 +111,7 @@ export function createGpsTracker() {
); );
let _debugMsg = $state(''); let _debugMsg = $state('');
let _intervalState = $state<IntervalState | null>(null);
function pollPoints() { function pollPoints() {
const bridge = getAndroidBridge(); const bridge = getAndroidBridge();
@@ -110,6 +127,13 @@ export function createGpsTracker() {
} catch (e) { } catch (e) {
_debugMsg = `poll err: ${(e as Error)?.message ?? e}`; _debugMsg = `poll err: ${(e as Error)?.message ?? e}`;
} }
// Poll interval state
try {
const stateJson = bridge.getIntervalState();
if (stateJson && stateJson !== '{}') {
_intervalState = JSON.parse(stateJson);
}
} catch { /* no interval active */ }
} }
async function start(voiceGuidance?: VoiceGuidanceConfig, startPaused = false) { async function start(voiceGuidance?: VoiceGuidanceConfig, startPaused = false) {
@@ -182,12 +206,14 @@ export function createGpsTracker() {
} }
isTracking = false; isTracking = false;
_intervalState = null;
const result = [...track]; const result = [...track];
return result; return result;
} }
function reset() { function reset() {
track = []; track = [];
_intervalState = null;
} }
function getAvailableTtsVoices(): Array<{ id: string; name: string; language: string }> { function getAvailableTtsVoices(): Array<{ id: string; name: string; language: string }> {
@@ -246,6 +272,7 @@ export function createGpsTracker() {
get latestPoint() { return latestPoint; }, get latestPoint() { return latestPoint; },
get available() { return checkTauri(); }, get available() { return checkTauri(); },
get debug() { return _debugMsg; }, get debug() { return _debugMsg; },
get intervalState() { return _intervalState; },
start, start,
stop, stop,
reset, reset,

View File

@@ -1,7 +1,7 @@
<script> <script>
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2, X } from 'lucide-svelte'; import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2, X, Timer, Plus, GripVertical } from 'lucide-svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n'; import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname)); const lang = $derived(detectFitnessLang($page.url.pathname));
@@ -72,6 +72,98 @@
let selectedActivity = $state(workout.activityType ?? 'running'); let selectedActivity = $state(workout.activityType ?? 'running');
let showActivityPicker = $state(false); let showActivityPicker = $state(false);
let showAudioPanel = $state(false); let showAudioPanel = $state(false);
let showIntervalPanel = $state(false);
// Interval templates
/** @type {any[]} */
let intervalTemplates = $state([]);
let selectedIntervalId = $state(/** @type {string | null} */ (null));
let showIntervalEditor = $state(false);
let editingIntervalId = $state(/** @type {string | null} */ (null));
let intervalEditorName = $state('');
/** @type {Array<{label: string, durationType: 'distance' | 'time', durationValue: number, customLabel: boolean}>} */
let intervalEditorSteps = $state([]);
let intervalSaving = $state(false);
const PRESET_LABELS = ['Easy', 'Moderate', 'Hard', 'Sprint', 'Recovery', 'Hill Sprints', 'Tempo', 'Warm Up', 'Cool Down'];
const selectedInterval = $derived(intervalTemplates.find((/** @type {any} */ t) => t._id === selectedIntervalId) ?? null);
async function fetchIntervalTemplates() {
try {
const res = await fetch('/api/fitness/intervals');
if (res.ok) {
const d = await res.json();
intervalTemplates = d.templates ?? [];
}
} catch {}
}
function openNewInterval() {
editingIntervalId = null;
intervalEditorName = '';
intervalEditorSteps = [{ label: 'Sprint', durationType: 'distance', durationValue: 400, customLabel: false }];
showIntervalEditor = true;
}
function openEditInterval(/** @type {any} */ tmpl) {
editingIntervalId = tmpl._id;
intervalEditorName = tmpl.name;
intervalEditorSteps = tmpl.steps.map((/** @type {any} */ s) => ({
label: s.label,
durationType: s.durationType,
durationValue: s.durationValue,
customLabel: !PRESET_LABELS.includes(s.label)
}));
showIntervalEditor = true;
}
function addIntervalStep() {
intervalEditorSteps = [...intervalEditorSteps, { label: 'Recovery', durationType: 'time', durationValue: 60, customLabel: false }];
}
function removeIntervalStep(/** @type {number} */ idx) {
intervalEditorSteps = intervalEditorSteps.filter((_, i) => i !== idx);
}
function moveIntervalStep(/** @type {number} */ idx, /** @type {number} */ dir) {
const target = idx + dir;
if (target < 0 || target >= intervalEditorSteps.length) return;
const arr = [...intervalEditorSteps];
[arr[idx], arr[target]] = [arr[target], arr[idx]];
intervalEditorSteps = arr;
}
async function saveInterval() {
if (intervalSaving || !intervalEditorName.trim() || intervalEditorSteps.length === 0) return;
intervalSaving = true;
try {
const body = {
name: intervalEditorName.trim(),
steps: intervalEditorSteps.map(s => ({
label: s.label,
durationType: s.durationType,
durationValue: s.durationValue
}))
};
const url = editingIntervalId ? `/api/fitness/intervals/${editingIntervalId}` : '/api/fitness/intervals';
const method = editingIntervalId ? 'PUT' : 'POST';
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (res.ok) {
showIntervalEditor = false;
await fetchIntervalTemplates();
}
} finally {
intervalSaving = false;
}
}
async function deleteInterval(/** @type {string} */ id) {
if (!confirm(t('delete_interval_confirm', lang))) return;
await fetch(`/api/fitness/intervals/${id}`, { method: 'DELETE' });
if (selectedIntervalId === id) selectedIntervalId = null;
await fetchIntervalTemplates();
}
const GPS_ACTIVITIES = [ const GPS_ACTIVITIES = [
{ id: 'running', label: 'Running', icon: '🏃' }, { id: 'running', label: 'Running', icon: '🏃' },
@@ -96,13 +188,15 @@
]; ];
function getVoiceGuidanceConfig() { function getVoiceGuidanceConfig() {
if (!vgEnabled) return undefined; const hasIntervals = selectedInterval?.steps?.length > 0;
if (!vgEnabled && !hasIntervals) return undefined;
return { return {
enabled: true, enabled: true,
triggerType: vgTriggerType, triggerType: vgTriggerType,
triggerValue: vgTriggerValue, triggerValue: vgTriggerValue,
metrics: vgMetrics, metrics: vgEnabled ? vgMetrics : [],
language: vgLanguage language: vgLanguage,
...(hasIntervals ? { intervals: selectedInterval.steps } : {})
}; };
} }
@@ -277,6 +371,15 @@
} catch {} } catch {}
vgLoaded = true; vgLoaded = true;
// Restore selected interval from localStorage
try {
const savedId = localStorage.getItem('selected_interval_id');
if (savedId) selectedIntervalId = savedId;
} catch {}
// Fetch interval templates
fetchIntervalTemplates();
// For GPS workouts in pre-start: start GPS immediately so the map // For GPS workouts in pre-start: start GPS immediately so the map
// shows the user's position while they configure activity/audio. // shows the user's position while they configure activity/audio.
if (workout.mode === 'gps' && !gpsStarted && !gps.isTracking) { if (workout.mode === 'gps' && !gpsStarted && !gps.isTracking) {
@@ -285,6 +388,16 @@
} }
}); });
// Persist selected interval ID
$effect(() => {
if (!vgLoaded) return;
if (selectedIntervalId) {
localStorage.setItem('selected_interval_id', selectedIntervalId);
} else {
localStorage.removeItem('selected_interval_id');
}
});
/** @param {string[]} exerciseIds */ /** @param {string[]} exerciseIds */
async function fetchPreviousData(exerciseIds) { async function fetchPreviousData(exerciseIds) {
const promises = exerciseIds.map(async (id) => { const promises = exerciseIds.map(async (id) => {
@@ -926,6 +1039,25 @@
</div> </div>
{/if} {/if}
{#if gps.intervalState}
<div class="interval-active-overlay">
{#if gps.intervalState.complete}
<div class="interval-complete-badge">
<Check size={14} />
<span>{t('intervals_complete', lang)}</span>
</div>
{:else}
<div class="interval-current-label">{gps.intervalState.currentLabel}</div>
<div class="interval-progress-row">
<span class="interval-step-count">{gps.intervalState.currentIndex + 1} / {gps.intervalState.totalSteps}</span>
<div class="interval-progress-bar">
<div class="interval-progress-fill" style="width: {Math.round(gps.intervalState.progress * 100)}%"></div>
</div>
</div>
{/if}
</div>
{/if}
<div class="gps-overlay-actions"> <div class="gps-overlay-actions">
<button class="gps-overlay-pause" onclick={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()} aria-label={workout.paused ? 'Resume' : 'Pause'}> <button class="gps-overlay-pause" onclick={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()} aria-label={workout.paused ? 'Resume' : 'Pause'}>
{#if workout.paused}<Play size={22} />{:else}<Pause size={22} />{/if} {#if workout.paused}<Play size={22} />{:else}<Pause size={22} />{/if}
@@ -939,16 +1071,21 @@
</div> </div>
{:else} {:else}
<div class="gps-options-grid"> <div class="gps-options-grid">
<button class="gps-option-tile" onclick={() => { showActivityPicker = !showActivityPicker; showAudioPanel = false; }} type="button"> <button class="gps-option-tile" onclick={() => { showActivityPicker = !showActivityPicker; showAudioPanel = false; showIntervalPanel = false; }} type="button">
<span class="gps-option-icon">{GPS_ACTIVITIES.find(a => a.id === selectedActivity)?.icon ?? '🏃'}</span> <span class="gps-option-icon">{GPS_ACTIVITIES.find(a => a.id === selectedActivity)?.icon ?? '🏃'}</span>
<span class="gps-option-label">Activity</span> <span class="gps-option-label">Activity</span>
<span class="gps-option-value">{GPS_ACTIVITIES.find(a => a.id === selectedActivity)?.label ?? 'Running'}</span> <span class="gps-option-value">{GPS_ACTIVITIES.find(a => a.id === selectedActivity)?.label ?? 'Running'}</span>
</button> </button>
<button class="gps-option-tile" onclick={() => { showAudioPanel = !showAudioPanel; showActivityPicker = false; }} type="button"> <button class="gps-option-tile" onclick={() => { showAudioPanel = !showAudioPanel; showActivityPicker = false; showIntervalPanel = false; }} type="button">
<Volume2 size={20} /> <Volume2 size={20} />
<span class="gps-option-label">Audio Stats</span> <span class="gps-option-label">Audio Stats</span>
<span class="gps-option-value">{vgEnabled ? `Every ${vgTriggerValue} ${vgTriggerType === 'distance' ? 'km' : 'min'}` : 'Off'}</span> <span class="gps-option-value">{vgEnabled ? `Every ${vgTriggerValue} ${vgTriggerType === 'distance' ? 'km' : 'min'}` : 'Off'}</span>
</button> </button>
<button class="gps-option-tile" onclick={() => { showIntervalPanel = !showIntervalPanel; showActivityPicker = false; showAudioPanel = false; }} type="button">
<Timer size={20} />
<span class="gps-option-label">{t('intervals', lang)}</span>
<span class="gps-option-value">{selectedInterval?.name ?? t('no_intervals', lang)}</span>
</button>
</div> </div>
{#if showActivityPicker} {#if showActivityPicker}
@@ -1018,6 +1155,33 @@
</div> </div>
{/if} {/if}
{#if showIntervalPanel}
<div class="interval-panel">
{#if intervalTemplates.length > 0}
<div class="interval-list">
{#each intervalTemplates as tmpl (tmpl._id)}
<div class="interval-card" class:selected={selectedIntervalId === tmpl._id}>
<button class="interval-card-main" type="button" onclick={() => { selectedIntervalId = selectedIntervalId === tmpl._id ? null : tmpl._id; }}>
<span class="interval-card-name">{tmpl.name}</span>
<span class="interval-card-info">{tmpl.steps.length} {t('steps_count', lang)}</span>
</button>
<div class="interval-card-actions">
<button class="interval-card-edit" type="button" onclick={() => openEditInterval(tmpl)}>{t('edit', lang)}</button>
<button class="interval-card-delete" type="button" onclick={() => deleteInterval(tmpl._id)}><Trash2 size={14} /></button>
</div>
</div>
{/each}
</div>
{:else}
<p class="interval-empty">{t('no_intervals', lang)}</p>
{/if}
<button class="interval-new-btn" type="button" onclick={openNewInterval}>
<Plus size={16} />
{t('new_interval', lang)}
</button>
</div>
{/if}
<button class="gps-start-btn" onclick={startGpsWorkout} disabled={gpsStarting}> <button class="gps-start-btn" onclick={startGpsWorkout} disabled={gpsStarting}>
{#if gpsStarting} {#if gpsStarting}
<span class="gps-spinner"></span> Initializing GPS… <span class="gps-spinner"></span> Initializing GPS…
@@ -1032,6 +1196,106 @@
</button> </button>
{/if} {/if}
</div> </div>
{#if showIntervalEditor}
<div class="interval-editor-overlay">
<div class="interval-editor">
<div class="interval-editor-header">
<h2>{editingIntervalId ? t('edit_interval', lang) : t('new_interval', lang)}</h2>
<button class="interval-editor-close" type="button" onclick={() => showIntervalEditor = false}>
<X size={20} />
</button>
</div>
<input
class="interval-editor-name"
type="text"
placeholder={t('interval_name_placeholder', lang)}
bind:value={intervalEditorName}
/>
<div class="interval-editor-steps">
{#each intervalEditorSteps as step, idx (idx)}
<div class="interval-step-card">
<div class="interval-step-header">
<span class="interval-step-num">{idx + 1}</span>
<div class="interval-step-move">
<button type="button" onclick={() => moveIntervalStep(idx, -1)} disabled={idx === 0}><ChevronUp size={14} /></button>
<button type="button" onclick={() => moveIntervalStep(idx, 1)} disabled={idx === intervalEditorSteps.length - 1}><ChevronDown size={14} /></button>
</div>
<button class="interval-step-remove" type="button" onclick={() => removeIntervalStep(idx)} disabled={intervalEditorSteps.length <= 1}>
<Trash2 size={14} />
</button>
</div>
<div class="interval-step-labels">
{#each PRESET_LABELS as preset}
<button
class="interval-label-chip"
class:selected={!step.customLabel && step.label === preset}
type="button"
onclick={() => { intervalEditorSteps[idx].label = preset; intervalEditorSteps[idx].customLabel = false; }}
>{preset}</button>
{/each}
<button
class="interval-label-chip"
class:selected={step.customLabel}
type="button"
onclick={() => { intervalEditorSteps[idx].customLabel = true; }}
>{t('custom', lang)}</button>
</div>
{#if step.customLabel}
<input
class="interval-step-custom-input"
type="text"
placeholder={t('step_label', lang)}
bind:value={intervalEditorSteps[idx].label}
/>
{/if}
<div class="interval-step-duration">
<input
class="interval-step-value"
type="number"
min="1"
bind:value={intervalEditorSteps[idx].durationValue}
/>
<div class="interval-step-type-toggle">
<button
class="interval-type-btn"
class:active={step.durationType === 'distance'}
type="button"
onclick={() => { intervalEditorSteps[idx].durationType = 'distance'; }}
>{t('meters', lang)}</button>
<button
class="interval-type-btn"
class:active={step.durationType === 'time'}
type="button"
onclick={() => { intervalEditorSteps[idx].durationType = 'time'; }}
>{t('seconds', lang)}</button>
</div>
</div>
</div>
{/each}
</div>
<button class="interval-add-step-btn" type="button" onclick={addIntervalStep}>
<Plus size={16} />
{t('add_step', lang)}
</button>
<button
class="interval-save-btn"
type="button"
onclick={saveInterval}
disabled={intervalSaving || !intervalEditorName.trim() || intervalEditorSteps.length === 0}
>
{intervalSaving ? t('saving', lang) : t('save_interval', lang)}
</button>
</div>
</div>
{/if}
</div> </div>
{:else if workout.active} {:else if workout.active}
@@ -1957,7 +2221,7 @@
} }
.gps-options-grid { .gps-options-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr 1fr;
gap: 0.5rem; gap: 0.5rem;
} }
.gps-option-tile { .gps-option-tile {
@@ -2112,4 +2376,367 @@
font-size: 1rem; font-size: 1rem;
cursor: pointer; cursor: pointer;
} }
/* Interval Panel (pre-start selection) */
.interval-panel {
background: rgba(46, 52, 64, 0.82);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
padding: 0.6rem;
color: #fff;
}
.interval-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 0.5rem;
}
.interval-card {
display: flex;
align-items: center;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
overflow: hidden;
transition: border-color 0.15s;
}
.interval-card.selected {
border-color: var(--nord8);
background: rgba(136,192,208,0.12);
}
.interval-card-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.1rem;
padding: 0.5rem 0.6rem;
background: none;
border: none;
color: inherit;
font: inherit;
cursor: pointer;
text-align: left;
}
.interval-card-name {
font-weight: 600;
font-size: 0.85rem;
}
.interval-card-info {
font-size: 0.7rem;
opacity: 0.6;
}
.interval-card-actions {
display: flex;
gap: 0.2rem;
padding-right: 0.4rem;
}
.interval-card-edit, .interval-card-delete {
background: none;
border: none;
color: rgba(255,255,255,0.5);
font: inherit;
font-size: 0.7rem;
cursor: pointer;
padding: 0.3rem;
}
.interval-card-edit:hover, .interval-card-delete:hover {
color: #fff;
}
.interval-empty {
text-align: center;
font-size: 0.8rem;
opacity: 0.5;
margin: 0.5rem 0;
}
.interval-new-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
width: 100%;
padding: 0.5rem;
background: rgba(255,255,255,0.08);
border: 1px dashed rgba(255,255,255,0.2);
border-radius: 8px;
color: var(--nord8);
font: inherit;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.interval-new-btn:hover {
background: rgba(255,255,255,0.12);
}
/* Interval Editor (full-screen overlay on map) */
.interval-editor-overlay {
position: absolute;
inset: 0;
z-index: 100;
background: rgba(46, 52, 64, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
display: flex;
flex-direction: column;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.interval-editor {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
padding-top: calc(1rem + env(safe-area-inset-top, 0px));
padding-bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
color: #fff;
max-width: 500px;
width: 100%;
margin: 0 auto;
}
.interval-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.interval-editor-header h2 {
margin: 0;
font-size: 1.2rem;
}
.interval-editor-close {
background: none;
border: none;
color: rgba(255,255,255,0.6);
cursor: pointer;
padding: 0.3rem;
}
.interval-editor-name {
width: 100%;
padding: 0.6rem 0.75rem;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 8px;
color: #fff;
font: inherit;
font-size: 0.95rem;
}
.interval-editor-name::placeholder {
color: rgba(255,255,255,0.35);
}
.interval-editor-steps {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.interval-step-card {
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px;
padding: 0.6rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.interval-step-header {
display: flex;
align-items: center;
gap: 0.4rem;
}
.interval-step-num {
font-weight: 700;
font-size: 0.8rem;
background: rgba(255,255,255,0.12);
border-radius: 50%;
width: 1.4rem;
height: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.interval-step-move {
display: flex;
gap: 0.1rem;
margin-left: auto;
}
.interval-step-move button {
background: none;
border: none;
color: rgba(255,255,255,0.4);
cursor: pointer;
padding: 0.15rem;
}
.interval-step-move button:hover:not(:disabled) {
color: #fff;
}
.interval-step-move button:disabled {
opacity: 0.2;
cursor: not-allowed;
}
.interval-step-remove {
background: none;
border: none;
color: rgba(255,255,255,0.3);
cursor: pointer;
padding: 0.15rem;
}
.interval-step-remove:hover:not(:disabled) {
color: var(--nord11);
}
.interval-step-remove:disabled {
opacity: 0.2;
cursor: not-allowed;
}
.interval-step-labels {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.interval-label-chip {
padding: 0.25rem 0.55rem;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 20px;
color: rgba(255,255,255,0.7);
font: inherit;
font-size: 0.72rem;
cursor: pointer;
transition: all 0.12s;
}
.interval-label-chip.selected {
background: var(--nord8);
border-color: var(--nord8);
color: var(--nord0);
font-weight: 600;
}
.interval-step-custom-input {
width: 100%;
padding: 0.4rem 0.6rem;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
color: #fff;
font: inherit;
font-size: 0.85rem;
}
.interval-step-duration {
display: flex;
align-items: center;
gap: 0.5rem;
}
.interval-step-value {
width: 5rem;
padding: 0.4rem 0.5rem;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
color: #fff;
font: inherit;
font-size: 0.9rem;
text-align: center;
}
.interval-step-type-toggle {
display: flex;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
overflow: hidden;
}
.interval-type-btn {
padding: 0.4rem 0.65rem;
background: rgba(255,255,255,0.06);
border: none;
color: rgba(255,255,255,0.5);
font: inherit;
font-size: 0.78rem;
cursor: pointer;
transition: all 0.12s;
}
.interval-type-btn.active {
background: var(--nord8);
color: var(--nord0);
font-weight: 600;
}
.interval-add-step-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
width: 100%;
padding: 0.55rem;
background: rgba(255,255,255,0.06);
border: 1px dashed rgba(255,255,255,0.2);
border-radius: 8px;
color: var(--nord8);
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.interval-save-btn {
width: 100%;
padding: 0.8rem;
background: var(--nord14);
border: none;
border-radius: 50px;
color: var(--nord0);
font: inherit;
font-weight: 700;
font-size: 1rem;
cursor: pointer;
}
.interval-save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Active Interval Overlay */
.interval-active-overlay {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 0.4rem 0.5rem;
background: rgba(136,192,208,0.12);
border: 1px solid rgba(136,192,208,0.25);
border-radius: 8px;
}
.interval-current-label {
font-size: 1.1rem;
font-weight: 800;
color: var(--nord8);
text-align: center;
}
.interval-progress-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.interval-step-count {
font-size: 0.72rem;
font-weight: 600;
opacity: 0.7;
white-space: nowrap;
}
.interval-progress-bar {
flex: 1;
height: 4px;
background: rgba(255,255,255,0.12);
border-radius: 2px;
overflow: hidden;
}
.interval-progress-fill {
height: 100%;
background: var(--nord8);
border-radius: 2px;
transition: width 0.5s ease;
}
.interval-complete-badge {
display: flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
padding: 0.3rem;
color: var(--nord14);
font-size: 0.85rem;
font-weight: 600;
}
</style> </style>