My child learned cup stacking, or sport stacking, at school and wanted a timer to practice at home. Buying a dedicated timer felt hard to justify, and a smartphone stopwatch was awkward to operate.
So I decided to build a simple tablet timer that starts and stops when you touch anywhere on the screen. Making it a PWA would let us add it to the home screen, use it like an app, and run it offline.
Demo: cupstacking.funq.kr
Technology Stack
| Stack | Technology |
|---|---|
| Framework | React 19.2 + TypeScript 5.9 |
| Build | Vite 7.2 + vite-plugin-pwa |
| Styling | Tailwind CSS v4 |
| Routing | React Router v7 |
1. A requestAnimationFrame-Based Timer
The Limitations of setInterval
// ❌ 부정확한 방식
setInterval(() => {
setTime(prev => prev + 10);
}, 10);setInterval does not guarantee precise intervals. It is throttled when the browser tab goes into the background, and callback execution time accumulates, causing drift.
requestAnimationFrame + Date.now()
export type TimerState = 'idle' | 'running' | 'stopped';
export function useTimer() {
const [time, setTime] = useState(0);
const [state, setState] = useState<TimerState>('idle');
const startTimeRef = useRef<number>(0);
const rafRef = useRef<number>(0);
useEffect(() => {
if (state !== 'running') return;
const updateTime = () => {
const elapsed = Date.now() - startTimeRef.current;
setTime(elapsed);
rafRef.current = requestAnimationFrame(updateTime);
};
rafRef.current = requestAnimationFrame(updateTime);
return () => {
cancelAnimationFrame(rafRef.current);
};
}, [state]);
const start = useCallback(() => {
startTimeRef.current = Date.now();
setState('running');
}, []);
const stop = useCallback(() => {
cancelAnimationFrame(rafRef.current);
const finalTime = Date.now() - startTimeRef.current;
setTime(finalTime);
setState('stopped');
return finalTime;
}, []);
const reset = useCallback(() => {
cancelAnimationFrame(rafRef.current);
setTime(0);
setState('idle');
}, []);
const toggle = useCallback(() => {
if (state === 'idle') {
start();
return null;
} else if (state === 'running') {
return stop();
}
return null;
}, [state, start, stop]);
return { time, state, start, stop, reset, toggle };
}Key points:
- Calculate elapsed time from
Date.now()→ no cumulative timing error requestAnimationFrame→ synchronized with the display refresh rate, usually 60fps- Store the start time in
useRef→ unaffected by rerenders toggle()→ handle start and stop in one function, returning the final time on stop
Formatting Time
export function formatTime(ms: number): string {
const seconds = Math.floor(ms / 1000);
const milliseconds = ms % 1000;
return `${seconds}.${milliseconds.toString().padStart(3, '0')}`;
}
// 12345 → "12.345"2. A Complete Guide to PWA (Progressive Web App)
PWA technology lets an app built with web technologies be used like a native app. Here is why I applied it to this timer:
- Home-screen installation—launch immediately by tapping an icon
- Full screen—looks like an app without the browser address bar
- Offline support—works without internet access
- Automatic updates—new deployments are applied automatically
Configuring vite-plugin-pwa
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
manifest: {
name: '컵쌓기 타이머',
short_name: '컵타이머',
description: '스포츠 스태킹 타이머 앱',
theme_color: '#10b981',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait',
icons: [
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
}
]
}
})
],
})Manifest Properties
| Property | Value | Description |
|---|---|---|
name | Cup Stacking Timer | Full name shown during app installation |
short_name | Cup Timer | Shown below the home-screen icon (12 characters or fewer recommended) |
display | standalone | Hide the browser UI and display like a native app |
orientation | portrait | Lock to portrait mode for the timer's use case |
theme_color | #10b981 | Status-bar color (emerald-500) |
background_color | #ffffff | Splash-screen background while the app loads |
Comparing Display Modes
┌─────────────────────────────────────────────────────┐
│ fullscreen │ standalone │ browser │
├───────────────┼───────────────┼────────────────────┤
│ 상태바 숨김 │ 상태바만 표시 │ 브라우저 UI 전체 │
│ 게임/몰입형 │ 일반 앱 │ 웹사이트 │
│ 주소창 없음 │ 주소창 없음 │ 주소창 있음 │
└─────────────────────────────────────────────────────┘
Why I chose standalone: it shows the status bar, including the clock and battery, while hiding the browser UI.
registerType: autoUpdate
registerType: 'autoUpdate'Service Worker update strategies:
| Strategy | Behavior |
|---|---|
autoUpdate | Automatically update the Service Worker when a new version is detected |
prompt | Show a UI asking whether the user wants to update |
Why I chose autoUpdate: there is little concern about losing state in this timer app, and it is better to always use the latest version.
Preparing Icons
A PWA needs at least two icons:
public/
├── apple-touch-icon.png # iOS Safari용 (180x180)
├── favicon.ico # 브라우저 탭용
├── pwa-192x192.png # Android 홈 화면용
└── pwa-512x512.png # 스플래시 화면용
Generate them automatically from the source SVG using sharp:
import sharp from 'sharp';
const sizes = [192, 512];
const svgBuffer = await fs.readFile('public/icon.svg');
for (const size of sizes) {
await sharp(svgBuffer)
.resize(size, size)
.png()
.toFile(`public/pwa-${size}x${size}.png`);
}Supporting iOS Safari
On iOS, specify apple-touch-icon separately:
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />Build Output
Files generated automatically when npm run build runs:
dist/
├── sw.js # Service Worker
├── workbox-*.js # Workbox 런타임
├── manifest.webmanifest
└── registerSW.js # SW 등록 스크립트
3. Preventing Duplicate Touch and Click Events
On mobile, a touchend is followed by a click event after a 300ms delay. If both are handled, the timer toggles twice.
const touchHandledRef = useRef(false);
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
e.preventDefault();
touchHandledRef.current = true;
handleTouch();
setTimeout(() => {
touchHandledRef.current = false;
}, 300);
},
[handleTouch]
);
const handleClick = useCallback(() => {
if (touchHandledRef.current) return; // 터치 후 클릭 무시
handleTouch();
}, [handleTouch]);<div
className={`min-h-full ${bgColor} flex flex-col`}
onClick={handleClick}
onTouchEnd={handleTouchEnd}
>Event flow:
- Touch →
touchHandledRef = true→ toggle the timer - Click within 300ms → check
touchHandledRef→ ignore - After 300ms →
touchHandledRef = false→ wait for the next input
4. A Custom LocalStorage Hook Pattern
Managing Players and Records
const PLAYERS_KEY = 'cupstacking_players';
const RECORDS_KEY = 'cupstacking_records';
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
export function usePlayers() {
const [players, setPlayers] = useState<Player[]>(() => {
const stored = localStorage.getItem(PLAYERS_KEY);
return stored ? JSON.parse(stored) : [];
});
useEffect(() => {
localStorage.setItem(PLAYERS_KEY, JSON.stringify(players));
}, [players]);
const addPlayer = useCallback((name: string): Player => {
const newPlayer: Player = {
id: generateId(),
name: name.trim(),
createdAt: Date.now(),
};
setPlayers((prev) => [...prev, newPlayer]);
return newPlayer;
}, []);
const removePlayer = useCallback((id: string) => {
setPlayers((prev) => prev.filter((p) => p.id !== id));
}, []);
const getPlayer = useCallback(
(id: string) => players.find((p) => p.id === id),
[players]
);
return { players, addPlayer, removePlayer, getPlayer };
}Design decisions:
- Initial value: read localStorage in the
useStateinitializer function (SSR-compatible) - Persistence: use
useEffectto detect state changes and save automatically - ID generation: timestamp + random value to prevent collisions
Optimizing Record Lookups
const getBestRecord = useCallback(
(eventType: TimeRecord['eventType'], playerIds: string[]) => {
const key = [...playerIds].sort().join(',');
return records
.filter(
(r) =>
r.eventType === eventType &&
[...r.playerIds].sort().join(',') === key
)
.sort((a, b) => a.time - b.time)[0];
},
[records]
);Sort playerIds and convert them to a string for comparison → treat [A, B] and [B, A] as identical.
5. Domain Modeling with TypeScript
export type EventType = '3-3-3' | '3-6-3' | 'cycle' | 'doubles' | 'team-3-6-3';
export interface Player {
id: string;
name: string;
createdAt: number;
}
export interface TimeRecord {
id: string;
eventType: EventType;
playerIds: string[];
time: number; // milliseconds
createdAt: number;
}
export const INDIVIDUAL_EVENTS: EventType[] = ['3-3-3', '3-6-3', 'cycle'];
export const TEAM_EVENTS: EventType[] = ['doubles', 'team-3-6-3'];
export const EVENT_NAMES: { [key in EventType]: string } = {
'3-3-3': '3-3-3',
'3-6-3': '3-6-3',
'cycle': '사이클',
'doubles': '더블',
'team-3-6-3': '팀 3-6-3',
};
export const EVENT_MIN_PLAYERS: { [key in EventType]: number } = {
'3-3-3': 1,
'3-6-3': 1,
'cycle': 1,
'doubles': 2,
'team-3-6-3': 2,
};{ [key in EventType]: string } → a value is required for every event. Adding an event causes a compile error if its value is missing.
6. Tailwind CSS v4 Configuration
@import "tailwindcss";
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
}
/* Prevent pull-to-refresh and overscroll */
html {
overscroll-behavior: none;
}
/* Disable text selection on timer screen */
.no-select {
-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;
}Background Colors by State
const bgColor =
state === 'idle'
? 'bg-white'
: state === 'running'
? 'bg-emerald-400'
: 'bg-amber-300';Visual feedback: ready (white) → running (green) → finished (yellow)
Responsive Font Sizes
<p
className="font-mono font-bold text-gray-800"
style={{ fontSize: 'min(20vw, 120px)' }}
>
{formatTime(time)}
</p>min(20vw, 120px) → 20% of the viewport width, capped at 120px. It keeps the size appropriate from tablets to desktops.
7. URL Parameters with React Router v7
Route Definitions
<Routes>
<Route path="/" element={<Home />} />
<Route path="/select/:eventType" element={<PlayerSelect />} />
<Route path="/timer/:eventType" element={<Timer />} />
<Route path="/result/:eventType" element={<Result />} />
<Route path="/ranking" element={<Ranking />} />
<Route path="/players" element={<PlayerManage />} />
</Routes>Combining Path and Query Parameters
const { eventType } = useParams<{ eventType: EventType }>();
const [searchParams] = useSearchParams();
const playerIds = useMemo(() => {
const param = searchParams.get('players');
return param ? param.split(',').filter(Boolean) : [];
}, [searchParams]);URL: /timer/3-6-3?players=abc123,def456 → playerIds = ["abc123", "def456"]
8. Preventing Accidental Input
A One-Second Guard
Prevent children from accidentally stopping the timer immediately:
const handleTouch = useCallback(() => {
if (state === 'stopped') return;
// 1초 미만에서 정지 불가
if (state === 'running' && time < 1000) return;
const finalTime = toggle();
if (finalTime !== null) {
const record = addRecord(eventType as EventType, playerIds, finalTime);
setSavedRecordId(record.id);
}
}, [state, time, toggle, addRecord, eventType, playerIds]);Delete a Record and Try Again
Delete an incorrectly measured record and restart:
const [savedRecordId, setSavedRecordId] = useState<string | null>(null);
const handleDeleteRecord = useCallback(() => {
if (savedRecordId) {
deleteRecord(savedRecordId);
setSavedRecordId(null);
}
reset();
}, [savedRecordId, deleteRecord, reset]);Keep the ID when saving a record → the delete button can remove only that record.
9. Nginx Deployment Configuration
This Nginx configuration deploys a Vite-built SPA to an Ubuntu server.
Static File Serving vs. Reverse Proxying
| Method | Purpose | Example |
|---|---|---|
| Static file serving | Vite/React SPA | root /path/to/dist |
| Reverse proxying | Next.js/Express | proxy_pass http://127.0.0.1:3000 |
Since this timer is a Vite SPA, Nginx serves the build output in dist/ directly.
Nginx Configuration File
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name cupstacking.funq.kr;
# 빌드 결과물 경로
root /home/funq/dev/cupstacking-timer/dist;
index index.html;
# SPA 라우팅: 모든 경로를 index.html로
location / {
try_files $uri $uri/ /index.html;
}
# 정적 에셋 캐싱 (1년)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip 압축
gzip on;
gzip_types
text/plain
text/css
application/json
application/javascript
text/xml
application/xml
text/javascript;
# SSL 인증서 (Let's Encrypt)
ssl_certificate /etc/letsencrypt/live/cupstacking.funq.kr/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cupstacking.funq.kr/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
# HTTP → HTTPS 리다이렉트
server {
listen 80;
listen [::]:80;
server_name cupstacking.funq.kr;
return 301 https://$host$request_uri;
}Key Settings Explained
try_files (SPA Routing)
try_files $uri $uri/ /index.html;| Request | Nginx behavior |
|---|---|
/ | Serve dist/index.html |
/assets/index.js | Serve the requested file |
/timer/3-6-3 | File not found → serve index.html → React Router handles the route |
An SPA uses client-side routing, so even paths that do not exist as files must return index.html.
Caching Static Assets
location ~* \.(js|css|png)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}Vite includes a hash in filenames during the build, such as index-abc123.js. The hash changes with the content, so caching for one year is safe.
Gzip Compression
gzip on;
gzip_types text/css application/javascript;Compresses 100KB of JavaScript to about 30KB, improving loading speed on mobile.
Deployment Commands
# 1. 빌드
npm run build
# 2. 서버에 업로드 (또는 git pull)
rsync -avz dist/ funq@server:/home/funq/dev/cupstacking-timer/dist/
# 3. Nginx 설정 테스트 및 재시작
sudo nginx -t
sudo systemctl reload nginxIssuing a Let's Encrypt SSL Certificate
# Certbot 설치
sudo apt install certbot python3-certbot-nginx
# 인증서 발급 (Nginx 설정 자동 수정)
sudo certbot --nginx -d cupstacking.funq.kr
# 자동 갱신 확인
sudo certbot renew --dry-runSummary
| Technology | Application |
|---|---|
requestAnimationFrame | Timer without drift |
useRef | Prevent duplicate touch handling and store the start time |
| LocalStorage hook | Persist players and records |
| TypeScript Literal Types | Type safety for stacking events |
| Vite PWA | Offline support and app installation |
CSS min() | Responsive Font Sizes |
| Nginx + Let's Encrypt | Static SPA serving and HTTPS |
My child now starts the timer by tapping the tablet and watches the records to see their progress. It is a simple app, but PWA made it possible to provide an experience on par with a native app.




Comments
Korean and English pages share this conversation.
Loading comments…