尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证制作者的介绍仅有上面的一句话。 更多详情请阅读仓库 README。 仓库 README ↗
正在加载完整 README。
查看完整 README ↗Tennis Hub (formerly Stringbed Meter): log your tennis, see when strings and shoes are due, keep your court bookings, and tap your racket's strings to read the string-bed tension from the pitch. A web app you install on your iPhone's Home Screen. Each player has their own account, so you can share the link with friends: everyone gets their own rackets and history. Data lives in Neon Postgres, accounts are Neon Auth, and the app is hosted on Vercel. It keeps working with no signal and catches up when you're back online.
The Neon project is linked in .neon (project young-sunset-08728679, branch production). neon.ts declares Postgres and Neon Auth (auth: true).
npm i -g neon@latest
neon login # opens a browser sign-in
neon deploy # enables Neon Auth on the branch, writes DATABASE_URL, DATABASE_URL_UNPOOLED and NEON_AUTH_BASE_URL to .env.local
neon neon-auth domain add https://<your-app>.vercel.app # Neon Auth only accepts sign-ins from listed origins
npm run db:migrate # optional: create the tables now (the API also does it on first sync)
Forgotten passwords. The sign-in form has a Forgot password? link. It asks Neon Auth (request-password-reset) to email a link; the link comes back to the app with ?token=…, where the player picks a new password (reset-password). Neon Auth sends the email, and the app's domain has to be one of its trusted domains (the domain add step above). If no email arrives, check the email settings for Neon Auth in the Neon console.
vercel.json serves public/, and npm run build checks that everything loads).DATABASE_URL: the pooled connection string from .env.local. If you connect Neon through Vercel's Storage tab instead, it sets this for you. Use the same project and branch.NEON_AUTH_BASE_URL: from .env.local.NEON_AUTH_COOKIE_SECRET: any 32+ random characters (openssl rand -base64 32).APP_PASSCODE and OWNER_EMAIL are no longer used and can be deleted. Rackets saved by the earlier passcode version have no owner; they go to the first account that signs in while it is the only account.
On Today, the left button under Log a session logs your usual session (the last type and length you logged) in one tap, with Undo. Something else opens a sheet to pick the racket, type of play (singles, doubles, hitting, lesson or drills, ball machine, serves), how long and the day. Hours on a racket count from its Strung on date, so they start again from zero when you restring. Your level (NTRP) and style of play (heavy topspin, flat and so on) are under Settings, saved with your account.
The Restring card shows how much life is left in each racket's strings, a projected restring date at your recent pace of play, and what is driving it. It takes the largest of four limits:
Hours are weighted by type of play (drills and ball machine count more than doubles). The hour figures are typical values, not lab constants; your own readings refine the estimate. The model lives in public/restring.js, shared with the chat connector, and is tested in test/restring.test.js.
Press Restrung on the racket's page each time a racket is restrung. The strings coming out are saved to the racket's Strings history with the dates, string, gauge, tension, the hours played on them and why they came out (broke, went dead or a routine restring); the racket takes the new strings and its hours start again from zero. How long each string lasts you (Gear tab) compares every string you have used across all rackets: average hours it lasted and how often it broke. The chat connector's record_restring does the same (with old_strings), and string_history answers "which string lasts longest for me?". Shared code: public/stringjobs.js.
Strings broke on the Restring card saves the set as broken in one tap and marks the racket as waiting for a restring (the chat tool strings_broke does the same). Every break you log teaches the restring estimate: its break hours move from the textbook figure toward how long strings actually last for you (personalBreak in public/restring.js).
Under Reminders in Settings, turn on notifications for the phone. Every morning (a Vercel cron at 17:00 UTC calls /api/reminders) each racket is checked, and you get one notification when it reaches Restring soon and one at Restring now, per set of strings. On iPhone this needs the app added to the Home Screen (iOS 16.4 or later). The Web Push keys are made on first use and kept in the database (or set VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY and VAPID_SUBJECT). Set CRON_SECRET in Vercel so only its cron can start the check. Code: lib/push.js, api/push.js, api/reminders.js, and the push handler in public/sw.js.
The app has four tabs along the bottom:
A racket's page has its restring estimate, Measure, Restrung, Strings broke and Edit (name, string, tension, dates, head size, calibration, delete), and a Timeline of sessions, readings and restrings, the Tension chart and readings, and its Strings history. Settings sits behind your initials at the top right: account and sync, units, your game, reminders, connections (court calendar, GameTime button, quick log, chat assistant) and backup.
The Shoes card on the Gear tab tracks the pair you play in. Every session you log, on any racket and from anywhere (the app, chat, quick log, court bookings), counts toward the pair in use; adding a new pair retires the old one that day. Expected life starts at 60 h and, once a pair or two is retired, a new pair starts from what yours actually lasted. Reminders cover shoes too, and the chat connector has list_shoes. Logic: public/shoes.js.
In Settings, under Connections, Make my quick-log link gives a private link (scope log) that can only add and undo play sessions. Opened in Safari it's a page of big buttons that can go on the Home Screen. An iPhone Shortcut can POST JSON to it ({"kind": "singles", "minutes": 90}, optionally racket and date) and gets one line of text back to show. Code: api/log.js.
Removing a reading, a session, a string-history entry or a whole racket shows an Undo bar for 8 seconds.
The app has a remote MCP server, so a chat assistant can log sessions for you: "I played an hour of singles", "log 90 minutes of doubles yesterday on the v9", "how many hours are on my strings?", "should I restring the v8?", "I restrung the v8 today at 52 lb".
claude.ai (OAuth sign-in). In Claude, open Settings → Connectors → Add custom connector, name it (for example Tennis Hub) and paste https://<your-app>.vercel.app/api/mcp as the server URL. Claude sends you to the app, which signs you in and asks whether to allow it, with read and write or read only access. There is no secret in the URL. Disconnect it any time in the app (Settings, under Tokens, links and connected apps). The phone app picks up changes on its next sync.
API tokens (Claude Code, the Claude API, scripts). In Settings, open API tokens, give the token a name, pick Read and write or Read only and an expiry, and press Make a token. It is shown once. Send it as Authorization: Bearer sbm_... to /api/mcp, for example:
claude mcp add --transport http tennis-hub https://<your-app>.vercel.app/api/mcp --header "Authorization: Bearer sbm_..."
A read only token lists and runs only the tools that read (marked readOnlyHint). An app that can't send headers can put the token in the URL instead: /api/mcp/sbm_... (connector links made before OAuth keep working this way). A token is a password for your tennis data; revoke it with ×.
How OAuth works here: /api/mcp answers 401 with WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource" (RFC 9728). The app is its own authorization server (/.well-known/oauth-authorization-server, RFC 8414): clients register at /oauth/register (RFC 7591), /oauth/authorize needs PKCE S256 and hands over to the app's Allow screen, and /oauth/token returns a one-hour access token plus a refresh token that rotates on every use. All of it is api/oauth.js and lib/oauth.js.
Tools: log_play, list_rackets, restring_advice, list_sessions, delete_session, record_restring, set_player_profile. "Today" means today in the phone's time zone when the token was made (for OAuth, at your last sign-in).
The Courts tab keeps the courts you book. Add one in the app, or paste a GameTime confirmation email into Claude with the connector on (it calls add_court_booking, and sending the same confirmation number again updates the booking). Once a booking ends, its time is logged as play on the racket you picked, or on the one you used most recently. This happens on the next sync, chat call or calendar refresh.
In Settings, under Connections, open Court calendar and make a private calendar link and subscribe to it in Apple or Google Calendar. Each booking appears with a reminder an hour before, and cancelled ones disappear. Calendar links can only read bookings and are separate from connector links (api_tokens.scope). The feed is GET /api/calendar/<secret>.ics. The calendar button on a booking (Courts tab) downloads a one-off .ics file for when you don't want to wait for the calendar to refresh.
GameTime button. In Settings, under Connections, open GameTime button and make a bookmark (drag it to the bookmarks bar, or paste it into a Safari bookmark on iPhone). Tap it while signed in on stc.gametime.net. It shows open courts by day and area, and tapping one opens GameTime's own booking form for that slot. It also sends your own bookings for the next 7 days to POST /api/gametime, which adds them, updates moved ones and cancels ones gone from GameTime. The bookmark's key (scope = 'gametime') can only save court bookings. The script is public/stc.js. It runs in your browser with your GameTime session and never books or cancels anything itself.
The panel has two more tabs. Clinics & events lists adult clinics, round robins and club nights for the next four weeks, with spots left, and links to each one on GameTime so you can sign up there. Partners lists members who opted into GameTime's partner finder, filtered by level, singles or doubles, and name, with email, text and call links. The partner list is read in your browser only; it is never sent to or stored by Stringbed Meter. Clinics and drills you're signed up for are imported as lesson time with your court bookings. Lessons shows the lesson time each pro is holding on the court sheet this week (book it with the pro or the Pro Shop), and Waitlist shows the groups waiting for each time, marking yours.
Regular times. Under Every week on the Courts tab, save the times you play every week (day, start, length, indoor/outdoor/clay, favourite court, backup courts to try in order if it's taken, partner). Edit changes one in place, Pause stops its alerts and picks until you resume it, and × removes it. A chat assistant can do the same with add_regular_time, update_regular_time (including pause) and remove_regular_time. The court calendar gets a "Book now" event with an alert at the minute GameTime opens each one (exactly 7 days before the court time ends, so a 2:15 to 3:30 pm court opens at 3:30 pm a week earlier), linking to that court's booking form and to each backup court's. The GameTime button lists them at the top of the Courts tab: for the next day you can book, it picks the best open court (your favourite first, then the same area, nearest time) with a one-tap link to GameTime's form, or offers the waitlist if nothing is open. The button reads the number of days from GameTime's own refusal message, and if you tap it on GameTime's "Booking Not Available" page it tells you when that court opens.
What's booked, at a glance. In the GameTime button's Courts tab, "Everything (grid)" shows every court and time for the day: open (tap to book), taken, yours, a lesson or a club event. Each time you tap the button it also saves the coming week's free slots to the app (POST /api/gametime with open; free or taken only, never who booked), at most every 20 minutes.
Booking through a chat assistant. The connector has find_open_courts (from the slots the button last saved, with a GameTime booking link for each), court_booking_link (a link to the booking form for a court and time, or the nearest open court), and list_regular_times, add_regular_time and remove_regular_time. An assistant can't finish a booking: GameTime makes the member complete it (it has a robot check), so the assistant hands over the link and the member taps Book.
Dinner after tennis. Tap the same bookmark on seattletennisclub.org while signed in. It asks Tennis Hub for your coming court times (POST /api/gametime with list: true), then reads the club's dining availability (/api/v1/dining/...) and shows Canoe Club and Club Grill times from half an hour after you finish, for your party size. Reserve on the club's dining page.
Removing a booking here does not cancel it with the club. Do that in GameTime. Notes on how GameTime's court sheet works, for a later direct hookup, are in docs/gametime.md.
A tapped string bed rings like a drum skin at one main pitch, usually 450–700 Hz. The app finds the lowest clear peak with an FFT (so an off-centre tap's louder overtone isn't mistaken for it), then computes the tension left in the strings from the head size and the string's weight per metre, using the oval-membrane formula that Cross & Bower (Sports Engineering, 2001) checked against a load cell. The big number converts that to stringing-machine terms using the typical 30% (nylon) to 40% (polyester) drop after stringing; calibrating replaces the typical drop with your stringer's. The model and pitch detection live in public/tension.js and are tested in test/tension.test.js.
npm install
npm run dev # http://localhost:3000, in-memory Postgres and a local stand-in for Neon Auth
npm test # API tests: accounts, per-player data, sync, upgrades
npm run build # checks the page, service worker and API routes load
npm run dev reads .env.local. With NEON_AUTH_BASE_URL set there, it uses your real Neon Auth; add http://localhost:3000 as a trusted domain first. With USE_DATABASE_URL=1, it uses your real database.
| Path | What it is |
|---|---|
public/index.html | The whole front end: no build step, no libraries |
public/sw.js | Offline cache. Bump VERSION when you change a file in public/. |
api/auth.js | /api/auth/* forwards to Neon Auth (Better Auth endpoints such as sign-up/email, sign-in/email, sign-out, get-session), so session cookies stay on this site |
api/sync.js | POST /api/sync {ops}: applies the signed-in player's queued changes in one transaction and returns their rackets |
api/mcp.js | Remote MCP server for chat assistants (/api/mcp with OAuth or a Bearer API token, or /api/mcp/<token>); tools in lib/assistant.js |
api/tokens.js | Make, list and revoke API tokens and links, and disconnect OAuth apps (signed-in player); only a hash of each secret is stored (lib/tokens.js) |
api/oauth.js | OAuth 2.1 for the connector: metadata, client registration, authorize and token (lib/oauth.js) |
lib/users.js | The users table: one row per Neon Auth account, upserted on every signed-in request |
lib/auth.js | Neon Auth proxy and session check (@neondatabase/auth/server) |
lib/db.js | node-postgres pool, attached to Vercel Fluid compute |
lib/store.js | Validation and SQL for each change, all scoped to the player |
lib/schema.js | Tables, created on first use. Sign-in lives in Neon Auth's neon_auth schema; users mirrors each account and every table with a user_id has a foreign key to it (on delete cascade). |
scripts/local-auth.js | Open-source Better Auth in memory, standing in for Neon Auth in dev and tests |
Changes are queued on the device and sent in order. Each reading has an id made on the phone, so a retry never saves it twice. A change aimed at another player's racket does nothing.