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로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
เว็บตารางแข่ง Formula 1 ภาษาไทย — สนามถัดไป นับถอยหลังรายเซสชัน ผลการแข่ง ตารางคะแนน และปฏิทินทั้งฤดูกาล แสดงเป็นเวลาไทย (GMT+7) หรือเวลาท้องถิ่นของสนามก็ได้ สร้างด้วย Next.js (App Router) + Tailwind CSS v4 · เป็น server component เกือบทั้งหมด และ deploy เป็น static/ISR ไม่ต้องมีฐานข้อมูล ต้องใช้ Node 20.9 ขึ้นไป (.nvmrc ตั้งไว้ที่ 22 — nvm use ได้เลย)README 전문 보기 ↗
เว็บตารางแข่ง Formula 1 ภาษาไทย — สนามถัดไป นับถอยหลังรายเซสชัน ผลการแข่ง ตารางคะแนน และปฏิทินทั้งฤดูกาล แสดงเป็นเวลาไทย (GMT+7) หรือเวลาท้องถิ่นของสนามก็ได้
สร้างด้วย Next.js (App Router) + Tailwind CSS v4 · เป็น server component เกือบทั้งหมด และ deploy เป็น static/ISR ไม่ต้องมีฐานข้อมูล
ต้องใช้ Node 20.9 ขึ้นไป (.nvmrc ตั้งไว้ที่ 22 — nvm use ได้เลย)
npm install
npm run dev # http://localhost:3000
เรื่อง lockfile:
package-lock.jsonถูกสร้างด้วย npm 11+ ซึ่งเขียนฟิลด์libcให้ optional dependency ที่เป็น native binary ถ้ารันnpm installด้วย npm 10 มันจะตัดฟิลด์พวกนั้นทิ้ง (38 จุด) กลายเป็น diff ที่ไม่ได้ตั้งใจ เจอแบบนั้นให้git checkout package-lock.jsonแล้วอัปเกรด npm ก่อน · CI ใช้npm ciซึ่งไม่แตะ lockfile เลย จึงไม่มีปัญหานี้
| สคริปต์ | ทำอะไร |
|---|---|
npm run dev | dev server |
npm run build | build โปรดักชัน (prerender หน้าส่วนใหญ่) |
npm start | รันผลลัพธ์จาก build |
npm run lint | ESLint |
npm test | ยูนิตเทสต์ (Vitest) |
npm run test:watch | เทสต์แบบ watch |
CI รัน npm ci → tsc --noEmit → lint → test → build ทุก PR
(.github/workflows/ci.yml) รันชุดเดียวกันในเครื่องได้ก่อน push
เทสต์อยู่ใน tests/ ครอบเฉพาะตรรกะที่ pure — retry/คิวใน lib/http.ts,
การห่อบรรทัด ICS, ฟังก์ชันเวลาและหน้าต่างรอบการแข่งใน lib/f1.ts,
การคำนวณโควตา/โทษกริดใน lib/power-units.ts และตัวแยกตาราง PDF ของ FIA
ส่วนที่ต้องต่อเน็ตจริงไม่ได้เทสต์ (mock fetch เอาเฉพาะพฤติกรรม retry)
ทั้งสองตัวเป็น optional — ไม่ตั้งก็รันได้
| ตัวแปร | ค่าเริ่มต้น | ใช้ทำอะไร |
|---|---|---|
NEXT_PUBLIC_SITE_URL | โดเมน production ของ Vercel · ไม่งั้น http://localhost:3000 | metadataBase สำหรับ OG image, sitemap.xml และ robots.txt |
NEXT_PUBLIC_SEASON | ปีปัจจุบัน | ล็อกฤดูกาลที่แสดง มีประโยชน์ช่วงต้นปีที่ Jolpica ยังไม่ปล่อยปฏิทินปีใหม่ (จะได้ค้างปีเก่าไว้ก่อน) และใช้ทดสอบการข้ามปี |
SITE_URL (lib/site.ts) ไล่หาตามลำดับนี้
NEXT_PUBLIC_SITE_URL ถ้าตั้งไว้ — ใช้เมื่อมีโดเมนของตัวเอง หรือ deploy ที่อื่นVERCEL_PROJECT_PRODUCTION_URL ที่ Vercel ใส่ให้เองทุก deploy — deploy บน Vercel
จึงไม่ต้องตั้งอะไรเลย ค่าที่ได้เป็นโดเมนเปล่า โค้ดเติม https:// ให้http://localhost:3000 ตอน devเผลอใส่ค่ามาไม่มี protocol ก็เติม https:// ให้ และตัด / ท้ายทิ้ง — new URL()
ใน metadataBase จะได้ไม่ throw ทั้ง build เพราะพิมพ์ตกตัวเดียว
SEASONถูกคำนวณครั้งเดียวตอนโหลดโมดูล เซิร์ฟเวอร์ที่รันค้างข้ามปีจะยังเห็นปีเก่า จนกว่าจะ restart — ถ้าเจอเคสนี้ให้ตั้งNEXT_PUBLIC_SEASONแล้ว redeploy
เว็บนี้ไม่มี backend ของตัวเอง ดึงสดจาก 4 แหล่ง แล้วพึ่ง ISR cache ของ Next เป็นหลัก
| แหล่ง | ใช้ทำอะไร | อยู่ที่ |
|---|---|---|
| Jolpica-F1 (Ergast ตัวสืบทอด) | ปฏิทิน ผลการแข่ง ควอลิฟาย สปรินต์ ตารางคะแนน | lib/f1.ts |
| openf1.org | ไทม์มิ่งรายรอบสำหรับหน้ารีเพลย์ + รูป headshot ทางการ | lib/replay.ts, lib/drivers.ts |
| Wikipedia REST API | ผังสนาม และรูปนักแข่ง (fallback) | lib/f1.ts, lib/drivers.ts |
| เอกสาร PDF ของ FIA | ยอดใช้ชิ้นส่วน power unit และคำตัดสินโทษกริด | lib/pu-parse.ts |
ทุกตัวมี rate limit และล่มได้ ฟังก์ชันดึงข้อมูลเลย ไม่ throw หลุดขึ้นไปถึงหน้าเว็บ — คืนค่าว่างหรือสแนปช็อตแทน แล้วให้หน้าเว็บตัดสินใจว่าจะแสดงอะไร
lib/schedule-fallback.ts และ lib/pu-fallback.ts เป็นข้อมูลนิ่งที่ commit ไว้ในรีโป
ใช้เมื่อต้นทางล่ม (ปฏิทินทั้งฤดูกาล และยอดใช้ชิ้นส่วนล่าสุด) หน้า power units จะขึ้นป้าย
บอกผู้ใช้เองเมื่อกำลังอ่านจากสแนปช็อตแทนของสด
สแนปช็อต PU สร้างจาก fetchPuData() ใน lib/pu-parse.ts — ไฟล์นั้นจงใจไม่ import อะไร
ของ Next เพื่อให้รันด้วย node ตรง ๆ ได้ อย่าเพิ่มการพึ่งพา Next เข้าไป
openf1 บล็อก IP ของ serverless (Vercel) แต่เปิด CORS ให้ ข้อมูลรีเพลย์เลยดึง จากฝั่งเบราว์เซอร์ ไม่ใช่ฝั่งเซิร์ฟเวอร์ และคำขอทุกตัวต่อคิวกันเส้นเดียวเว้นระยะ ~750ms เพราะ openf1 แบบไม่มี API key จำกัดราว 1 req/วินาที รองรับเฉพาะฤดูกาล 2023 ขึ้นไป
app/ หน้าเว็บ (App Router) — ทุกไฟล์เป็น server component ถ้าไม่เขียน "use client"
page.tsx สนามถัดไป + นับถอยหลัง + ผลล่าสุด
results/ ผลการแข่งทั้งฤดูกาล
standings/ ตารางคะแนน + กราฟแต้มสะสม + เครื่องคำนวณลุ้นแชมป์
calendar/ ปฏิทินทั้งฤดูกาล
calendar.ics/ ฟีด .ics ทุก session (route handler)
api/widget/ JSON ก้อนเล็กสำหรับ widget บนมือถือ
race/[round]/ รายละเอียดสนาม (ตาราง ผัง ผล ควอลิฟาย)
race/[round]/replay/ รีเพลย์ไทม์มิ่งรอบต่อรอบ
driver/[id]/ โปรไฟล์นักแข่ง + เทียบเพื่อนร่วมทีม
constructor/[id]/ โปรไฟล์ทีม
power-units/ ยอดใช้ชิ้นส่วนเครื่องยนต์และโทษกริด
compare/ เทียบนักขับสองคน (?a=&b=)
pitstops/ สถิติพิทสต็อปทั้งฤดูกาล
reaction/ เกมวัดรีแอคชันตอนไฟดับ
components/ UI ที่ใช้ซ้ำ (client component ส่วนใหญ่อยู่ที่นี่)
lib/ ดึงข้อมูล แปลงข้อมูล และตารางค่าคงที่
public/ ไฟล์สาธารณะ · scriptable-widget.js = widget สำหรับ iPhone
tests/ ยูนิตเทสต์ (Vitest)
formatInTz() ใน lib/f1.ts เท่านั้น (ล็อก ca-gregory กันเลขปี
กลายเป็น พ.ศ.) ถ้าต้องแสดงเวลาที่สลับโซนตามที่ผู้ใช้เลือกได้ ให้ใช้ <LocalTime>
ซึ่ง SSR เป็นเวลาไทยเสมอแล้วค่อยสลับฝั่ง client — กัน hydration mismatchexport const revalidate ของตัวเอง และแต่ละ fetch
ตั้ง revalidate ของตัวเองอีกชั้น ข้อมูลที่นิ่งแล้ว (แต้มสะสมย้อนหลัง) แคชยาวได้เป็นสัปดาห์fetchRetry() ใน lib/http.ts ที่เดียว
retry เฉพาะ 429/5xx ส่วน 4xx อื่นเลิกทันที ผู้เรียกแค่ปรับจำนวนครั้งกับ backoff
ของตัวเอง อย่าเขียน retry loop ใหม่ — เคยมี 3 ก๊อปแล้วสองตัวมีบั๊กเดียวกันlib/f1.ts ไม่ใช่ weekday ของ Intl
เพราะ ICU ของ Node กับเบราว์เซอร์ให้คนละคำ (อาทิตย์ vs อา.) แล้ว hydration พัง@media (prefers-reduced-motion: reduce)
ที่ท้าย app/globals.css/api/widget คืน JSON ก้อนเล็ก (~2 KB ส่วนใหญ่เป็นจุดผังสนาม) สำหรับเอาไปทำ widget บนหน้าโฮม —
เปิด CORS ไว้ (Access-Control-Allow-Origin: *) เพราะเป็นข้อมูลสาธารณะอ่านอย่างเดียว
{
"season": 2026,
"state": "upcoming", // upcoming · live · season-over · no-calendar
"generatedAt": "…",
"race": {
"round": "15", "name": "Azerbaijan Grand Prix",
"short": "Azerbaijan", // ชื่อประเทศแบบแอป F1 · ประเทศที่มีหลายสนามใช้ชื่อเมือง (Miami, Las Vegas)
"flag": "🇦🇿", "circuit": "…", "circuitId": "baku", "locality": "Baku", "country": "Azerbaijan",
"startsAt": "…", "isSprint": false,
"track": { "w": 100, "h": 57.4, "pts": [12.3, 40.1, …] } // ผังสนามย่อ ≤ 64 จุด (x,y สลับกัน)
},
"session": { "code": "FP1", "label": "ซ้อม 1", "startsAt": "…", "endsAt": "…" },
"sessions": [ /* ทุก session ของสุดสัปดาห์ รูปเดียวกับ session */ ],
"leader": { "name": "M. Verstappen", "points": 400, "constructorId": "red_bull" },
"top3": [ { "code": "VER", "name": "M. Verstappen", "points": 400, "constructorId": "red_bull" }, … ],
"lastRace": { "round": "14", "short": "Spain", "flag": "🇪🇸", "startsAt": "…",
"podium": [ { "code": "HAM", "name": "L. Hamilton", "constructorId": "ferrari" }, … ] },
"showPodium": false // true ช่วง 3.5 วันหลังเรซ → widget โชว์โพเดียมเป็นหลัก
}
เวลาเป็น ISO (UTC) ทั้งหมด ให้ฝั่ง widget ฟอร์แมตตามเครื่องผู้ใช้เอง
https://<โดเมน>/widget.js ใน Safari แล้วก็อปทั้งหมดF1
(ถ้าเป็นโดเมนอื่น แก้ค่า SITE บรรทัดบน ๆ ของทั้งสองไฟล์)F1ก็อปครั้งเดียวจบ — public/widget.js เป็นแค่ตัวโหลด (~40 บรรทัด)
ทุกครั้งที่ widget รีเฟรชมันดึง public/scriptable-widget.js
ตัวล่าสุดจากเว็บมารันเอง แก้ widget แล้ว deploy ผู้ใช้ก็ได้ของใหม่ทันที ต่อเน็ตไม่ได้ก็ใช้
ชุดล่าสุดที่เก็บไว้ในเครื่อง และถ้าเว็บตอบเป็นหน้า error จะไม่เอามารัน (ตรวจ status 200 และ
ต้องมี Script.setWidget ในโค้ด — อย่าลบบรรทัดนั้นออกจาก scriptable-widget.js)
รองรับทุกขนาด: small · medium · large และบนหน้าจอล็อก (แถบ · วงกลม · บรรทัดเดียว)
FP1 SQ SPRINT Q RACE) และวันเวลาตามเขตเวลาเครื่องDrawContext (ไม่โหลดรูป)LIVE / SPRINTนับถอยหลังใช้
applyTimerStyle()ให้ iOS เดินเลขเอง ไม่ใช่วาดตัวเลขค้างไว้ — ระบบคุมรอบรีเฟรชของ widget เอง (ราว 15 นาทีขึ้นไป บังคับไม่ได้) ถ้าวาดเองเลขจะค้าง ส่วนrefreshAfterDateเป็นแค่คำขอ ไม่ใช่คำสั่ง
เทสต์ของสคริปต์นี้อยู่ใน tests/scriptable-widget.test.ts — รันไฟล์จริงด้วย Scriptable API
จำลอง แล้วตรวจสิ่งที่วาดออกมาในทุกสถานะ รวมถึงตอนต่อเน็ตไม่ได้
ไม่เกี่ยวข้องกับ Formula 1 อย่างเป็นทางการ · F1 และ Formula 1 เป็นเครื่องหมายการค้าของ Formula One Licensing B.V.