fitness: add streak aura with fire and lightning effects on stats page

Separate streak counter from stat tiles into its own component with
animated aura effects: glow (1w), particles (2w), fire (3w), and
fire + lightning bolts (6/12/24w). Fire animations tuned for energetic
workout feel with faster durations and upward-anchored scaling.
On desktop, streak sits beside the workouts chart; on mobile, above it.
This commit is contained in:
2026-03-23 10:46:23 +01:00
parent 9f45a1525b
commit 0ba22b103b
2 changed files with 463 additions and 46 deletions

View File

@@ -0,0 +1,368 @@
<script>
/**
* @type {{ value?: number, burst?: boolean }}
*/
let { value = 0, burst = false } = $props();
let showBurst = $state(false);
/** @type {ReturnType<typeof setTimeout> | undefined} */
let burstTimer;
$effect(() => {
if (burst) {
clearTimeout(burstTimer);
showBurst = true;
burstTimer = setTimeout(() => showBurst = false, 2000);
}
});
// Phase 0: nothing (< 1 week)
// Phase 1: glow (1+ weeks)
// Phase 2: particles (2+ weeks)
// Phase 3: fire (3+ weeks)
// Phase 4: fire + lightning (6+ weeks), extra bolts at 12w and 24w
const phase = $derived(
value >= 6 ? 4 :
value >= 3 ? 3 :
value >= 2 ? 2 :
value >= 1 ? 1 : 0
);
// Extra lightning bolts at higher streaks
const bolts = $derived(
value >= 24 ? 3 :
value >= 12 ? 2 : 1
);
const burstParticles = [
{ x: 10, y: 0, size: 8, delay: 0, dur: 1.6 },
{ x: 25, y: 5, size: 10, delay: 0.05, dur: 1.8 },
{ x: 40, y: 10, size: 12, delay: 0.02, dur: 2.0 },
{ x: 55, y: 3, size: 7, delay: 0.1, dur: 1.7 },
{ x: 70, y: 8, size: 9, delay: 0.08, dur: 1.9 },
{ x: 85, y: 2, size: 11, delay: 0.12, dur: 1.6 },
{ x: 15, y: 15, size: 6, delay: 0.15, dur: 1.5 },
{ x: 35, y: 20, size: 10, delay: 0.18, dur: 1.8 },
{ x: 50, y: 12, size: 8, delay: 0.07, dur: 2.0 },
{ x: 65, y: 18, size: 7, delay: 0.22, dur: 1.7 },
{ x: 80, y: 25, size: 9, delay: 0.1, dur: 1.9 },
{ x: 20, y: 30, size: 11, delay: 0.25, dur: 1.6 },
{ x: 45, y: 22, size: 6, delay: 0.03, dur: 1.8 },
{ x: 60, y: 28, size: 10, delay: 0.2, dur: 2.0 },
{ x: 75, y: 15, size: 8, delay: 0.14, dur: 1.5 },
{ x: 30, y: 35, size: 12, delay: 0.28, dur: 1.7 },
{ x: 5, y: 10, size: 7, delay: 0.06, dur: 1.9 },
{ x: 90, y: 20, size: 9, delay: 0.16, dur: 1.6 },
{ x: 48, y: 32, size: 8, delay: 0.3, dur: 2.0 },
{ x: 22, y: 8, size: 10, delay: 0.11, dur: 1.8 },
{ x: 68, y: 35, size: 6, delay: 0.23, dur: 1.5 },
{ x: 38, y: 5, size: 11, delay: 0.04, dur: 1.7 },
{ x: 82, y: 30, size: 7, delay: 0.26, dur: 1.9 },
{ x: 52, y: 18, size: 9, delay: 0.09, dur: 1.6 },
];
</script>
<div class="aura phase-{phase}">
{#if phase >= 2}
<div class="fire">
<div class="fire-left">
{#if phase >= 3}<div class="main-fire"></div>{/if}
<div class="particle-fire"></div>
</div>
<div class="fire-center">
{#if phase >= 3}<div class="main-fire"></div>{/if}
<div class="particle-fire"></div>
</div>
<div class="fire-right">
{#if phase >= 3}<div class="main-fire"></div>{/if}
<div class="particle-fire"></div>
</div>
<div class="fire-bottom">
{#if phase >= 3}<div class="main-fire"></div>{/if}
</div>
</div>
{/if}
{#if phase >= 4}
<div class="lightning">
<svg class="bolt bolt-1" viewBox="0 0 30 80" xmlns="http://www.w3.org/2000/svg">
<polyline points="15,0 8,28 18,32 6,58 16,62 4,80" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linejoin="bevel"/>
</svg>
{#if bolts >= 2}
<svg class="bolt bolt-2" viewBox="0 0 30 80" xmlns="http://www.w3.org/2000/svg">
<polyline points="12,0 20,24 10,30 22,55 12,60 20,80" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linejoin="bevel"/>
</svg>
{/if}
{#if bolts >= 3}
<svg class="bolt bolt-3" viewBox="0 0 30 80" xmlns="http://www.w3.org/2000/svg">
<polyline points="18,0 10,20 20,26 8,50 18,55 10,80" fill="none" stroke="currentColor" stroke-width="3.5" stroke-linejoin="bevel"/>
</svg>
{/if}
</div>
{/if}
{#if showBurst}
<div class="burst-particles">
{#each burstParticles as p, i (i)}
<div
class="bp"
style:left="{p.x}%"
style:bottom="{p.y}%"
style:width="{p.size}px"
style:height="{p.size}px"
style:animation-delay="{p.delay}s"
style:animation-duration="{p.dur}s"
></div>
{/each}
</div>
{/if}
<span class="number">{value}</span>
</div>
<style>
/* Base layout */
.aura {
position: relative;
width: 88px;
display: grid;
place-items: center;
}
.aura.phase-3,
.aura.phase-4 {
height: 88px;
}
.number {
position: relative;
z-index: 5;
font-size: 2.5rem;
font-weight: 700;
color: var(--nord13);
--shadow-outline: 0 0 1px rgba(255,255,255,0.9), 0 0 3px rgba(0,0,0,0.4);
}
/* Phase 1 - Glow (1 week) */
.phase-1 .number {
text-shadow:
0 0 8px rgba(255,215,100,.5),
0 0 16px rgba(255,215,100,.35);
animation: glow-pulse 2.5s ease-in-out infinite;
}
@keyframes glow-pulse {
0%,100% { text-shadow: 0 0 8px rgba(255,215,100,.4); }
50% { text-shadow: 0 0 16px rgba(255,215,100,.8); }
}
/* Phase 2/3 - Ember text shadow (2-3 weeks) */
.phase-2 .number,
.phase-3 .number,
.phase-4 .number {
animation: ember-pulse 1.4s infinite alternate;
}
@keyframes ember-pulse {
0% {
text-shadow:
var(--shadow-outline),
0 0 6px rgba(255,140,0,.6),
0 0 12px rgba(255,90,0,.4),
0 0 20px rgba(255,50,0,.2);
}
100% {
text-shadow:
var(--shadow-outline),
0 0 10px rgba(255,180,0,.9),
0 0 18px rgba(255,120,0,.6),
0 0 28px rgba(255,70,0,.35);
}
}
/* Fire effect */
.fire {
position: absolute;
bottom: -10px;
left: 50%;
transform: translateX(-50%) scale(0.55);
width: 100px;
height: 100px;
pointer-events: none;
z-index: 4;
}
@keyframes scaleUpDown {
0% { transform: scaleY(1) scaleX(1); }
25% { transform: scaleY(1.1) scaleX(0.92); }
50% { transform: scaleY(0.92) scaleX(1.08); }
75% { transform: scaleY(1.08) scaleX(0.9); }
100% { transform: scaleY(1) scaleX(1); }
}
@keyframes shake {
0%,100% { transform: skewX(0) scale(1); }
50% { transform: skewX(5deg) scale(0.9); }
}
@keyframes particleUp {
0% { opacity: 0; }
20% { opacity: 1; }
80% { opacity: 1; }
100% { opacity: 0; top: -100%; transform: scale(0.5); }
}
@keyframes glow {
0%,100% { background-color: #ef5a00; }
50% { background-color: #ff7800; }
}
.fire-center,
.fire-left,
.fire-right {
position: absolute;
width: 100%;
height: 100%;
transform-origin: bottom center;
}
.fire-center { animation: scaleUpDown 1.5s ease-out infinite; }
.fire-left { animation: shake 1.6s ease-out infinite; }
.fire-right { animation: shake 1.1s ease-out infinite; }
.main-fire {
position: absolute;
width: 100%;
height: 100%;
background-image: radial-gradient(
farthest-corner at 10px 0,
color-mix(in srgb, var(--nord11) 70%, transparent),
color-mix(in srgb, var(--nord12) 70%, transparent) 60%,
color-mix(in srgb, var(--nord13) 85%, transparent) 95%
);
filter: drop-shadow(0 0 6px var(--nord12));
transform: scaleX(0.8) rotate(45deg);
border-radius: 0 40% 60% 40%;
}
.fire-left .main-fire { top: 15%; left: -20%; width: 80%; height: 80%; }
.fire-right .main-fire { top: 15%; right: -25%; width: 80%; height: 80%; }
.particle-fire {
position: absolute;
width: 10px;
height: 10px;
background-color: var(--nord13);
filter: drop-shadow(0 0 4px var(--nord12));
border-radius: 50%;
animation: particleUp 1.4s ease-out infinite;
}
.fire-center .particle-fire { top: 60%; left: 45%; }
.fire-left .particle-fire { top: 20%; left: 20%; animation-duration: 1.8s; }
.fire-right .particle-fire { top: 45%; left: 50%; width: 15px; height: 15px; }
.fire-bottom .main-fire {
position: absolute;
top: 30%;
left: 20%;
width: 75%;
height: 75%;
background-color: #ff7800;
transform: scaleX(0.8) rotate(45deg);
border-radius: 0 40% 100% 40%;
filter: blur(10px);
animation: glow 1s ease-out infinite;
}
/* Burst particles */
.burst-particles {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60px;
height: 80px;
pointer-events: none;
z-index: 6;
}
.burst-particles .bp {
position: absolute;
background-color: var(--nord13);
filter: drop-shadow(0 0 4px var(--nord12));
border-radius: 50%;
opacity: 0;
animation: burstParticleUp 2s ease-out forwards;
}
@keyframes burstParticleUp {
0% { transform: translateY(0) scale(1); opacity: 0; }
10% { opacity: 1; }
60% { opacity: 0.8; }
100% { transform: translateY(-80px) scale(0.3); opacity: 0; }
}
/* Lightning */
.lightning {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 7;
}
.bolt {
position: absolute;
height: 70px;
width: 24px;
color: #eaf6ff;
filter: drop-shadow(0 0 4px rgba(180,220,255,.9)) drop-shadow(0 0 8px rgba(120,180,255,.6));
opacity: 0;
}
/* Bolt 1: left side */
.bolt-1 {
top: -20px;
left: -8px;
transform: rotate(-15deg);
animation: lightning-1 3.2s step-end infinite;
}
/* Bolt 2: right side */
.bolt-2 {
top: -18px;
right: -6px;
transform: rotate(12deg);
animation: lightning-2 4.1s step-end infinite;
}
/* Bolt 3: center-left */
.bolt-3 {
top: -22px;
left: 12px;
transform: rotate(-5deg);
animation: lightning-3 2.8s step-end infinite;
}
/* Each bolt: mostly invisible, brief double-flash spikes */
@keyframes lightning-1 {
0% { opacity: 0; }
12% { opacity: 0.9; }
14% { opacity: 0; }
16% { opacity: 1; }
18% { opacity: 0; }
65% { opacity: 0; }
67% { opacity: 0.8; }
69% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes lightning-2 {
0% { opacity: 0; }
35% { opacity: 0; }
37% { opacity: 1; }
39% { opacity: 0; }
41% { opacity: 0.7; }
43% { opacity: 0; }
82% { opacity: 0.9; }
84% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes lightning-3 {
0% { opacity: 0; }
22% { opacity: 0; }
24% { opacity: 1; }
25% { opacity: 0; }
27% { opacity: 0.8; }
28% { opacity: 0; }
55% { opacity: 0.9; }
57% { opacity: 0; }
58% { opacity: 0.7; }
59% { opacity: 0; }
100% { opacity: 0; }
}
</style>

View File

@@ -1,7 +1,8 @@
<script>
import { page } from '$app/stores';
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
import { Dumbbell, Route, Flame, Zap, Weight, Info } from 'lucide-svelte';
import { Dumbbell, Route, Flame, Weight, Info } from 'lucide-svelte';
import FitnessStreakAura from '$lib/components/fitness/FitnessStreakAura.svelte';
import { onMount } from 'svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
@@ -159,16 +160,6 @@
<div class="card-value">{stats.totalCardioKm ?? 0}<span class="card-unit">km</span></div>
<div class="card-label">{t('distance_covered', lang)}</div>
</div>
<button class="lifetime-card streak" onclick={startGoalEdit}>
<div class="card-icon"><Zap size={24} /></div>
<div class="card-value">{goalStreak}</div>
<div class="card-label">{t('streak', lang)}</div>
{#if goalWeekly !== null}
<div class="card-goal">{goalWeekly}x / {t('streak_week', lang).toLowerCase()}</div>
{:else}
<div class="card-goal">{t('set_goal', lang)}</div>
{/if}
</button>
</div>
{#if goalEditing}
@@ -192,16 +183,32 @@
</div>
{/if}
{#if (stats.workoutsChart?.data?.length ?? 0) > 0}
<FitnessChart
type="bar"
data={workoutsChartData}
title={t('workouts_per_week', lang)}
height="220px"
/>
{:else}
<p class="empty-chart">{t('no_workout_data', lang)}</p>
{/if}
<div class="chart-streak-row">
<div class="chart-streak-chart">
{#if (stats.workoutsChart?.data?.length ?? 0) > 0}
<FitnessChart
type="bar"
data={workoutsChartData}
title={t('workouts_per_week', lang)}
height="220px"
/>
{:else}
<p class="empty-chart">{t('no_workout_data', lang)}</p>
{/if}
</div>
<button class="streak-section" onclick={startGoalEdit}>
<FitnessStreakAura value={goalStreak} />
<div class="streak-meta">
<span class="streak-unit">{goalStreak === 1 ? t('streak_week', lang) : t('streak_weeks', lang)}</span>
<span class="streak-label">{t('streak', lang)}</span>
{#if goalWeekly !== null}
<span class="streak-goal">{goalWeekly}x / {t('streak_week', lang).toLowerCase()}</span>
{:else}
<span class="streak-goal">{t('set_goal', lang)}</span>
{/if}
</div>
</button>
</div>
{#if (stats.weightChart?.data?.length ?? 0) > 1}
<FitnessChart
@@ -226,7 +233,7 @@
.lifetime-cards {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: 0.6rem;
}
.lifetime-card {
@@ -241,16 +248,6 @@
text-align: center;
position: relative;
}
button.lifetime-card {
border: none;
cursor: pointer;
font-family: inherit;
color: inherit;
transition: box-shadow 0.15s;
}
button.lifetime-card:hover {
box-shadow: var(--shadow-sm), 0 0 0 2px var(--nord13);
}
.lifetime-card::before {
content: '';
position: absolute;
@@ -270,9 +267,6 @@
.lifetime-card.cardio::before {
background: var(--nord14);
}
.lifetime-card.streak::before {
background: var(--nord13);
}
.card-icon {
display: flex;
align-items: center;
@@ -298,10 +292,6 @@
color: var(--nord14);
background: color-mix(in srgb, var(--nord14) 15%, transparent);
}
.streak .card-icon {
color: var(--nord13);
background: color-mix(in srgb, var(--nord13) 15%, transparent);
}
.card-value {
font-size: 1.4rem;
font-weight: 800;
@@ -321,12 +311,6 @@
text-transform: uppercase;
color: var(--color-text-secondary);
}
.card-goal {
font-size: 0.6rem;
color: var(--color-text-secondary);
opacity: 0.7;
margin-top: 0.1rem;
}
.card-hint {
font-size: 0.55rem;
color: var(--color-text-secondary);
@@ -377,10 +361,75 @@
text-decoration: underline;
}
@media (max-width: 750px) {
/* Chart + Streak row */
.chart-streak-row {
display: flex;
gap: 1rem;
align-items: stretch;
}
.chart-streak-chart {
flex: 1;
min-width: 0;
}
/* Streak section */
.streak-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
background: var(--color-surface);
border: none;
border-radius: 12px;
box-shadow: var(--shadow-sm);
cursor: pointer;
font-family: inherit;
color: inherit;
transition: box-shadow 0.15s;
}
.streak-section:hover {
box-shadow: var(--shadow-sm), 0 0 0 2px var(--nord13);
}
.streak-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
}
.streak-unit {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.streak-label {
font-size: 1rem;
font-weight: 700;
color: var(--nord13);
}
.streak-goal {
font-size: 0.7rem;
color: var(--color-text-secondary);
opacity: 0.7;
}
@media (max-width: 600px) {
.lifetime-cards {
grid-template-columns: repeat(2, 1fr);
}
.chart-streak-row {
flex-direction: column-reverse;
}
.streak-section {
flex-direction: row;
gap: 1rem;
}
.streak-meta {
align-items: flex-start;
}
}
@media (max-width: 400px) {
.lifetime-cards {