feat(fitness): support repeat groups in interval training templates
All checks were successful
CI / update (push) Successful in 4m8s

Allow users to nest steps inside a repeat group (e.g. "5×: 30s sprint,
60s recovery") when building GPS interval templates. Groups are tagged
{ type: 'group', repeat, steps } alongside flat { type: 'step' } entries
and capped at one nesting level. Entries are expanded into a flat list
before handing off to the native Android TTS/interval service, so the
runtime state machine is unchanged.
This commit is contained in:
2026-04-13 09:17:38 +02:00
parent 6e48cfd27c
commit 63a10df7c5
8 changed files with 398 additions and 118 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.30.0",
"version": "1.31.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -264,6 +264,10 @@ const translations: Translations = {
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' },
add_group: { en: '+ Add Repeat Group', de: '+ Wiederholungsgruppe' },
repeat_times: { en: 'times', de: 'mal' },
ungroup: { en: 'Ungroup', de: 'Auflösen' },
group_label: { en: 'Repeat', de: 'Wiederholen' },
step_label: { en: 'Label', de: 'Bezeichnung' },
meters: { en: 'meters', de: 'Meter' },
seconds: { en: 'seconds', de: 'Sekunden' },

View File

@@ -15,11 +15,39 @@ export interface GpsPoint {
}
export interface IntervalStep {
type?: 'step';
label: string;
durationType: 'distance' | 'time';
durationValue: number; // meters (distance) or seconds (time)
}
export interface IntervalGroup {
type: 'group';
repeat: number;
steps: IntervalStep[];
}
export type IntervalEntry = IntervalStep | IntervalGroup;
/** Expand groups (repeat × steps) into a flat step list for the native bridge. */
export function flattenIntervals(entries: IntervalEntry[]): IntervalStep[] {
const out: IntervalStep[] = [];
for (const e of entries) {
if ((e as IntervalGroup).type === 'group') {
const g = e as IntervalGroup;
for (let i = 0; i < g.repeat; i++) {
for (const s of g.steps) {
out.push({ label: s.label, durationType: s.durationType, durationValue: s.durationValue });
}
}
} else {
const s = e as IntervalStep;
out.push({ label: s.label, durationType: s.durationType, durationValue: s.durationValue });
}
}
return out;
}
export interface VoiceGuidanceConfig {
enabled: boolean;
triggerType: 'distance' | 'time';

View File

@@ -1,39 +1,29 @@
import mongoose from 'mongoose';
export interface IIntervalStep {
type?: 'step';
label: string;
durationType: 'distance' | 'time';
durationValue: number; // meters (distance) or seconds (time)
}
export interface IIntervalGroup {
type: 'group';
repeat: number;
steps: IIntervalStep[];
}
export type IIntervalEntry = IIntervalStep | IIntervalGroup;
export interface IIntervalTemplate {
_id?: string;
name: string;
steps: IIntervalStep[];
steps: IIntervalEntry[];
createdBy: string;
createdAt?: Date;
updatedAt?: Date;
}
const IntervalStepSchema = new mongoose.Schema({
label: {
type: String,
required: true,
trim: true,
maxlength: 50
},
durationType: {
type: String,
required: true,
enum: ['distance', 'time']
},
durationValue: {
type: Number,
required: true,
min: 1
}
});
const IntervalTemplateSchema = new mongoose.Schema(
{
name: {
@@ -43,11 +33,11 @@ const IntervalTemplateSchema = new mongoose.Schema(
maxlength: 100
},
steps: {
type: [IntervalStepSchema],
type: [mongoose.Schema.Types.Mixed],
required: true,
validate: {
validator: function(steps: IIntervalStep[]) {
return steps.length > 0;
validator: function(steps: IIntervalEntry[]) {
return Array.isArray(steps) && steps.length > 0;
},
message: 'An interval template must have at least one step'
}
@@ -67,4 +57,46 @@ const IntervalTemplateSchema = new mongoose.Schema(
IntervalTemplateSchema.index({ createdBy: 1 });
function validateStep(s: any): string | null {
if (!s || typeof s !== 'object') return 'Invalid step';
if (!s.label || typeof s.label !== 'string') return 'Each step must have a label';
if (!['distance', 'time'].includes(s.durationType)) return 'durationType must be "distance" or "time"';
if (typeof s.durationValue !== 'number' || s.durationValue < 1) return 'durationValue must be a positive number';
return null;
}
/** Validate an interval entry list. Groups allowed at top level only (depth cap 1). */
export function validateIntervalEntries(entries: any[], allowGroup = true): string | null {
if (!Array.isArray(entries) || entries.length === 0) return 'At least one step is required';
for (const e of entries) {
if (e?.type === 'group') {
if (!allowGroup) return 'Groups cannot be nested';
if (typeof e.repeat !== 'number' || e.repeat < 1 || e.repeat > 99) return 'Group repeat must be 1-99';
const inner = validateIntervalEntries(e.steps, false);
if (inner) return inner;
} else {
const err = validateStep(e);
if (err) return err;
}
}
return null;
}
/** Flatten groups by expanding repeat × steps into a flat step list. */
export function flattenIntervalEntries(entries: IIntervalEntry[]): IIntervalStep[] {
const out: IIntervalStep[] = [];
for (const e of entries) {
if ((e as IIntervalGroup).type === 'group') {
const g = e as IIntervalGroup;
for (let i = 0; i < g.repeat; i++) {
for (const s of g.steps) out.push({ ...s, type: 'step' });
}
} else {
const s = e as IIntervalStep;
out.push({ ...s, type: 'step' });
}
}
return out;
}
export const IntervalTemplate = mongoose.model<IIntervalTemplate>("IntervalTemplate", IntervalTemplateSchema);

View File

@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { IntervalTemplate } from '$models/IntervalTemplate';
import { IntervalTemplate, validateIntervalEntries } from '$models/IntervalTemplate';
export const GET: RequestHandler = async ({ locals }) => {
const session = await locals.auth();
@@ -30,18 +30,12 @@ export const POST: RequestHandler = async ({ request, locals }) => {
const data = await request.json();
const { name, steps } = data;
if (!name || !steps || !Array.isArray(steps) || steps.length === 0) {
if (!name || !steps) {
return json({ error: 'Name and at least one step are required' }, { status: 400 });
}
for (const step of steps) {
if (!step.label || !step.durationType || !step.durationValue) {
return json({ error: 'Each step must have a label, durationType, and durationValue' }, { status: 400 });
}
if (!['distance', 'time'].includes(step.durationType)) {
return json({ error: 'durationType must be "distance" or "time"' }, { status: 400 });
}
}
const validationError = validateIntervalEntries(steps);
if (validationError) return json({ error: validationError }, { status: 400 });
const template = new IntervalTemplate({
name,

View File

@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { IntervalTemplate } from '$models/IntervalTemplate';
import { IntervalTemplate, validateIntervalEntries } from '$models/IntervalTemplate';
import mongoose from 'mongoose';
export const GET: RequestHandler = async ({ params, locals }) => {
@@ -47,15 +47,12 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
const data = await request.json();
const { name, steps } = data;
if (!name || !steps || !Array.isArray(steps) || steps.length === 0) {
if (!name || !steps) {
return json({ error: 'Name and at least one step are required' }, { status: 400 });
}
for (const step of steps) {
if (!step.label || !step.durationType || !step.durationValue) {
return json({ error: 'Each step must have a label, durationType, and durationValue' }, { status: 400 });
}
}
const validationError = validateIntervalEntries(steps);
if (validationError) return json({ error: validationError }, { status: 400 });
const template = await IntervalTemplate.findOneAndUpdate(
{ _id: params.id, createdBy: session.user.nickname },

View File

@@ -5,6 +5,7 @@
import { Plus, Trash2, Play, Pencil, X, Save, CalendarClock, ChevronUp, ChevronDown, ArrowRight, MapPin, Dumbbell, Timer, BookOpen, Check } from '@lucide/svelte';
import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { flattenIntervals } from '$lib/js/gps.svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { toast } from '$lib/js/toast.svelte';
@@ -614,7 +615,7 @@
class:selected={editorIntervalId === tmpl._id}
onclick={() => editorIntervalId = editorIntervalId === tmpl._id ? null : tmpl._id}
type="button"
>{tmpl.name} ({tmpl.steps.length} steps)</button>
>{tmpl.name} ({flattenIntervals(tmpl.steps).length} steps)</button>
{/each}
</div>
</div>

View File

@@ -1,7 +1,7 @@
<script>
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2, X, Timer, Plus, GripVertical } from '@lucide/svelte';
import { Trash2, Play, Pause, Trophy, Clock, Dumbbell, Route, RefreshCw, Check, ChevronUp, ChevronDown, Flame, MapPin, Volume2, X, Timer, Plus, GripVertical, Repeat } from '@lucide/svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { confirm } from '$lib/js/confirmDialog.svelte';
@@ -9,7 +9,7 @@
const sl = $derived(fitnessSlugs(lang));
import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import { getGpsTracker, trackDistance } from '$lib/js/gps.svelte';
import { getGpsTracker, trackDistance, flattenIntervals } from '$lib/js/gps.svelte';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { formatPaceRangeLabel, formatPaceValue } from '$lib/data/cardioPrRanges';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
@@ -84,7 +84,12 @@
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}>} */
/**
* @typedef {{ type: 'step', label: string, durationType: 'distance' | 'time', durationValue: number, customLabel: boolean }} EditorLeaf
* @typedef {{ type: 'group', repeat: number, steps: EditorLeaf[] }} EditorGroup
* @typedef {EditorLeaf | EditorGroup} EditorEntry
*/
/** @type {EditorEntry[]} */
let intervalEditorSteps = $state([]);
let intervalSaving = $state(false);
@@ -92,6 +97,30 @@
const selectedInterval = $derived(intervalTemplates.find((/** @type {any} */ t) => t._id === selectedIntervalId) ?? null);
/** @returns {EditorLeaf} */
function makeLeaf(label = 'Sprint', durationType = /** @type {'distance'|'time'} */ ('distance'), durationValue = 400) {
return { type: 'step', label, durationType, durationValue, customLabel: !PRESET_LABELS.includes(label) };
}
/** @param {any} s @returns {EditorLeaf} */
function leafFromDb(s) {
return {
type: 'step',
label: s.label,
durationType: s.durationType,
durationValue: s.durationValue,
customLabel: !PRESET_LABELS.includes(s.label)
};
}
/** @param {any} e @returns {EditorEntry} */
function entryFromDb(e) {
if (e?.type === 'group') {
return { type: 'group', repeat: e.repeat ?? 2, steps: (e.steps ?? []).map(leafFromDb) };
}
return leafFromDb(e);
}
async function fetchIntervalTemplates() {
try {
const res = await fetch('/api/fitness/intervals');
@@ -105,24 +134,37 @@
function openNewInterval() {
editingIntervalId = null;
intervalEditorName = '';
intervalEditorSteps = [{ label: 'Sprint', durationType: 'distance', durationValue: 400, customLabel: false }];
intervalEditorSteps = [makeLeaf()];
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)
}));
intervalEditorSteps = (tmpl.steps ?? []).map(entryFromDb);
showIntervalEditor = true;
}
function addIntervalStep() {
intervalEditorSteps = [...intervalEditorSteps, { label: 'Recovery', durationType: 'time', durationValue: 60, customLabel: false }];
intervalEditorSteps = [...intervalEditorSteps, makeLeaf('Recovery', 'time', 60)];
}
function addIntervalGroup() {
intervalEditorSteps = [...intervalEditorSteps, {
type: 'group',
repeat: 5,
steps: [makeLeaf('Sprint', 'time', 30), makeLeaf('Recovery', 'time', 60)]
}];
}
function ungroupAt(/** @type {number} */ idx) {
const g = intervalEditorSteps[idx];
if (!g || g.type !== 'group') return;
intervalEditorSteps = [
...intervalEditorSteps.slice(0, idx),
...g.steps,
...intervalEditorSteps.slice(idx + 1)
];
}
function removeIntervalStep(/** @type {number} */ idx) {
@@ -137,17 +179,49 @@
intervalEditorSteps = arr;
}
function addStepToGroup(/** @type {number} */ groupIdx) {
const g = intervalEditorSteps[groupIdx];
if (!g || g.type !== 'group') return;
g.steps = [...g.steps, makeLeaf('Recovery', 'time', 60)];
}
function removeStepFromGroup(/** @type {number} */ groupIdx, /** @type {number} */ stepIdx) {
const g = intervalEditorSteps[groupIdx];
if (!g || g.type !== 'group') return;
if (g.steps.length <= 1) return;
g.steps = g.steps.filter((_, i) => i !== stepIdx);
}
function moveStepInGroup(/** @type {number} */ groupIdx, /** @type {number} */ stepIdx, /** @type {number} */ dir) {
const g = intervalEditorSteps[groupIdx];
if (!g || g.type !== 'group') return;
const target = stepIdx + dir;
if (target < 0 || target >= g.steps.length) return;
const arr = [...g.steps];
[arr[stepIdx], arr[target]] = [arr[target], arr[stepIdx]];
g.steps = arr;
}
/** @param {EditorLeaf} s */
function serializeLeaf(s) {
return { type: 'step', label: s.label, durationType: s.durationType, durationValue: s.durationValue };
}
/** @param {EditorEntry} e */
function serializeEntry(e) {
if (e.type === 'group') {
return { type: 'group', repeat: e.repeat, steps: e.steps.map(serializeLeaf) };
}
return serializeLeaf(e);
}
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
}))
steps: intervalEditorSteps.map(serializeEntry)
};
const url = editingIntervalId ? `/api/fitness/intervals/${editingIntervalId}` : '/api/fitness/intervals';
const method = editingIntervalId ? 'PUT' : 'POST';
@@ -201,7 +275,7 @@
language: vgLanguage,
ttsVolume: vgVolume,
audioDuck: vgAudioDuck,
...(hasIntervals ? { intervals: selectedInterval.steps } : {})
...(hasIntervals ? { intervals: flattenIntervals(selectedInterval.steps) } : {})
};
}
@@ -1105,7 +1179,7 @@
<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>
<span class="interval-card-info">{flattenIntervals(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>
@@ -1156,76 +1230,138 @@
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>
{#snippet stepCard(step, num, onMoveUp, onMoveDown, onRemove, canMoveUp, canMoveDown, canRemove)}
<div class="interval-step-card">
<div class="interval-step-header">
<span class="interval-step-num">{num}</span>
<div class="interval-step-move">
<button type="button" onclick={onMoveUp} disabled={!canMoveUp}><ChevronUp size={14} /></button>
<button type="button" onclick={onMoveDown} disabled={!canMoveDown}><ChevronDown size={14} /></button>
</div>
<button class="interval-step-remove" type="button" onclick={onRemove} disabled={!canRemove}>
<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}
<div class="interval-step-labels">
{#each PRESET_LABELS as preset}
<button
class="interval-label-chip"
class:selected={step.customLabel}
class:selected={!step.customLabel && step.label === preset}
type="button"
onclick={() => { intervalEditorSteps[idx].customLabel = true; }}
>{t('custom', lang)}</button>
</div>
onclick={() => { step.label = preset; step.customLabel = false; }}
>{preset}</button>
{/each}
<button
class="interval-label-chip"
class:selected={step.customLabel}
type="button"
onclick={() => { step.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}
{#if step.customLabel}
<input
class="interval-step-custom-input"
type="text"
placeholder={t('step_label', lang)}
bind:value={step.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 class="interval-step-duration">
<input
class="interval-step-value"
type="number"
min="1"
bind:value={step.durationValue}
/>
<div class="interval-step-type-toggle">
<button
class="interval-type-btn"
class:active={step.durationType === 'distance'}
type="button"
onclick={() => { step.durationType = 'distance'; }}
>{t('meters', lang)}</button>
<button
class="interval-type-btn"
class:active={step.durationType === 'time'}
type="button"
onclick={() => { step.durationType = 'time'; }}
>{t('seconds', lang)}</button>
</div>
</div>
</div>
{/snippet}
<div class="interval-editor-steps">
{#each intervalEditorSteps as entry, idx (idx)}
{#if entry.type === 'group'}
<div class="interval-group-card">
<div class="interval-group-header">
<Repeat size={16} />
<span class="interval-group-label">{t('group_label', lang)}</span>
<input
class="interval-group-repeat"
type="number"
min="1"
max="99"
bind:value={entry.repeat}
/>
<span class="interval-group-times">× {t('repeat_times', lang)}</span>
<div class="interval-group-actions">
<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>
<button class="interval-group-ungroup" type="button" onclick={() => ungroupAt(idx)}>{t('ungroup', lang)}</button>
<button class="interval-step-remove" type="button" onclick={() => removeIntervalStep(idx)} disabled={intervalEditorSteps.length <= 1}>
<Trash2 size={14} />
</button>
</div>
</div>
<div class="interval-group-steps">
{#each entry.steps as gStep, gIdx (gIdx)}
{@render stepCard(
gStep,
`${idx + 1}.${gIdx + 1}`,
() => moveStepInGroup(idx, gIdx, -1),
() => moveStepInGroup(idx, gIdx, 1),
() => removeStepFromGroup(idx, gIdx),
gIdx > 0,
gIdx < entry.steps.length - 1,
entry.steps.length > 1
)}
{/each}
<button class="interval-add-step-btn interval-add-step-btn--inner" type="button" onclick={() => addStepToGroup(idx)}>
<Plus size={14} />
{t('add_step', lang)}
</button>
</div>
</div>
{:else}
{@render stepCard(
entry,
`${idx + 1}`,
() => moveIntervalStep(idx, -1),
() => moveIntervalStep(idx, 1),
() => removeIntervalStep(idx),
idx > 0,
idx < intervalEditorSteps.length - 1,
intervalEditorSteps.length > 1
)}
{/if}
{/each}
</div>
<button class="interval-add-step-btn" type="button" onclick={addIntervalStep}>
<Plus size={16} />
{t('add_step', lang)}
</button>
<div class="interval-add-row">
<button class="interval-add-step-btn" type="button" onclick={addIntervalStep}>
<Plus size={16} />
{t('add_step', lang)}
</button>
<button class="interval-add-step-btn" type="button" onclick={addIntervalGroup}>
<Repeat size={16} />
{t('add_group', lang)}
</button>
</div>
<button
class="interval-save-btn"
@@ -2631,6 +2767,13 @@
color: var(--nord0);
font-weight: 600;
}
.interval-add-row {
display: flex;
gap: 0.5rem;
}
.interval-add-row .interval-add-step-btn {
flex: 1;
}
.interval-add-step-btn {
display: flex;
align-items: center;
@@ -2646,6 +2789,87 @@
font-size: 0.85rem;
cursor: pointer;
}
.interval-add-step-btn--inner {
padding: 0.4rem;
font-size: 0.78rem;
}
.interval-group-card {
background: rgba(136, 192, 208, 0.08);
border: 1px solid rgba(136, 192, 208, 0.25);
border-radius: 12px;
padding: 0.6rem;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.interval-group-header {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--nord8);
font-size: 0.8rem;
}
.interval-group-label {
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 0.7rem;
}
.interval-group-repeat {
width: 3rem;
padding: 0.25rem 0.4rem;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(136, 192, 208, 0.4);
border-radius: 6px;
color: #fff;
font: inherit;
font-size: 0.9rem;
font-weight: 700;
text-align: center;
}
.interval-group-times {
color: rgba(255,255,255,0.55);
font-size: 0.78rem;
}
.interval-group-actions {
display: flex;
align-items: center;
gap: 0.25rem;
margin-left: auto;
}
.interval-group-actions button {
background: none;
border: none;
color: rgba(255,255,255,0.4);
cursor: pointer;
padding: 0.2rem;
}
.interval-group-actions button:hover:not(:disabled) {
color: #fff;
}
.interval-group-actions button:disabled {
opacity: 0.2;
cursor: not-allowed;
}
.interval-group-ungroup {
font: inherit;
font-size: 0.7rem;
letter-spacing: 0.04em;
text-transform: uppercase;
font-weight: 600;
padding: 0.2rem 0.45rem !important;
border-radius: 4px;
}
.interval-group-ungroup:hover {
background: rgba(255,255,255,0.08);
}
.interval-group-steps {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-left: 0.6rem;
border-left: 2px solid rgba(136, 192, 208, 0.3);
}
.interval-save-btn {
width: 100%;
padding: 0.8rem;