OpenAthlete
AGPLv3 endurance training platform: EU-oriented hosting, transparent CTL/ATL/TSB logic in code, self-hosting, and full export. Built in Grenoble.
翻譯準備中
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 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.