Skip to content

Commit 63922bd

Browse files
authored
Merge pull request #209 from timoclsn/running-goal-celebration
Running Goal Celebration
2 parents 42f7b20 + 3dd9a6b commit 63922bd

File tree

6 files changed

+171
-34
lines changed

6 files changed

+171
-34
lines changed

Diff for: components/CountUpNumber.tsx

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { useEffect, useState } from 'react';
2+
3+
const easeOutQuad = (t: number) => t * (2 - t);
4+
const frameDuration = 1000 / 60;
5+
6+
interface Props {
7+
children: number;
8+
duration?: number;
9+
play?: boolean;
10+
}
11+
12+
export function CountUpAnimation({ children, duration = 2000, play }: Props) {
13+
const countTo = children;
14+
const [count, setCount] = useState(0);
15+
16+
useEffect(() => {
17+
let counter: ReturnType<typeof setInterval> | undefined;
18+
19+
if (play) {
20+
let frame = 0;
21+
const totalFrames = Math.round(duration / frameDuration);
22+
counter = setInterval(() => {
23+
frame++;
24+
const progress = easeOutQuad(frame / totalFrames);
25+
setCount(countTo * progress);
26+
27+
if (frame === totalFrames) {
28+
counter && clearInterval(counter);
29+
}
30+
}, frameDuration);
31+
} else {
32+
counter && clearInterval(counter);
33+
}
34+
35+
return () => counter && clearInterval(counter);
36+
}, [countTo, duration, play]);
37+
38+
return (
39+
<>
40+
{play
41+
? Math.floor(count).toString().padStart(countTo.toString().length, '0')
42+
: countTo}
43+
</>
44+
);
45+
}

Diff for: components/RunningElement.tsx

+27-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
import { useRef } from 'react';
12
import type { Icon } from 'react-feather';
23

4+
import { CountUpAnimation } from './CountUpNumber';
35
import { Skeleton } from './Skeleton';
6+
import { useOnScreen } from './useOnScreen';
47
interface Label {
58
text: string;
69
description: string;
@@ -12,19 +15,29 @@ interface Props {
1215
href?: string;
1316
labels?: Label[];
1417
nowrap?: boolean;
18+
animateLabelNumber?: boolean;
1519
}
1620

17-
export function RunningElement({ Icon, text, href, labels, nowrap }: Props) {
21+
export function RunningElement({
22+
Icon,
23+
text,
24+
href,
25+
labels,
26+
nowrap,
27+
animateLabelNumber,
28+
}: Props) {
29+
const ref = useRef<HTMLDivElement>(null);
30+
const visible = useOnScreen(ref);
1831
return (
19-
<div className="flex items-center space-x-4">
32+
<div className="flex items-center space-x-4" ref={ref}>
2033
<div className="leading-none">
2134
{text ? (
2235
<Icon size={22} />
2336
) : (
2437
<Skeleton circle height="20px" width="20px" />
2538
)}
2639
</div>
27-
<p className={'my-2' + (nowrap ? ' whitespace-nowrap' : '')}>
40+
<p className={`my-2${nowrap && ' whitespace-nowrap'}`}>
2841
{href ? (
2942
<a href={href} target="_blank" rel="noopener noreferrer">
3043
{text}
@@ -36,13 +49,23 @@ export function RunningElement({ Icon, text, href, labels, nowrap }: Props) {
3649
{labels && (
3750
<div className="flex flex-wrap items-center">
3851
{labels.map((label) => {
52+
const textElements = label.text.match(/[^\d]+|\d+/g) ?? [];
53+
const number = textElements[0] as unknown as number;
54+
const rest = textElements[1];
3955
return (
4056
<div
4157
key={label.text}
4258
className="px-3 py-1 m-1 text-xs font-bold uppercase rounded-full whitespace-nowrap bg-highlight dark:bg-highlight-dark text-light"
4359
title={label.description}
4460
>
45-
{label.text}
61+
{animateLabelNumber ? (
62+
<>
63+
<CountUpAnimation play={visible}>{number}</CountUpAnimation>
64+
{rest}
65+
</>
66+
) : (
67+
label.text
68+
)}
4669
</div>
4770
);
4871
})}

Diff for: components/WidgetRunning.tsx

+31-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import JSConfetti from 'js-confetti';
2+
import { useEffect, useRef } from 'react';
13
import {
24
ArrowRight,
35
Calendar,
@@ -10,6 +12,10 @@ import {
1012

1113
import type { LastRun, ThisYear } from '../pages/api/running';
1214
import { RunningElement } from './RunningElement';
15+
import { useOnScreen } from './useOnScreen';
16+
17+
const jsConfetti = typeof window !== 'undefined' ? new JSConfetti() : null;
18+
1319
interface Props {
1420
thisYear?: ThisYear;
1521
lastRun?: LastRun;
@@ -96,8 +102,31 @@ export function WidgetRunning({ thisYear, lastRun }: Props) {
96102
const yearProgress = getYearProgress();
97103
const yearTrend = runningProgress >= yearProgress ? '↑' : '↓';
98104

105+
// Emoji explosion if running progress is over 100%
106+
const ref = useRef<HTMLDivElement>(null);
107+
const visible = useOnScreen(ref);
108+
109+
useEffect(() => {
110+
let timer: ReturnType<typeof setTimeout> | undefined;
111+
112+
if (visible) {
113+
timer = setTimeout(() => {
114+
if (jsConfetti && runningProgress >= 100) {
115+
jsConfetti.addConfetti({
116+
emojis: ['🏆', '🏃‍♂️', '🏃', '🏃‍♀️'],
117+
confettiNumber: runningProgress,
118+
});
119+
}
120+
}, 3000);
121+
} else {
122+
timer && clearTimeout(timer);
123+
}
124+
125+
return () => timer && clearTimeout(timer);
126+
}, [visible, runningProgress]);
127+
99128
return (
100-
<div className="px-6 py-12 xl:px-12 xl:py-20">
129+
<div className="px-6 py-12 xl:px-12 xl:py-20" ref={ref}>
101130
<h2 className="mb-2 text-xl font-bold md:text-2xl lg:text-3xl">Laufen</h2>
102131
<ul>
103132
<li>
@@ -112,6 +141,7 @@ export function WidgetRunning({ thisYear, lastRun }: Props) {
112141
},
113142
]
114143
}
144+
animateLabelNumber
115145
/>
116146
</li>
117147
</ul>

Diff for: components/useOnScreen.ts

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { RefObject, useEffect, useState } from 'react';
2+
3+
export function useOnScreen<T extends Element>(
4+
ref: RefObject<T>,
5+
rootMargin = 0
6+
) {
7+
const [isVisible, setIsVisible] = useState(false);
8+
9+
useEffect(() => {
10+
const currentElement = ref?.current;
11+
12+
const observer = new IntersectionObserver(
13+
([entry]) => {
14+
setIsVisible(entry.isIntersecting);
15+
},
16+
{
17+
rootMargin: `${rootMargin}px`,
18+
}
19+
);
20+
21+
currentElement && observer.observe(currentElement);
22+
23+
return () => {
24+
currentElement && observer.unobserve(currentElement);
25+
};
26+
}, [ref, rootMargin]);
27+
28+
return isVisible;
29+
}

Diff for: package-lock.json

+35-26
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: package.json

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "timoclasen.de",
33
"description": "My personal website.",
4-
"version": "2.20.0",
4+
"version": "2.21.0",
55
"private": true,
66
"author": {
77
"name": "Timo Clasen",
@@ -45,6 +45,7 @@
4545
"date-fns": "^2.27.0",
4646
"date-fns-tz": "^1.1.7",
4747
"ethers": "^5.5.2",
48+
"js-confetti": "^0.10.1",
4849
"lru-cache": "^6.0.0",
4950
"match-sorter": "^6.3.1",
5051
"next": "12.0.7",
@@ -78,7 +79,7 @@
7879
"autoprefixer": "^10.4.0",
7980
"cypress": "^9.1.1",
8081
"dotenv": "^10.0.0",
81-
"eslint": "^8.4.1",
82+
"eslint": "^8.5.0",
8283
"eslint-config-next": "12.0.8-canary.1",
8384
"eslint-config-prettier": "^8.3.0",
8485
"eslint-plugin-cypress": "^2.12.1",
@@ -89,7 +90,7 @@
8990
"globby": "^12.0.2",
9091
"he": "^1.2.0",
9192
"husky": "^7.0.4",
92-
"lint-staged": "^12.1.2",
93+
"lint-staged": "^12.1.3",
9394
"node-fetch": "^3.1.0",
9495
"npm-run-all": "^4.1.5",
9596
"postcss": "^8.4.5",

0 commit comments

Comments
 (0)