Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d336156bbb | |||
| e1b9e97484 | |||
| ddac06d68f | |||
| 5a5e45b685 | |||
| 77f00db037 | |||
| 50a60b29e7 |
@@ -0,0 +1,37 @@
|
|||||||
|
name: Docker Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: gitea.shcizo.se
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.PACKAGES_TOKEN }}
|
||||||
|
|
||||||
|
- uses: docker/metadata-action@v5
|
||||||
|
id: meta
|
||||||
|
with:
|
||||||
|
images: gitea.shcizo.se/shcizo/pdga-rating
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
@@ -1,52 +1,13 @@
|
|||||||
name: Release Please
|
# Deprecated: replaced by .gitea/workflows/docker-build.yml (tag-trigger).
|
||||||
|
# Origin moved from GitHub to Gitea; release-please-action and ghcr.io are
|
||||||
|
# GitHub-only and don't run on Gitea Actions. Trigger changed to manual-only
|
||||||
|
# so neither GitHub nor Gitea Actions will auto-run this file.
|
||||||
|
name: Release Please (deprecated)
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_dispatch:
|
||||||
branches: [main]
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-please:
|
noop:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
|
||||||
release_created: ${{ steps.release.outputs.release_created }}
|
|
||||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
|
||||||
steps:
|
steps:
|
||||||
- uses: googleapis/release-please-action@v4
|
- run: echo "Deprecated. See .gitea/workflows/docker-build.yml"
|
||||||
id: release
|
|
||||||
with:
|
|
||||||
release-type: node
|
|
||||||
|
|
||||||
docker:
|
|
||||||
needs: release-please
|
|
||||||
if: ${{ needs.release-please.outputs.release_created }}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- uses: docker/metadata-action@v5
|
|
||||||
id: meta
|
|
||||||
with:
|
|
||||||
images: ghcr.io/${{ github.repository }}
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}},value=${{ needs.release-please.outputs.tag_name }}
|
|
||||||
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.tag_name }}
|
|
||||||
type=raw,value=latest
|
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
".": "1.2.0"
|
|
||||||
}
|
|
||||||
@@ -4,13 +4,14 @@ PDGA rating scraper and display app. Scrapes player ratings and course data from
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Runtime:** Node.js 18 (Alpine in Docker)
|
- **Runtime:** Node.js 22 (slim/Debian-based in Docker)
|
||||||
|
- **Hosting:** Gitea (`gitea.shcizo.se/shcizo/pdga-rating`) — use `tea` CLI for issues/PRs, not `gh`
|
||||||
- **Server:** Express with EJS templates
|
- **Server:** Express with EJS templates
|
||||||
- **Database:** SQLite3 (file: `ratings.db`, Docker: `/app/data/ratings.db`)
|
- **Database:** SQLite3 (file: `ratings.db`, Docker: `/app/data/ratings.db`)
|
||||||
- **Frontend:** HTMX + vanilla JS (in `public/js/`)
|
- **Frontend:** HTMX + vanilla JS (in `public/js/`)
|
||||||
- **Scraping:** Puppeteer (with stealth plugin) + direct HTTP
|
- **Scraping:** Puppeteer (with stealth plugin) + direct HTTP
|
||||||
- **Logging:** Pino (JSON in production, pino-pretty in dev)
|
- **Logging:** Pino (JSON in production, pino-pretty in dev)
|
||||||
- **CI/CD:** Release Please + Docker build/push to GHCR
|
- **CI/CD:** Gitea Actions (tag-triggered docker build/push to `gitea.shcizo.se/shcizo/pdga-rating`)
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -38,15 +39,25 @@ public/
|
|||||||
- `LOG_LEVEL=debug npm start` — Enable debug logging
|
- `LOG_LEVEL=debug npm start` — Enable debug logging
|
||||||
- `docker compose up` — Run via Docker
|
- `docker compose up` — Run via Docker
|
||||||
|
|
||||||
|
**No test framework or lint setup** — `package.json` has only `start` and `dev` scripts. If adding either, document it here.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Logging:** Use `require('./logger')` (or relative path). Never use `console.log/error` in backend code. Use appropriate Pino levels: `debug` for verbose/diagnostic data, `info` for operational status, `warn` for retries/degraded state, `error` for failures, `fatal` for startup crashes.
|
- **Logging:** Use `require('./logger')` (or relative path). Never use `console.log/error` in backend code. Use appropriate Pino levels: `debug` for verbose/diagnostic data, `info` for operational status, `warn` for retries/degraded state, `error` for failures, `fatal` for startup crashes.
|
||||||
- **Frontend JS:** `console.error` is fine in `public/js/` — runs in browser, no Pino.
|
- **Frontend JS:** `console.error` is fine in `public/js/` — runs in browser, no Pino.
|
||||||
- **Commits:** Conventional commits (`feat:`, `fix:`, `refactor:`, `chore:`) — drives release-please.
|
- **Commits:** Conventional commits (`feat:`, `fix:`, `refactor:`, `chore:`, `ci:`).
|
||||||
|
- **Releases:** Manual version bump — edit `version` in `package.json` + `package-lock.json`, commit as `<version>`, tag `v<version>`, push commit + tag (`git push origin main v<version>`). Triggers `.gitea/workflows/docker-build.yml` which builds and pushes the image. Auth uses repo secret `PACKAGES_TOKEN` (PAT with `write:package`) — the auto-injected `GITEA_TOKEN` does not have effective registry access.
|
||||||
- **Scraping:** Two strategies per entity: direct HTTP (fast, preferred) with Puppeteer fallback (stealth plugin for anti-bot). Rate limiting must be respected.
|
- **Scraping:** Two strategies per entity: direct HTTP (fast, preferred) with Puppeteer fallback (stealth plugin for anti-bot). Rate limiting must be respected.
|
||||||
- **Database:** Migrations run automatically on startup in `db.js`. Schema changes go there.
|
- **Database:** Migrations run automatically on startup in `db.js`. Schema changes go there.
|
||||||
- **Templates:** EJS with shared layout in `views/partials/`. Pages use HTMX for dynamic content loading.
|
- **Templates:** EJS with shared layout in `views/partials/`. Pages use HTMX for dynamic content loading.
|
||||||
|
|
||||||
|
## PDGA Domain Notes
|
||||||
|
|
||||||
|
- **Rating publication cycle:** PDGA officially recalculates ratings on the **second Tuesday of each month**. `getNextPDGAUpdateDate()` in `src/services/rating-calculator.js` computes this — round filtering uses it as cutoff.
|
||||||
|
- **Predicted rating algorithm:** `calculatePredictedRating(roundRatings)` replicates PDGA's formula — 12-mo window (expands to 24 if <8 rounds), outlier removal at ≥7 rounds (2.5σ + 100pt threshold), double-weighting of recent 25% at ≥9 rounds. Returns `{rating, stdDev, debugLog}`.
|
||||||
|
- **Rate limits:** `POST /api/refresh-round-history/:pdgaNumber` enforces a 24h cooldown per player (`src/routes/players.js`). Don't bypass — PDGA's site rate-limits aggressively.
|
||||||
|
- **Round history refresh** uses Puppeteer (stealth plugin), other scraping prefers direct HTTP. Predicted rating is recomputed and stored on each refresh.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pdga-ratings",
|
"name": "pdga-ratings",
|
||||||
"version": "1.2.0",
|
"version": "1.2.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pdga-ratings",
|
"name": "pdga-ratings",
|
||||||
"version": "1.2.0",
|
"version": "1.2.3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ejs": "^4.0.1",
|
"ejs": "^4.0.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pdga-ratings",
|
"name": "pdga-ratings",
|
||||||
"version": "1.2.0",
|
"version": "1.2.3",
|
||||||
"description": "PDGA rating scraper and display",
|
"description": "PDGA rating scraper and display",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"packages": {
|
|
||||||
".": {
|
|
||||||
"release-type": "node",
|
|
||||||
"changelog-sections": [
|
|
||||||
{ "type": "feat", "section": "Features" },
|
|
||||||
{ "type": "fix", "section": "Bug Fixes" },
|
|
||||||
{ "type": "chore", "section": "Miscellaneous" },
|
|
||||||
{ "type": "refactor", "section": "Code Refactoring" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
const { getLastRefresh } = require('../models/player');
|
const { getLastRefresh } = require('../models/player');
|
||||||
|
const { getNextPDGAUpdateDate } = require('./rating-calculator');
|
||||||
const logger = require('../logger');
|
const logger = require('../logger');
|
||||||
|
|
||||||
|
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||||
|
|
||||||
function formatRelative(isoString) {
|
function formatRelative(isoString) {
|
||||||
if (!isoString) return 'Never';
|
if (!isoString) return 'Never';
|
||||||
const then = new Date(isoString.replace(' ', 'T') + (isoString.endsWith('Z') ? '' : 'Z'));
|
const then = new Date(isoString.replace(' ', 'T') + (isoString.endsWith('Z') ? '' : 'Z'));
|
||||||
@@ -18,25 +22,18 @@ function formatRelative(isoString) {
|
|||||||
return then.toISOString().slice(0, 10);
|
return then.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
// First Tuesday of next month — approximation of PDGA's monthly cycle
|
function formatNextUpdate(date) {
|
||||||
function computeNextUpdate(now = new Date()) {
|
return `${DAY_NAMES[date.getDay()]} ${date.getDate()} ${MONTH_NAMES[date.getMonth()]}`;
|
||||||
const year = now.getUTCFullYear();
|
|
||||||
const month = now.getUTCMonth() + 1; // next month, may roll over
|
|
||||||
const candidate = new Date(Date.UTC(month === 12 ? year + 1 : year, month === 12 ? 0 : month, 1));
|
|
||||||
// 0=Sun, 1=Mon, 2=Tue
|
|
||||||
const offset = (2 - candidate.getUTCDay() + 7) % 7;
|
|
||||||
candidate.setUTCDate(1 + offset);
|
|
||||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
||||||
return `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][candidate.getUTCDay()]} ${candidate.getUTCDate()} ${months[candidate.getUTCMonth()]}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTopbarLocals() {
|
async function getTopbarLocals() {
|
||||||
|
const nextUpdate = formatNextUpdate(getNextPDGAUpdateDate());
|
||||||
try {
|
try {
|
||||||
const lastIso = await getLastRefresh();
|
const lastIso = await getLastRefresh();
|
||||||
return { lastRefresh: formatRelative(lastIso), nextUpdate: computeNextUpdate() };
|
return { lastRefresh: formatRelative(lastIso), nextUpdate };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn({ err }, 'topbar locals fallback');
|
logger.warn({ err }, 'topbar locals fallback');
|
||||||
return { lastRefresh: 'Unknown', nextUpdate: computeNextUpdate() };
|
return { lastRefresh: 'Unknown', nextUpdate };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user