BikeControl (SwiftControl)
Use Zwift Click, Zwift Play, Shimano Di2, or any Bluetooth controller with MyWhoosh, Rouvy, Zwift, and more. Free to try on iOS, Android, Windows, and macOS.
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기📱 A progressive web app built with React, TypeScript, and Vite. Uses a pure reducer state machine to enforce snooker rules, Web Audio API for offline sounds, and Supabase for cloud history.
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
A snooker scorer that knows the rules, so you don't have to argue about them.
Keeping score on paper works right up until someone pots a red on a foul, or the black gets re-spotted, or nobody can remember whose visit it is. SnookerBee is a phone-sized referee: it enforces the sequence, computes the penalties, and stays out of the way the rest of the time.
It runs offline, installs to your home screen, and survives iOS deciding to close it mid-frame.
Built for landscape. The scoring layout needs the width, so portrait shows a rotate prompt rather than a squeezed table.README 전문 보기 ↗A snooker scorer that knows the rules, so you don't have to argue about them.
▶ Open the app · installs to your home screen · works offline
Keeping score on paper works right up until someone pots a red on a foul, or the black gets re-spotted, or nobody can remember whose visit it is. SnookerBee is a phone-sized referee: it enforces the sequence, computes the penalties, and stays out of the way the rest of the time.
It runs offline, installs to your home screen, and survives iOS deciding to close it mid-frame.
Built for landscape. The scoring layout needs the width, so portrait shows a rotate prompt rather than a squeezed table.
A pure reducer — no side effects, fully testable — that holds the frame together:
| Mode | |
|---|---|
| 1 v 1 | Standard head-to-head |
| Teams | 2v2 or 3v3, alternating visits |
| Free-for-All | 2–8 players, solo scoring |
| Century | Race to exactly 50 or 100 |
10 or 15 reds. Best of 1, 3 or 5 — odd only, so a match can't end level.
Each frame rotates who breaks, and the rest follow in the order you set: 1,2,3 → 2,1,3 → 3,1,2. Everyone plays after someone different.
A few decisions that aren't obvious from the outside:
Durations are subtracted, not counted. There's no ticking timer. Start times are recorded and elapsed time is derived from them, so backgrounding the app can't lose time, undo can't rewind the clock, and nothing re-renders once a second for the length of a match.
Matches survive the app dying. iOS terminates backgrounded web apps. State is written to localStorage as you play, so the dashboard can offer to resume, and a finished match is held until a save actually succeeds.
Sharing is preview-first. iOS drops user activation across an await, so generating a PNG and then calling navigator.share() silently fails. The card is shown first; the share button is a fresh gesture with the file already in hand.
A version can't ship without release notes. npm run build runs scripts/check-release.mjs first. It fails the build — and so the Vercel deploy — unless package.json matches the newest entry in src/changelog.ts, versions run newest → oldest, and every entry is dated and lists a change. The app reads the same file for its What's new page.
Colour lives on the content, not behind it. Selected states tint text and icons rather than filling the element, which keeps one thing loud per screen instead of five.
| Layer | Choice |
|---|---|
| UI | React 19, TypeScript, Vite 8 |
| Routing | React Router 7 |
| State | useReducer state machine, no external store |
| Styling | Vanilla CSS with design tokens, themed by class |
| Offline | vite-plugin-pwa + Workbox |
| Audio | Web Audio API oscillators |
| Backend | Supabase (Postgres + Google OAuth, Row Level Security) |
| Testing | Vitest — rules engine, results, frame analysis, player stats |
| Lint | oxlint |
| CI | GitHub Actions — lint, test and build on every push and PR |
| Hosting | Vercel, gated by a release-notes check |
Requires Node 18+.
git clone https://github.com/arshad0126/snookerbee.git
cd snookerbee
npm install
npm run dev
Then open the printed URL — usually http://localhost:5173 — and turn your phone (or the browser window) sideways.
| Script | |
|---|---|
npm run dev | Dev server with HMR |
npm test | Vitest suite |
npm run lint | oxlint |
npm run build | Release-notes check, typecheck and production build |
npm run preview | Serve the built output |
Cloud sync is optional. Without Supabase credentials the app runs in guest mode and keeps history on the device.
Set these in .env locally, or in your host's environment settings:
VITE_SUPABASE_URL=your_supabase_project_url
VITE_SUPABASE_ANON_KEY=your_supabase_anon_public_key
Vite inlines these at build time, so a deployment missing them produces a bundle that can't reach the database — rebuild after changing them.
Create a Supabase project, open the SQL Editor, and run docs/schema.sql.
It is additive and safe to run against a database that already holds matches. If you set the project up before Century mode existed, running it again adds the two new tables and the milestone columns without touching anything else.
Row Level Security is on for every table — a signed-in user can only reach their own rows.
-- Matches
create table matches (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id) on delete cascade,
mode text not null,
reds_count int not null,
best_of int not null,
created_at timestamptz default now(),
duration_ms bigint not null,
winner_name text not null
);
alter table matches enable row level security;
create policy "Users can CRUD their own matches" on matches
for all using (auth.uid() = user_id);
-- Players within a match
create table match_players (
id uuid primary key default gen_random_uuid(),
match_id uuid references matches(id) on delete cascade,
player_name text not null,
team_name text,
total_score int default 0,
highest_break int default 0,
frames_won int default 0,
fouls_committed int default 0,
time_spent_ms bigint default 0
);
alter table match_players enable row level security;
create policy "Users can CRUD their own match players" on match_players
for all using (
match_id in (select id from matches where user_id = auth.uid())
);
-- Frame-by-frame action logs
create table match_frames (
id uuid primary key default gen_random_uuid(),
match_id uuid references matches(id) on delete cascade,
frame_number int not null,
duration_ms bigint,
action_log jsonb
);
alter table match_frames enable row level security;
create policy "Users can CRUD their own match frames" on match_frames
for all using (
match_id in (select id from matches where user_id = auth.uid())
);
src/
├── engine/ Rules engine — reducer, validators, types, constants (+ tests)
├── components/ Screens, plus ui/ for the shared component library
├── lib/ Supabase, local storage, audio, share cards (+ tests)
├── hooks/ Auth and theme
├── styles/ Design tokens and stylesheets
└── changelog.ts Release notes — the build checks it, the app shows it
The engine has no React in it. reducer.ts takes a state and an action and returns a state, which is why the rules can be reasoned about — and corrected — without touching the interface.
Built for the table, not the spreadsheet.