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분 만에 확인하기TT
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
TT Alarms — screenshot-to-alarm reminders for table tennis matches
Upload screenshots of upcoming table tennis matches, selections and statistics. Google Gemini reads them, you review and correct the extracted data next to the original image, and the app schedules server-side reminders that arrive as browser push and in-app notifications before each match starts.
(Screenshots come from the automated end-to-end run, which uses a mocked Gemini API and generated sample screenshots.)README 전문 보기 ↗Upload screenshots of upcoming table tennis matches, selections and statistics. Google Gemini reads them, you review and correct the extracted data next to the original image, and the app schedules server-side reminders that arrive as browser push and in-app notifications before each match starts.
| Review (screenshot beside extracted data) | Dashboard + in-app notification | Mobile |
|---|---|---|
(Screenshots come from the automated end-to-end run, which uses a mocked Gemini API and generated sample screenshots.)
responseJsonSchema) for player 1/2, competition, start time text, OVER/UNDER, points line, O/U record, O/U %, EDGE % and the status-bar clock. Every value is re-validated on the server. Invalid values become null with a warning and are never "repaired" by guessing.Today at 6:00 PM, Tomorrow at 10:30 AM, 21/09/2026 at 15:00, Starts in 45 minutes, 18:00 CET, UTC+2 offsets, weekday names, ISO and month-name dates. Anything uncertain is flagged for confirmation (see Time handling).Next.js 16 (App Router) · React 19 · TypeScript · Tailwind CSS 4 · PostgreSQL · Prisma 7 (with @prisma/adapter-pg) · @google/genai (Gemini 3.5 Flash-Lite, with Gemini 3.8 Flash as automatic backup) · web-push (VAPID) · Zod 4 · Luxon · Vitest.
The NBA page tracks tipsters' NBA picks - no alarms, just profit:
Tutorial: the first time you open the NBA page (after the main tutorial), a short walkthrough explains scanning picks, tipsters and bookmaker codes, the game date and details, automatic results and the stats. Replay it with How it works on the NBA page or Settings → NBA → Show the NBA tutorial.
Scan picks: screenshot a tipster's message (e.g. Discord). Gemini reads every bet in it: the pick, units ("1.5u"), odds, bookmaker and when it was posted. American odds are turned into decimal (+138 = 2.38). Whose pick comes from the sender: names listed in Settings → NBA are mapped (Winible → ADP); anyone else is tracked under the name they post with (Tyvan). Bookmaker codes are mapped the same way (FD → Sportsbet, "All on Sportsbet" applies to every bet; with several odds listed, the first bookmaker counts). The bookmaker is picked from a list - Bet365, Ladbrokes, Tab, Sportsbet, Pointsbet - and always saved with that one spelling ("PointsBet", "points bet" and "PB" are all Pointsbet), so each bookmaker has one row in the stats. Discord timestamps ("Yesterday at 6:52 PM", "6/13/2026 5:00 AM") are read in your timezone. Check the rows, then add them; the same pick from the same tipster at the same time isn't added twice.
Add bet types one in by hand; every bet can be edited or deleted.
Settling: Won / Lost / Void on each pending bet (and back to pending). Profit is in units with your unit size, like table tennis.
Stats: NBA, table tennis and combined totals at the top; each tipster's bets, record, win rate, ROI and profit (click one to see only their picks); running profit (NBA or combined), Daily P/L calendar and a by-bookmaker table.
Automatic results (ESPN): player props, moneylines, spreads and totals are settled by themselves once the game is final, from ESPN's free public scores and box scores (no key or account). On its own (from the same cron call as the alarms, every 10 minutes per server) it first looks up when the bet's game tips off, then leaves it until about an hour after the game should be over (tip-off + 3½ hours), then checks every 20 minutes until it's final; Check results looks right away. It finds the game by team (or, for a player prop with no team, through that day's box scores), waits for "Final", then settles: props from the player's stat against the line (didn't play = void, exactly on the line = push/void), moneylines from the winner, spreads from the margin plus the spread, totals from both scores. The bet shows auto and what was found ("De'Aaron Fox: 3 threes (line 1.5)"); settling it yourself always wins. Futures, parlays and anything unclear (no line, team not found after 2 days, postponed games) are left for you, with a note saying why. ESPN's data is unofficial: if it changes or doesn't answer, bets just stay pending ("Couldn't reach ESPN - will try again"). The details each bet needs (player / stat / line / side, or the team and spread) are read from the screenshot and can be edited. (optional) points the checks at another ESPN-compatible address.
Access code (optional): set ACCESS_CODE (e.g. on Vercel → Environment Variables) and every visitor must enter it before they see anything, including the login page. Each device asks once (it's remembered for a year); changing the code makes everyone enter the new one. Codes ignore upper/lower case. The alarm checker (/api/cron/dispatch) keeps working without it.
First-run tutorial: after signing up, a short tutorial asks where your picks come from. Upload reads screenshots of your picks (plus a free Gemini key); without pick screenshots you can get the picks from Tailing. It then asks which bookmaker you use and fills in League links for you: Ladbrokes (recommended: you rarely get limited, and it has Czech Liga Pro) or Sportsbet (you get limited extremely fast; TT Cup and TT Elite only). Next you choose when the alarm goes off. Ladbrokes publishes Over/Under points lines 5 minutes before a match and Sportsbet 30 minutes before, so picking a bookmaker pre-selects that time. After that it covers notifications, unit size (1u should be 1-2% of your bankroll) and how alarms and profit tracking work. It also shows what to do when the alarm goes off: click the players' names to open the league on your bookmaker, then bet in the Total market (Over/Under). It can't be skipped the first time and shows once per account; replay it (and close it any time) from Settings → Account → Show the tutorial again.
LEADERBOARD_ALWAYS_SHOW (in .env, comma-separated) are ranked by ROI regardless of the minimum. You can filter to Bot or Personal plays. Only display names are shown, never emails. Click a name to open that account's profit page (read-only: its bets, graph, calendar and stats; the tailed account's name opens Tailing). Turn off Settings → Account → Show me on the leaderboard to hide yourself from both.TAILING_ACCOUNT_EMAIL in .env). The page shows that account's upcoming bets and a read-only profit page (all the filters and charts). The owner sees a preview of what everyone else sees. NBA tailing: accounts the owner allows (Settings → Accounts → NBA tailing; it starts on for Lyu only) also get a Table tennis / NBA switch there, showing the owner's NBA record (overall and per tipster), open picks and settled picks. Open picks can be copied to your own NBA page (one at a time or all), with the same units and odds, and are settled from ESPN like your own; a pick can't be copied twice. Futures aren't included (not shown, counted or copied). The owner's notes stay private.Upcoming → Triggered → Pending → Completed. A match moves to Pending only when you confirm the bet is placed (I've placed the bet on the alarm, or the Bet placed button on the card), and moves to Completed when you mark the bet Won / Lost / Void. Record bet just notes the stake/odds early: the match stays where it is and its alarm still rings. Finished or cancelled matches without a pending bet are in Completed.
Didn't record the bet? When a match with a pick (e.g. 1.5U OVER) starts with no bet recorded, the pick is filled in as its bet (the match's units and odds) and the match moves to Pending, marked Auto-filled. Not when you skipped or cancelled the alarm. Scanning the bet slip later replaces it with what you actually bet; marking it won/lost or editing it confirms it. Until then it doesn't count towards profit. Turn it off in Settings → Bets you don't record. Finished matches with no bet registered (none, or only an auto-filled one never confirmed) are deleted 3 days after they started (unless edited in that time), so the dashboard doesn't fill up - long enough to scan the slips first, which need the match to tell bot plays from personal ones. Bets you registered are never deleted.
Placed the bet before the reminder? Click Bet placed on the card: the units are filled in from the play (e.g. 1.5U), you enter the odds you actually got (required), the alarm won't go off, and the match moves to Pending. Confirming on the alarm screen also requires the odds. Each card shows the play's units next to the pick.
| What | Required? | Where to get it | Env variable(s) |
|---|---|---|---|
| PostgreSQL 14+ database | Yes | Local install, Docker (docker compose), or a hosted service (Neon, Supabase, Railway, RDS…) | DATABASE_URL |
| Google Gemini API key | Yes, for scanning (one per user) | https://aistudio.google.com/apikey (free tier available) | None: each user enters their own key in Settings → Gemini API (with the model and backup model) |
| VAPID key pair for Web Push | Yes, for background push | Run npm run vapid locally. No account is needed. | VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT |
| Cron secret | Only for SCHEDULER_MODE=external | openssl rand -hex 32 | CRON_SECRET |
| Session secret | Optional (one is generated and stored in the database if unset) | openssl rand -hex 32 | SESSION_SECRET |
| External cron service | Only on serverless hosts (Vercel etc.) | cron-job.org, Upstash QStash, Vercel Cron (Pro) | — |
All secrets stay on the server. The browser only receives the VAPID public key, from /api/push/config at runtime. .env is git-ignored, and .env.example documents every variable.
Prerequisites: Node.js 20.19+ (22 recommended) and PostgreSQL.
# 1. Install dependencies (also generates the Prisma client)
npm install
# 2. Configure environment
cp .env.example .env
# - set DATABASE_URL
# - npm run vapid -> paste both keys into VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY
# 3. Create the database schema
npx prisma migrate deploy # or: npm run db:migrate:dev while developing
# 4. Run
npm run dev # http://localhost:3000
Then:
No Postgres installed? Run docker compose up -d db and set
DATABASE_URL=postgresql://tt:tt@localhost:5432/table_tennis?schema=public. The compose file does not publish the DB port by default. Add ports: ["5432:5432"] to the db service if you need it.
| Command | Purpose |
|---|---|
npm run dev / build / start | Next.js development / production build / production server |
npm run worker | Standalone alarm scheduler process |
npm test | Vitest test suite (add TEST_DATABASE_URL to include the Postgres integration tests) |
npm run lint / npm run typecheck | ESLint / TypeScript |
npm run db:migrate | Apply migrations (prisma migrate deploy) |
npm run db:migrate:dev | Create/apply migrations during development |
npm run db:studio | Browse the database |
npm run vapid | Generate VAPID keys |
Browser timers alone cannot be trusted: tabs sleep, phones lock and servers restart. Instead:
fireAt = startsAt − reminderMinutes, a status and nextAttemptAt. Nothing is held only in memory, so alarms survive restarts and deploys.UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING id, so several dispatchers (web server, worker, cron) can run at once and none of them processes the same alarm twice.(alarm, generation, subscription), a unique in-app notification per (alarm, generation), and a notification tag so the OS replaces rather than stacks notifications. Editing an alarm bumps generation, so a rescheduled alarm is delivered again.404/410 deactivate the expired subscription.SCHEDULER_MODE)| Mode | Use when | How it runs | Precision |
|---|---|---|---|
inprocess (default) | One long-running Node server (npm start on a VPS, Railway, Render, Fly.io) | src/instrumentation.ts starts the loop inside the Next.js server | ~1 s (polls every SCHEDULER_INTERVAL_MS, and also wakes when the next alarm is due) |
worker | Docker/VPS with a separate process (recommended) | npm run worker (the worker service in docker-compose.yml) | ~1 s for existing alarms; up to one poll interval for alarms created less than 10 s before they're due |
external | Serverless (Vercel, Netlify) where background processes aren't allowed | A cron service calls GET /api/cron/dispatch with Authorization: Bearer $CRON_SECRET | Up to the cron interval (typically 1 min) |
Recommendation: the most reliable setup for minute-level reminders is a long-running host (Docker Compose on a small VPS, Railway, Render or Fly.io) with the worker or inprocess mode. On Vercel you need an external every-minute trigger:
vercel.json with {"crons":[{"path":"/api/cron/dispatch","schedule":"* * * * *"}]}. Vercel sends Authorization: Bearer $CRON_SECRET automatically when CRON_SECRET is set.https://your-app/api/cron/dispatch every minute with the header Authorization: Bearer <CRON_SECRET>.Running more than one mode at the same time is safe because claims are atomic.
All three have free plans. Together they run the app 24/7 with your PC off.
[YOUR-PASSWORD] with your password and add ?sslmode=require to the end.
DATABASE_URL, used by the app.DIRECT_URL, used only for database migrations during builds.DATABASE_URL: it allows only ~15 connections, and Vercel's instances use them up ("max clients reached in session mode")..env, with these changes:
DATABASE_URL = the Supabase Transaction pooler string (port 6543)DIRECT_URL = the Supabase Session pooler string (port 5432)APP_URL = your Vercel address, e.g. https://tt-alarms.vercel.app (you can add it after the first deploy, then redeploy)CRON_SECRET = a long random password (openssl rand -hex 32)VAPID_* keys and SESSION_SECRETSCHEDULER_MODE: on Vercel it defaults to externalvercel-build script applies the database migrations, then builds the app.vercel.json runs the app in Vercel's Sydney region (syd1), next to a Sydney Supabase database. If your database is elsewhere, change it to the matching region (e.g. sin1, iad1, fra1). A server far from the database adds a noticeable delay to every click.https://<your-app>.vercel.app/api/cron/dispatch.Free plan limits:
CPU_BUDGET_HOURS, default 4): at 35%, 55% and 75% open apps check less and less often - notifications every 1, 2 then 5 minutes, the dashboard every 3, 10 minutes then only when an alarm goes off, the computer alarm every 30, 45 then 60 seconds (pushes still ring it straight away) - so it slows down long before the limit. The cron's alarm check is never slowed. Settings → Server status shows the server time used and the saving step; it's an estimate, Vercel → Usage has the exact figure.Updating: push to GitHub. Vercel rebuilds and redeploys automatically (about 1–2 minutes) and runs new database migrations. Browsers and the desktop app pick up the new version on their next reload. The desktop app also reloads by itself when it is reopened after being in the tray for 30+ minutes.
desktop/ is a small Electron app: an installable Windows program that opens your hosted app in its own window.
App changes reach it automatically (it loads your server). You only need a new installer when desktop/ itself changes.
Build the installer on GitHub (no setup):
https://tt-alarms.vercel.app. Or set it once as the APP_URL repository variable under Settings → Secrets and variables → Actions → Variables.TT-Alarms-Setup-1.0.0.exe..exe (Google Drive, Discord...). Pushing a tag such as desktop-v1.0.0 also attaches it to a GitHub Release.Or build on a Windows PC:
cd desktop
# put your address in config.json: { "appUrl": "https://tt-alarms.vercel.app" }
npm install
npm run dist # -> desktop\dist\TT-Alarms-Setup-1.0.0.exe
Use npm start to try it without installing. TT_APP_URL=http://localhost:3000 npm start points it at a local server.
The installer isn't code-signed, so Windows SmartScreen shows "Windows protected your PC" the first time. Click More info → Run anyway. Removing the warning needs a paid code-signing certificate.
Web Push isn't available inside Electron. The desktop app shows its own Windows notifications while it's running (including from the tray); phones keep using Web Push.
cp .env.example .env # fill in the VAPID keys
docker compose up -d --build
# open http://localhost:3000 (put it behind HTTPS for push - see below)
The web container applies migrations on start (prisma migrate deploy).
npm ci && npm run build
npx prisma migrate deploy
npm start # SCHEDULER_MODE=inprocess
# or: SCHEDULER_MODE=worker npm start + npm run worker (two processes)
HTTPS is required for service workers and push everywhere except localhost. Use your platform's TLS or a reverse proxy such as Caddy, nginx or Cloudflare Tunnel.
Each device that enables notifications is marked as a computer or a phone (auto-detected; switchable in Settings → devices).
1U OVER (12/17, 71%)) is a bot play. A match without one is a personal play; its pick comes from the O/U %: over 50% is OVER, under 50% is UNDER (exactly 50% is left for you to choose). Scans never pick SWEEP (it is easily misread from the SWEEP statistic): a SWEEP read from a screenshot is replaced by the O/U rule (a 1U SWEEP badge still makes it a bot play). Choose SWEEP yourself when you want it. The badge stake (1U, 2U) is read too. You can switch the type on the review screen or on the match page.These are notifications, not native phone alarms:
Gemini only transcribes the time text it sees. All date arithmetic is done by deterministic code (src/lib/time/resolve.ts):
Capture time is taken from, in order: EXIF DateTimeOriginal (with its offset if present), then the file's last-modified time, then nothing. A file-modified time counts as reliable only when it matches the clock visible in the screenshot's status bar (within 3 minutes). Otherwise it is flagged. On the review screen you can enter the capture time or click Taken just now.
Timezone comes from a label printed in the screenshot (CET, UTC+2, …) if there is one. Otherwise your confirmed timezone from Settings is used. If your timezone is not confirmed, every time needs confirmation.
The app never guesses. These cases are shown in amber and need an explicit "I checked this start time" tick or a manual edit:
03/04/2026 (the alternative reading is shown)If no time is visible at all, the field stays empty and you enter it yourself.
Confirmed times are stored as UTC (timestamptz) together with the IANA timezone used, so alarms trigger correctly across DST changes.
dedupeKey (normalised player names in any order + start minute) is enforced by the database. If the same players already have a match within ±3 hours, you are asked to confirm before another one is created.matchId), and delivery is de-duplicated as described above.npm test # unit tests (Gemini API mocked)
TEST_DATABASE_URL=postgresql://... npm test # + PostgreSQL integration tests (database must be migrated)
The suites cover:
tests/gemini-extraction.test.ts: request shape (image, JSON schema, default temperature), validation and coercion, nulls for missing data, rejected/invalid output, API error handling. @google/genai is mocked.tests/time-resolve.test.ts: all required time formats, timezones, DST, ambiguous dates, and every "needs confirmation" path.tests/alarm-scheduling.test.ts: the 17:55 trigger time for an 18:00 match with a 5-min reminder, concurrent dispatchers, retries, expired subscriptions, missed alarms, crash recovery, edits during sending, auto-completion, and notification text.tests/duplicates-and-validation.test.ts: name normalisation, duplicate keys, merge suggestions and merging, candidate validation, API schemas, image type sniffing and session tokens.tests/db-integration.test.ts: real Postgres. Covers duplicate prevention under concurrent requests, SKIP LOCKED claiming across connections, rescheduling and cancelling.CI (.github/workflows/ci.yml) runs lint, typecheck, all tests against a Postgres service, and a production build.
prisma/
schema.prisma # database schema
migrations/ # SQL migrations
public/
sw.js # service worker: push display + click-to-open
icons/ # PWA / notification icons
src/
app/ # App Router pages and API routes
page.tsx # dashboard
upload/ # upload + review
matches/[id]/, matches/new/
settings/, login/
api/ # screenshots, extract, matches, alarms, push, notifications, settings, cron, auth, health
components/ # UI (dashboard, match card, review forms, providers)
lib/
gemini/ # prompt, JSON schema, client call, server-side validation
time/resolve.ts # deterministic time resolution
alarms/ # schedule maths, dispatcher, Prisma store, service layer, notification text
matching/dedupe.ts # duplicate keys + merge suggestions
review/candidate.ts # review-screen model, merge and validation
push/, scheduler/ # web-push sender, scheduler loop
validation/ # Zod schemas
instrumentation.ts # starts the in-process scheduler
proxy.ts # optional password protection
worker/index.ts # standalone scheduler worker
tests/ # Vitest suites
src/lib/env.ts). None of them are NEXT_PUBLIC_*.nosniff.DATABASE_URL and run npx prisma migrate deploy.gemini_not_configured: add your Gemini API key in Settings → Gemini API (every account needs its own). With gemini_unavailable (429/5xx), wait and click Rescan.[scheduler] started in the server logs, check that npm run worker is running, or that your cron service calls /api/cron/dispatch.The two reference screenshots mentioned in the original brief did not arrive with the request. The dashboard design follows the written description (compact dark cards, OVER | O/U: 20/9 - 69% | EDGE: 47% line, colour-coded selections, progress bars). The Gemini prompt is layout-agnostic. Once real screenshots are available, scan them and adjust src/lib/gemini/schema.ts if any field needs extra guidance.
NBA_STATS_BASE_URLGame date: read from a slip when it shows the game ("SAT 8:40PM ET"); typed in, it's the day the game is played where you are (a 7 PM ET game is the next morning in Sydney; the time of day doesn't matter, and the US date works too). A scanned pick with no game shown is set to tomorrow (picks sent today are for tonight's US games, which are tomorrow in Sydney); change it in the review if the game is another day. NBA dates are just the day (no time). Check results also looks at bets up to a year old, so you can test it on a past game, and its message lists why each unsettled bet wasn't settled (the same note shows under the bet). ESPN is asked like a browser, with a second ESPN address as a backup, and a day with no games is asked again for preseason games.
NBA bets never appear on the table tennis Profit page, the leaderboard or Tailing.
AuthorizationBearer <your CRON_SECRET>STORAGE_LIMIT_MB, default 500 MB - Supabase's free plan, which turns read-only when full);PUT/DELETE /api/matches/:id/bet ({stake, odds, result}).