mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
Migrate SRS to Cloudflare Worker: D1 state, email digest, review issues, live charts
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "gate pass rate",
|
||||
"message": "no gates yet",
|
||||
"color": "lightgrey"
|
||||
}
|
||||
@@ -1,563 +0,0 @@
|
||||
{
|
||||
"problems": {
|
||||
"1": {
|
||||
"issue": 29,
|
||||
"topic": 3,
|
||||
"difficulty": "easy",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"4": {
|
||||
"issue": 66,
|
||||
"topic": 7,
|
||||
"difficulty": "hard",
|
||||
"set": "deferred",
|
||||
"stage": "new",
|
||||
"next_review": "2026-09-29",
|
||||
"defer_until": "2026-09-29",
|
||||
"history": []
|
||||
},
|
||||
"11": {
|
||||
"issue": 44,
|
||||
"topic": 5,
|
||||
"difficulty": "medium",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"15": {
|
||||
"issue": 43,
|
||||
"topic": 5,
|
||||
"difficulty": "medium",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"33": {
|
||||
"issue": 60,
|
||||
"topic": 7,
|
||||
"difficulty": "medium",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"42": {
|
||||
"issue": 49,
|
||||
"topic": 5,
|
||||
"difficulty": "hard",
|
||||
"set": "deferred",
|
||||
"stage": "new",
|
||||
"next_review": "2026-09-28",
|
||||
"defer_until": "2026-09-28",
|
||||
"history": []
|
||||
},
|
||||
"49": {
|
||||
"issue": 30,
|
||||
"topic": 3,
|
||||
"difficulty": "medium",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"52": {
|
||||
"issue": 156,
|
||||
"topic": 22,
|
||||
"difficulty": "hard",
|
||||
"set": "deferred",
|
||||
"stage": "new",
|
||||
"next_review": "2026-10-03",
|
||||
"defer_until": "2026-10-03",
|
||||
"history": []
|
||||
},
|
||||
"72": {
|
||||
"issue": 171,
|
||||
"topic": 24,
|
||||
"difficulty": "medium",
|
||||
"set": "deferred",
|
||||
"stage": "new",
|
||||
"next_review": "2026-10-03",
|
||||
"defer_until": "2026-10-03",
|
||||
"history": []
|
||||
},
|
||||
"76": {
|
||||
"issue": 57,
|
||||
"topic": 6,
|
||||
"difficulty": "hard",
|
||||
"set": "deferred",
|
||||
"stage": "new",
|
||||
"next_review": "2026-09-28",
|
||||
"defer_until": "2026-09-28",
|
||||
"history": []
|
||||
},
|
||||
"84": {
|
||||
"issue": 76,
|
||||
"topic": 9,
|
||||
"difficulty": "hard",
|
||||
"set": "deferred",
|
||||
"stage": "new",
|
||||
"next_review": "2026-09-30",
|
||||
"defer_until": "2026-09-30",
|
||||
"history": []
|
||||
},
|
||||
"97": {
|
||||
"issue": 172,
|
||||
"topic": 24,
|
||||
"difficulty": "medium",
|
||||
"set": "deferred",
|
||||
"stage": "new",
|
||||
"next_review": "2026-10-04",
|
||||
"defer_until": "2026-10-04",
|
||||
"history": []
|
||||
},
|
||||
"121": {
|
||||
"issue": 50,
|
||||
"topic": 6,
|
||||
"difficulty": "easy",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"124": {
|
||||
"issue": 105,
|
||||
"topic": 13,
|
||||
"difficulty": "hard",
|
||||
"set": "deferred",
|
||||
"stage": "new",
|
||||
"next_review": "2026-09-30",
|
||||
"defer_until": "2026-09-30",
|
||||
"history": []
|
||||
},
|
||||
"125": {
|
||||
"issue": 41,
|
||||
"topic": 5,
|
||||
"difficulty": "easy",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"167": {
|
||||
"issue": 42,
|
||||
"topic": 5,
|
||||
"difficulty": "medium",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"212": {
|
||||
"issue": 129,
|
||||
"topic": 17,
|
||||
"difficulty": "hard",
|
||||
"set": "deferred",
|
||||
"stage": "new",
|
||||
"next_review": "2026-10-02",
|
||||
"defer_until": "2026-10-02",
|
||||
"history": []
|
||||
},
|
||||
"217": {
|
||||
"issue": 27,
|
||||
"topic": 3,
|
||||
"difficulty": "easy",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"221": {
|
||||
"issue": 173,
|
||||
"topic": 24,
|
||||
"difficulty": "medium",
|
||||
"set": "deferred",
|
||||
"stage": "new",
|
||||
"next_review": "2026-10-04",
|
||||
"defer_until": "2026-10-04",
|
||||
"history": []
|
||||
},
|
||||
"224": {
|
||||
"issue": 71,
|
||||
"topic": 8,
|
||||
"difficulty": "hard",
|
||||
"set": "deferred",
|
||||
"stage": "new",
|
||||
"next_review": "2026-09-29",
|
||||
"defer_until": "2026-09-29",
|
||||
"history": []
|
||||
},
|
||||
"238": {
|
||||
"issue": 37,
|
||||
"topic": 4,
|
||||
"difficulty": "medium",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"242": {
|
||||
"issue": 28,
|
||||
"topic": 3,
|
||||
"difficulty": "easy",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"269": {
|
||||
"issue": 145,
|
||||
"topic": 20,
|
||||
"difficulty": "hard",
|
||||
"set": "deferred",
|
||||
"stage": "new",
|
||||
"next_review": "2026-10-02",
|
||||
"defer_until": "2026-10-02",
|
||||
"history": []
|
||||
},
|
||||
"295": {
|
||||
"issue": 125,
|
||||
"topic": 16,
|
||||
"difficulty": "hard",
|
||||
"set": "deferred",
|
||||
"stage": "new",
|
||||
"next_review": "2026-10-01",
|
||||
"defer_until": "2026-10-01",
|
||||
"history": []
|
||||
},
|
||||
"303": {
|
||||
"issue": 36,
|
||||
"topic": 4,
|
||||
"difficulty": "easy",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"347": {
|
||||
"issue": 31,
|
||||
"topic": 3,
|
||||
"difficulty": "medium",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"383": {
|
||||
"issue": 32,
|
||||
"topic": 3,
|
||||
"difficulty": "easy",
|
||||
"set": "optional",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"392": {
|
||||
"issue": 45,
|
||||
"topic": 5,
|
||||
"difficulty": "easy",
|
||||
"set": "optional",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"502": {
|
||||
"issue": 126,
|
||||
"topic": 16,
|
||||
"difficulty": "hard",
|
||||
"set": "deferred",
|
||||
"stage": "new",
|
||||
"next_review": "2026-10-01",
|
||||
"defer_until": "2026-10-01",
|
||||
"history": []
|
||||
},
|
||||
"560": {
|
||||
"issue": 38,
|
||||
"topic": 4,
|
||||
"difficulty": "medium",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"704": {
|
||||
"issue": 58,
|
||||
"topic": 7,
|
||||
"difficulty": "easy",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"875": {
|
||||
"issue": 59,
|
||||
"topic": 7,
|
||||
"difficulty": "medium",
|
||||
"set": "core",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
},
|
||||
"977": {
|
||||
"issue": 46,
|
||||
"topic": 5,
|
||||
"difficulty": "easy",
|
||||
"set": "optional",
|
||||
"solved_on": "2026-08-24",
|
||||
"stage": "+2",
|
||||
"next_review": "2026-08-26",
|
||||
"history": [
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"kind": "first",
|
||||
"result": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"topics": {
|
||||
"3": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"4": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"5": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"6": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"7": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"8": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"9": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"10": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"11": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"12": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"13": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"14": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"15": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"16": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"17": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"18": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"19": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"20": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"21": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"22": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"23": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"24": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"25": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
},
|
||||
"26": {
|
||||
"misses": 0,
|
||||
"boost": false
|
||||
}
|
||||
},
|
||||
"drill_pool_used": [],
|
||||
"gates": [
|
||||
{
|
||||
"week": 2,
|
||||
"issue": 208,
|
||||
"date": "2026-08-24",
|
||||
"problems": [
|
||||
189,
|
||||
209,
|
||||
162,
|
||||
71,
|
||||
496
|
||||
],
|
||||
"fallbacks": [],
|
||||
"results": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
name: Gate
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 12 * * 6" # Saturday 8 AM ET — create this week's gate
|
||||
workflow_dispatch:
|
||||
issues:
|
||||
types: [closed] # grade a gate when it closes
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
# Shared with srs-scheduler / srs-logger / srs-setup: never race on srs.json.
|
||||
concurrency:
|
||||
group: srs-state
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
create:
|
||||
# Trigger A: the only issue-creating workflow in the SRS. The script
|
||||
# no-ops when this week's gate already exists.
|
||||
if: github.event_name != 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- run: bun scripts/srs-gate.ts create
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Commit state
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add .github/srs
|
||||
git diff --cached --quiet && { echo "no state change"; exit 0; }
|
||||
git commit -m "srs: create weekly gate [skip ci]"
|
||||
for i in 1 2 3; do git push && exit 0; git pull --rebase; done
|
||||
git push
|
||||
|
||||
close:
|
||||
# Trigger B: grade, log, boost, badge.
|
||||
if: >-
|
||||
github.event_name == 'issues' &&
|
||||
contains(github.event.issue.labels.*.name, 'gate')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- run: bun scripts/srs-gate.ts close
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Commit state
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add .github/srs docs/gate-log.md
|
||||
git diff --cached --quiet && { echo "no state change"; exit 0; }
|
||||
git commit -m "srs: grade gate #${{ github.event.issue.number }} [skip ci]"
|
||||
for i in 1 2 3; do git push && exit 0; git pull --rebase; done
|
||||
git push
|
||||
@@ -1,43 +0,0 @@
|
||||
name: SRS Logger
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
# Shared with srs-scheduler / gate / srs-setup: never race on srs.json.
|
||||
concurrency:
|
||||
group: srs-state
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
log:
|
||||
# Owner-only, and only on the daily brief or a gate issue. Put every
|
||||
# /done line in ONE comment — queued duplicate runs may be superseded.
|
||||
if: >-
|
||||
github.event.comment.user.login == github.repository_owner &&
|
||||
(github.event.issue.title == '📋 Today' ||
|
||||
contains(github.event.issue.labels.*.name, 'gate'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
- run: bun scripts/srs-logger.ts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PROJECT_PAT: ${{ secrets.PROJECT_PAT }}
|
||||
|
||||
- name: Commit state
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add .github/srs
|
||||
git diff --cached --quiet && { echo "no state change"; exit 0; }
|
||||
git commit -m "srs: log results from #${{ github.event.issue.number }} [skip ci]"
|
||||
for i in 1 2 3; do git push && exit 0; git pull --rebase; done
|
||||
git push
|
||||
@@ -1,30 +0,0 @@
|
||||
name: SRS Scheduler
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 10 * * *" # 6 AM ET — before the day starts
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
# Shared with srs-logger / gate / srs-setup: never race on srs.json.
|
||||
concurrency:
|
||||
group: srs-state
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
brief:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
# Stateless: reads srs.json + schedule.json, rewrites the pinned
|
||||
# 📋 Today issue, mirrors Target Date. Commits nothing.
|
||||
- run: bun scripts/srs-scheduler.ts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PROJECT_PAT: ${{ secrets.PROJECT_PAT }}
|
||||
@@ -1,29 +0,0 @@
|
||||
name: SRS Setup
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
# Shared with the other SRS workflows: never race on srs.json.
|
||||
concurrency:
|
||||
group: srs-state
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
|
||||
# Verifies PROJECT_PAT against the user project, creates the SRS
|
||||
# single-select fields when missing, and re-mirrors every laddered
|
||||
# problem's Target Date / SRS Stage / First Attempt. Idempotent.
|
||||
- run: bun scripts/srs-setup.ts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PROJECT_PAT: ${{ secrets.PROJECT_PAT }}
|
||||
@@ -37,3 +37,9 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
.blume/
|
||||
/docs/.wrangler/state/v3
|
||||
.blume-verify/
|
||||
|
||||
# api worker
|
||||
api/.dev.vars
|
||||
api/.wrangler/
|
||||
api/worker-configuration.d.ts
|
||||
api/node_modules/
|
||||
|
||||
@@ -2,27 +2,31 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `docs/`, and GitHub Actions automation that reconciles issues, publishes docs, and drives a spaced-repetition system (SRS). Issues model the curriculum: ~24 `topic` issues own ~161 `problem` sub-issues (`set:core|optional|deferred`, `diff:*`); milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
||||
LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues own ~161 `problem` sub-issues (`set:core|optional|deferred`, `diff:*`); milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
|
||||
|
||||
## Architecture & Data Flow
|
||||
|
||||
Two subsystems, one direction of truth:
|
||||
Three owners, one direction of truth:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
work[work/ solutions] -->|bun run sync| docs[docs/solutions/*.mdx]
|
||||
work -->|close-solved.yml| PI[problem issues]
|
||||
PI -->|close-topics.yml| TI[topic issues]
|
||||
SRS[.github/srs/srs.json] -->|srs-scheduler| Today[📋 Today issue]
|
||||
Today -->|/done comments| Logger[srs-logger] --> SRS
|
||||
SRS -->|mirror, best-effort| Proj[GitHub Project fields]
|
||||
SRS -->|gate.yml| Gate[weekly gate issue + docs/gate-log.md]
|
||||
GH[GitHub issues = catalog] -->|nightly reconcile| D1[(D1 = SRS state)]
|
||||
D1 -->|8 AM ET cron| Mail[digest email + one-tap links]
|
||||
Mail -->|GET /log| D1
|
||||
PI -->|/done webhook| D1
|
||||
D1 -->|best-effort mirror| Proj[GitHub Project fields]
|
||||
D1 -->|Sat midnight ET| Review[Review — Week N issue]
|
||||
D1 --> Charts[SVG charts + /api/stats]
|
||||
```
|
||||
|
||||
- **Reconcile, don't react.** `close-solved.ts`, `close-topics.ts`, and the SRS scripts recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional (nothing ever reopens).
|
||||
- **`.github/srs/srs.json` is the single source of truth.** Project fields (`Target Date`, `SRS Stage`, `First Attempt`) are a best-effort mirror via `scripts/srs-project.ts`: every GraphQL failure becomes a warning, never an error — an srs.json update must never be lost to a mirror failure. Topic rows' `Target Date` is never written (problem rows only).
|
||||
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `srs.ts`, FNV-1a → mulberry32, seed = date / ISO week), so running a workflow twice picks identical problems.
|
||||
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead.
|
||||
- **Reconcile, don't react.** `close-solved.ts`, `close-topics.ts`, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional.
|
||||
- **D1 owns SRS state; GitHub issues own the catalog; `api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). Project fields (`Target Date`, `SRS Stage`, `First Attempt`) are a best-effort mirror (`api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written.
|
||||
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
|
||||
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `api/src/srs.test.ts`.
|
||||
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
||||
|
||||
## Key Directories
|
||||
|
||||
@@ -31,8 +35,8 @@ flowchart LR
|
||||
| `work/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/Easy/Array/1.two-sum.py`. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
||||
| `scripts/` | Flat Bun TS: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import) |
|
||||
| `docs/` | Blume site. `docs/solutions/(<category>)/<num>-<slug>.mdx` generated by sync; `(algorithms)/`, `(data-structures)/` hand-written explainers |
|
||||
| `.github/srs/` | Machine state: `srs.json` (bot-committed), `schedule.json` (day → topic issue, human-edited), `badge.json` (shields endpoint) |
|
||||
| `.github/workflows/` | deploy, close-solved, close-topics, srs-scheduler, srs-logger, gate, srs-setup |
|
||||
| `api/` | Cloudflare Worker (own Bun workspace): `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/`, `scripts/import-srs.ts` |
|
||||
| `.github/workflows/` | deploy (docs), close-solved, close-topics — Worker deploys are manual (`bun run api:deploy`) |
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -43,38 +47,40 @@ bun run sync # work/ → docs/solutions/ pages
|
||||
bun run dev|build # Blume docs site
|
||||
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
||||
bun run close-topics -- --dry-run
|
||||
SRS_DRY=1 SRS_TODAY=2026-08-30 bun scripts/srs-scheduler.ts # preview a brief
|
||||
bun scripts/srs-logger.ts --issue N --author prdlk --body "/done 704 pass"
|
||||
bun scripts/srs-gate.ts create|close --issue N
|
||||
bun run api:dev # wrangler dev on :8787 (local D1); api:test = DST guard tests
|
||||
bun run api:deploy # deploy the Worker (manual by design)
|
||||
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
||||
"localhost:8787/admin/digest?dry=1&date=2026-08-30" # preview a digest, send nothing
|
||||
```
|
||||
|
||||
## Code Conventions & Common Patterns
|
||||
|
||||
- **Scripts are either entries or libraries.** Entries (`close-*.ts`, `srs-scheduler|logger|gate|setup.ts`) use shebang + top-level `await` and run their pipeline linearly. Libraries (`github.ts`, `report.ts`, `srs.ts`, `srs-project.ts`) must stay side-effect-free on import — nothing touches network/credentials until a factory (`github()`, `projectMirror()`) is awaited.
|
||||
- **Scripts are either entries or libraries.** Entries (`scripts/close-*.ts`) use shebang + top-level `await`. Libraries (`scripts/github.ts`, `scripts/report.ts`, everything in `api/src/`) are side-effect-free on import — nothing touches network/credentials until a factory (`github()`, `projectMirror()`) is called.
|
||||
- **Shared plumbing:** `github()` gives `repo`, `api()` (throws `METHOD path -> status body`), `list()` (paginating async generator), `closeIssue()` (comment **first**, then close — a failed PATCH still leaves a trace). `report.ts` gives `printTable`/`markdownTable`/`writeStepSummary`.
|
||||
- **Narrow API payloads with guards**, not inline casts: `if (!("number" in value) || typeof value.number !== "number") return;` (see `readProblemIssue` in `close-solved.ts`). Named-const casts only with a reason.
|
||||
- **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; SRS takes `SRS_DRY=1` plus overrides `SRS_TODAY`, `SRS_STATE`, `SRS_SCHEDULE`, `SRS_GATE_LOG`, `SRS_BADGE` — point `SRS_STATE` at a scratch copy to exercise logic safely.
|
||||
- **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `srs.ts`) to dodge DST. Never `new Date()` math directly.
|
||||
- **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; Worker admin routes take `?dry=1&force=1&date=` (the `SRS_TODAY` equivalent) and `wrangler dev` runs against local D1 — exercise logic there before touching production state.
|
||||
- **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `api/src/srs.ts`) to dodge DST. Never `new Date()` math directly.
|
||||
- **Style:** section-divider comments (`// ── name ───`), file-top doc comments explaining the *why* and invariants, 2-space JSON with trailing newline, `Map` for dynamic keys / `Record` for static tables, no tiny one-expression wrapper functions.
|
||||
- **Known intentional duplication:** `close-solved.ts` re-implements header stripping instead of importing from `sync.ts` because `sync.ts` runs its pipeline on import. Don't "deduplicate" it.
|
||||
|
||||
## Important Files
|
||||
|
||||
- `scripts/srs.ts` — SRS domain: ladder (`new → +2 → +5 → +10 → retired`; fail resets to `+2`), `fetchCatalog()` (walks topic sub-issues, parses `LC <n> · <name> · <diff> · <set>` titles), campaign math (`CAMPAIGN_START`).
|
||||
- `api/src/srs.ts` — SRS domain: ladder (`new → +2 → +5 → +10 → retired`; fail resets to `+2`; first-ever log enters at `+2`), ET date math, seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here).
|
||||
- `api/src/index.ts` — router + cron dispatch; `api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`.
|
||||
- `scripts/sync.ts` — work→docs contract: only `## Solution` onward is script-owned on existing pages; human prose is never touched; never hand-write solution pages or edit inside `## Solution`.
|
||||
- `.github/srs/srs.json`, `docs/gate-log.md`, `.github/srs/badge.json` — **bot-committed** (`github-actions[bot]`, `[skip ci]`). Don't hand-edit; fix via scripts.
|
||||
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`islands/ProgressDashboard.tsx`).
|
||||
- `blume.config.ts` — site base `/leetcode`; `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`).
|
||||
- Workflows share concurrency group `srs-state` (never race on srs.json); `PROJECT_PAT` secret is required for user-Project GraphQL (default `GITHUB_TOKEN` cannot), used by srs-scheduler/logger/setup only.
|
||||
- Old `.github/srs/` JSON state and the `srs-*` Actions are RETIRED — do not resurrect; `api/scripts/import-srs.ts` documents the migration.
|
||||
|
||||
## Runtime/Tooling Preferences
|
||||
|
||||
- **Bun only**: run scripts with `bun scripts/<name>.ts`, install with `bun install --frozen-lockfile`; `bun.lock` is the sole lockfile. Node 22 appears only in `deploy.yml` because Blume requires it.
|
||||
- **No tsconfig, no ESLint, no Prettier** — match surrounding style by hand. `@types/bun` is the only type dependency.
|
||||
- Automation workflows skip `bun install` (scripts use Bun builtins + `fetch` only) — keep new automation dependency-free.
|
||||
- **Bun only**: run scripts with `bun scripts/<name>.ts`, install with `bun install --frozen-lockfile`; `api/` is its own workspace with its own lockfile. Node 22 appears only in `deploy.yml` because Blume requires it.
|
||||
- **No root tsconfig, no ESLint, no Prettier** — match surrounding style by hand. `api/` has a strict `tsconfig.json`; `Env` types are generated (`bunx wrangler types`), never hand-written.
|
||||
- **Zero runtime npm dependencies in automation and the Worker** — SVG, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only.
|
||||
- Local GitHub auth falls back to `gh auth token`; scripts run fine outside Actions.
|
||||
|
||||
## Testing & QA
|
||||
|
||||
- **No test framework, no `*.test.*` files** — `bun run test` submits one solution to LeetCode's judge.
|
||||
- QA is dry-runs + scratch state: verify schedulers/reconcilers with `--dry-run`/`SRS_DRY=1` and a copied `SRS_STATE` before live runs; idempotency check = run twice, `cmp` output.
|
||||
- Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `api/src/srs.test.ts` (`bun:test`) proves the DST cron guards with fixed dates; run via `bun run api:test`.
|
||||
- QA is dry-runs + local state: Worker logic against `wrangler dev` + local D1 with `?dry=1&date=`; reconcilers with `--dry-run`; idempotency check = run twice, `cmp` output.
|
||||
- Docs changes: `bunx blume build --isolated` must pass with no new warnings (then `rm -rf .blume-verify`); after sync, review only *created* pages (prose transforms are heuristic).
|
||||
|
||||
@@ -5,7 +5,13 @@
|
||||
<p align="center">
|
||||
<a href="https://x.com/basedprad"><img alt="follow" src="https://shieldcn.dev/x/follow/basedprad.svg" /></a>
|
||||
<a href="https://lc.prad.nu"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
|
||||
<a href="docs/gate-log.md"><img alt="gate pass rate" src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fprdlk%2Fleetcode%2Fmain%2F.github%2Fsrs%2Fbadge.json" /></a>
|
||||
<a href="https://lc.prad.nu/progress"><img alt="gate pass rate" src="https://srs-api.prdlk.workers.dev/badge/gate.svg" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="phase progress" src="https://srs-api.prdlk.workers.dev/chart/progress.svg" /><br>
|
||||
<img alt="SRS ladder" src="https://srs-api.prdlk.workers.dev/chart/ladder.svg" /><br>
|
||||
<img alt="attempt heatmap" src="https://srs-api.prdlk.workers.dev/chart/heatmap.svg" />
|
||||
</p>
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
GH_PAT=github_pat_or_gho_token_with_issues_and_projects_rw
|
||||
WEBHOOK_SECRET=random_hex_matching_the_repo_webhook
|
||||
LINK_KEY=random_hex_signing_one_tap_links
|
||||
@@ -0,0 +1,81 @@
|
||||
# srs-api
|
||||
|
||||
Cloudflare Worker running the spaced-repetition system: daily digest email,
|
||||
one-tap logging, `/done` webhook, Saturday review issues, live SVG charts,
|
||||
and the `/api/stats` feed for the docs progress page.
|
||||
|
||||
Live at `https://srs-api.prdlk.workers.dev`.
|
||||
|
||||
## Direction of truth
|
||||
|
||||
- **D1 owns SRS state** — stages, review dates, attempt history, gate
|
||||
scores, boost flags. Nothing else is authoritative.
|
||||
- **GitHub issues own the catalog** — topics, problems, `set:*`/`diff:*`
|
||||
labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via
|
||||
`/admin/reconcile`); it recomputes from scratch, never invents rows, and
|
||||
never overwrites SRS-owned columns (`stage`, `next_review`).
|
||||
- **The repo owns the schedule** — `data/schedule.json`, human-edited,
|
||||
bundled at deploy. Git history is its audit log.
|
||||
- The GitHub Project mirror (`Target Date`, `SRS Stage`, `First Attempt`)
|
||||
is best-effort: failures log warnings, never block a D1 write. Topic rows
|
||||
are never written.
|
||||
|
||||
## The ladder
|
||||
|
||||
`new → +2 → +5 → +10 → retired`; pass advances, fail resets to `+2`.
|
||||
A problem's first-ever log enters at `+2` regardless of result. Stage names
|
||||
the NEXT review's interval. All dates are ET calendar strings anchored at
|
||||
noon UTC (`src/srs.ts`) — never raw `Date` math.
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Auth | Purpose |
|
||||
|---|---|---|
|
||||
| `GET /log?p&r&d&sig` | HMAC (`LINK_KEY`) | one-tap pass/fail; idempotent per (problem, date, kind); links expire after 3 days |
|
||||
| `POST /webhook/github` | HMAC (`WEBHOOK_SECRET`) | `/done <n> pass\|fail` comments (owner only, any issue); `review`-issue close → gate scoring |
|
||||
| `GET /chart/{progress,ladder,heatmap}.svg`, `GET /badge/gate.svg` | public | hand-rolled SVGs, `max-age=300` (GitHub Camo's freshness floor) |
|
||||
| `GET /api/stats` | public, CORS-pinned to the docs origin | one JSON document for `/progress` |
|
||||
| `POST /admin/{digest,review,reconcile}` | `Authorization: Bearer <LINK_KEY>` | manual triggers; `digest` takes `?dry=1&force=1&date=` |
|
||||
|
||||
## Crons (DST-proof)
|
||||
|
||||
Each event has two UTC crons; code fires only when the computed ET hour
|
||||
matches (`etHour` in `src/srs.ts`, unit-checked in `src/srs.test.ts`):
|
||||
|
||||
- `0 12,13 * * *` → 8 AM ET: catalog reconcile, then the digest.
|
||||
- `0 4,5 * * 6` → midnight ET Saturday: create `Review — Week N` (so the
|
||||
8 AM digest can link to it).
|
||||
|
||||
## Secrets & bindings
|
||||
|
||||
`wrangler secret put` — `GH_PAT` (Issues + Projects RW), `WEBHOOK_SECRET`
|
||||
(matches the repo webhook), `LINK_KEY` (signs one-tap links, gates admin
|
||||
routes). Bindings in `wrangler.jsonc`: `DB` (D1 `srs`), `EMAIL`
|
||||
(`send_email`, restricted to the verified destination). Sender domain
|
||||
`prdlk.com` is onboarded to Email Sending.
|
||||
|
||||
## Local dev
|
||||
|
||||
```sh
|
||||
cp .dev.vars.example .dev.vars # or fill GH_PAT/WEBHOOK_SECRET/LINK_KEY
|
||||
bun install
|
||||
bunx wrangler d1 migrations apply srs --local
|
||||
bun run dev # wrangler dev on :8787, local D1
|
||||
curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
||||
"localhost:8787/admin/digest?dry=1&date=2026-08-31" # prints HTML, sends nothing
|
||||
bun test src # DST guard + date math
|
||||
```
|
||||
|
||||
`?date=` on admin routes is the `SRS_TODAY` equivalent. The one-shot
|
||||
migration from the retired `.github/srs/srs.json` lives at
|
||||
`scripts/import-srs.ts` (`--remote` for production D1); it is re-runnable —
|
||||
import-sourced attempts are wiped and re-inserted.
|
||||
|
||||
## Deploy
|
||||
|
||||
```sh
|
||||
bun run deploy # from api/, or `bun run api:deploy` at the repo root
|
||||
```
|
||||
|
||||
Manual by design — never coupled to the docs deploy. After changing
|
||||
bindings, rerun `bunx wrangler types`.
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "srs-api",
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.125.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="],
|
||||
|
||||
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260820.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-5F2/t7SVnugG3rscSe9da1LoHst+GiuVGPaE9BP6j5AonlFpiYi0eoJrl3wof9zDBIIgJZ87NjRj9TKjbbYgHg=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260820.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jFnG7715+r9FXRZPpuWWe5Ayd9v/IJKDODARg56ffFJWWtte6bFNi5VY3GazBM04hEmxny6OjXQBh3j2E/wNZw=="],
|
||||
|
||||
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260820.1", "", { "os": "linux", "cpu": "x64" }, "sha512-TbTYaCBht0OaOWmnVpg43hXVYtIti/Kg6lvO819H2DwbxPpK1BH0z2C+Y7ySrVJLlxZQeic5eN7/noqbP80hJg=="],
|
||||
|
||||
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260820.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-FQmri1UF7hBnnpeyC5SZJCAnsSRs3+Ykn9wlO5zxmE2qIgKfbJZ+toJ6qrQyugWlxE65juT33HU6aYLtutLJHw=="],
|
||||
|
||||
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260820.1", "", { "os": "win32", "cpu": "x64" }, "sha512-BPvCuMxIQfA47wtYsGbBG6Bcar57Qs7yHqU2jWkT1+sRnL/741/ZbQGP9kVRMQ5fwBMuqNFmQRzAu8gSm7ri/w=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="],
|
||||
|
||||
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
|
||||
|
||||
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
|
||||
|
||||
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||
|
||||
"@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="],
|
||||
|
||||
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"miniflare": ["miniflare@5.20260820.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260820.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-Bv1j2kcKKNwXLWuCx+j0xGt7z318mqQkJmEN6ellM9sCESbPBDTM9ofZMbKqx47jSnoGA3CiaUkAmzGVXUa/wQ=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="],
|
||||
|
||||
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
|
||||
|
||||
"workerd": ["workerd@1.20260820.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260820.1", "@cloudflare/workerd-darwin-arm64": "1.20260820.1", "@cloudflare/workerd-linux-64": "1.20260820.1", "@cloudflare/workerd-linux-arm64": "1.20260820.1", "@cloudflare/workerd-windows-64": "1.20260820.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-/wk4rFNHH6IVMXFe6aPEZsT5YWpWtfKTUMt7+rFkyeGbXcGCy4yxODmJatbqYj0jmqHETdz0+m2mT+vNjXnF7w=="],
|
||||
|
||||
"wrangler": ["wrangler@4.125.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260820.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260820.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260820.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-yFpvggu+xk1Hdm/Uxwaqa19bb7GArME4CrCS3Vov68a2TZq2MPO+wLocKbbnIC9K0oLowcdau7/ycxbbNHKCEg=="],
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
-- SRS schema. D1 owns SRS state (stage, next_review, attempts, gates,
|
||||
-- boosts); GitHub issues own the catalog columns, reconciled nightly.
|
||||
CREATE TABLE problems (
|
||||
lc_number INTEGER PRIMARY KEY,
|
||||
issue INTEGER NOT NULL,
|
||||
topic_issue INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
difficulty TEXT NOT NULL, -- easy|medium|hard
|
||||
set_label TEXT NOT NULL, -- core|optional|deferred
|
||||
stage TEXT NOT NULL DEFAULT 'new', -- new|+2|+5|+10|retired
|
||||
next_review TEXT, -- YYYY-MM-DD ET, NULL when retired/unsolved
|
||||
defer_until TEXT -- deferred Hards: 2026-09-28+
|
||||
);
|
||||
|
||||
CREATE TABLE attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
lc_number INTEGER NOT NULL REFERENCES problems(lc_number),
|
||||
date TEXT NOT NULL,
|
||||
kind TEXT NOT NULL, -- first|review|drill|gate
|
||||
result TEXT NOT NULL, -- pass|fail
|
||||
source TEXT NOT NULL -- email|webhook|import
|
||||
);
|
||||
|
||||
-- One-tap email links must be idempotent (same link twice = no-op), but
|
||||
-- /done webhook corrections (pass then fail, same day) must stay legal —
|
||||
-- so uniqueness applies to email-sourced attempts only.
|
||||
CREATE UNIQUE INDEX attempts_email_once
|
||||
ON attempts (lc_number, date, kind) WHERE source = 'email';
|
||||
CREATE INDEX attempts_by_date ON attempts (date);
|
||||
|
||||
CREATE TABLE topics (
|
||||
issue INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
misses INTEGER NOT NULL DEFAULT 0,
|
||||
boost INTEGER NOT NULL DEFAULT 0,
|
||||
milestone INTEGER -- phase; from the topic issue
|
||||
);
|
||||
|
||||
CREATE TABLE gates (
|
||||
week INTEGER PRIMARY KEY, -- ISO week
|
||||
issue INTEGER,
|
||||
problems TEXT NOT NULL, -- JSON array of lc_numbers
|
||||
pass_rate REAL,
|
||||
closed_on TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE drill_pool_used (lc_number INTEGER PRIMARY KEY);
|
||||
|
||||
-- Digest idempotency: one email per ET date.
|
||||
CREATE TABLE email_log (
|
||||
date TEXT PRIMARY KEY, -- YYYY-MM-DD ET
|
||||
sent_at TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "srs-api",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "wrangler dev --test-scheduled",
|
||||
"deploy": "wrangler deploy",
|
||||
"types": "wrangler types",
|
||||
"test": "bun test src"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.125.0"
|
||||
}
|
||||
}
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* One-shot migration: .github/srs/srs.json → D1.
|
||||
*
|
||||
* Run AFTER the catalog reconcile has filled `problems` (the import only
|
||||
* overlays SRS-owned fields — stage, next_review, defer_until — and inserts
|
||||
* attempts/topic counters). Re-runnable without duplicates: import-sourced
|
||||
* attempts are wiped and re-inserted, everything else upserts.
|
||||
*
|
||||
* bun api/scripts/import-srs.ts # local D1 (wrangler dev state)
|
||||
* bun api/scripts/import-srs.ts --remote # production D1
|
||||
*
|
||||
* Prints row counts; verify them against srs.json before deleting anything.
|
||||
*/
|
||||
import { $ } from "bun";
|
||||
import { join } from "node:path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
const API = join(ROOT, "api");
|
||||
const REMOTE = process.argv.includes("--remote");
|
||||
|
||||
interface Attempt {
|
||||
date: string;
|
||||
kind: string;
|
||||
result: string;
|
||||
}
|
||||
interface Problem {
|
||||
issue: number;
|
||||
topic: number;
|
||||
difficulty: string;
|
||||
set: string;
|
||||
solved_on?: string;
|
||||
stage: string;
|
||||
next_review?: string;
|
||||
defer_until?: string;
|
||||
history: Attempt[];
|
||||
}
|
||||
interface State {
|
||||
problems: Record<string, Problem>;
|
||||
topics: Record<string, { misses: number; boost: boolean }>;
|
||||
drill_pool_used: number[];
|
||||
}
|
||||
|
||||
const state: State = JSON.parse(
|
||||
await Bun.file(join(ROOT, ".github", "srs", "srs.json")).text(),
|
||||
);
|
||||
|
||||
const q = (v: string | null | undefined) => (v == null ? "NULL" : `'${v}'`);
|
||||
const lines: string[] = ["DELETE FROM attempts WHERE source = 'import';"];
|
||||
|
||||
let attempts = 0;
|
||||
for (const [lc, p] of Object.entries(state.problems)) {
|
||||
lines.push(
|
||||
`UPDATE problems SET stage = ${q(p.stage)}, next_review = ${q(p.next_review ?? null)}, ` +
|
||||
`defer_until = ${q(p.defer_until ?? null)} WHERE lc_number = ${Number(lc)};`,
|
||||
);
|
||||
for (const a of p.history) {
|
||||
lines.push(
|
||||
`INSERT INTO attempts (lc_number, date, kind, result, source) ` +
|
||||
`VALUES (${Number(lc)}, ${q(a.date)}, ${q(a.kind)}, ${q(a.result)}, 'import');`,
|
||||
);
|
||||
attempts++;
|
||||
}
|
||||
}
|
||||
for (const [topic, t] of Object.entries(state.topics)) {
|
||||
lines.push(
|
||||
`UPDATE topics SET misses = ${t.misses}, boost = ${t.boost ? 1 : 0} WHERE issue = ${Number(topic)};`,
|
||||
);
|
||||
}
|
||||
for (const lc of state.drill_pool_used) {
|
||||
lines.push(`INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (${lc});`);
|
||||
}
|
||||
|
||||
const sqlPath = join(API, "migrations", ".import.sql");
|
||||
await Bun.write(sqlPath, lines.join("\n") + "\n");
|
||||
const flag = REMOTE ? "--remote" : "--local";
|
||||
await $`bunx wrangler d1 execute srs ${flag} --file ${sqlPath}`.cwd(API);
|
||||
await $`rm ${sqlPath}`;
|
||||
|
||||
const counts =
|
||||
await $`bunx wrangler d1 execute srs ${flag} --json --command ${"SELECT (SELECT COUNT(*) FROM problems) AS problems, (SELECT COUNT(*) FROM problems WHERE stage != 'new') AS laddered, (SELECT COUNT(*) FROM problems WHERE defer_until IS NOT NULL) AS deferred, (SELECT COUNT(*) FROM attempts WHERE source='import') AS imported_attempts, (SELECT COUNT(*) FROM topics) AS topics, (SELECT COUNT(*) FROM drill_pool_used) AS drills_used"}`
|
||||
.cwd(API)
|
||||
.json();
|
||||
|
||||
const expected = {
|
||||
json_problems: Object.keys(state.problems).length,
|
||||
json_attempts: attempts,
|
||||
json_laddered: Object.values(state.problems).filter((p) => p.stage !== "new").length,
|
||||
json_deferred: Object.values(state.problems).filter((p) => p.defer_until).length,
|
||||
json_topics: Object.keys(state.topics).length,
|
||||
json_drills_used: state.drill_pool_used.length,
|
||||
};
|
||||
console.log("expected from srs.json:", JSON.stringify(expected));
|
||||
console.log("in D1:", JSON.stringify(counts[0]?.results?.[0] ?? counts));
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Nightly GitHub → D1 catalog reconcile. GitHub issues own the catalog
|
||||
* (topics, problems, set/diff labels, milestones); this recomputes desired
|
||||
* rows from scratch on every run — re-runs are no-ops. SRS-owned columns
|
||||
* (stage, next_review) are NEVER overwritten; defer_until is set once, on
|
||||
* first sight of a deferred problem. The Worker never invents catalog rows.
|
||||
*/
|
||||
import { addDays } from "./srs.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
const TITLE_RE = /^LC (\d+) · (.+) · (Easy|Medium|Hard) · (core|optional|deferred)$/;
|
||||
|
||||
/** Deferred Hards enter the queue from this date, two per day. */
|
||||
const DEFER_FROM = "2026-09-28";
|
||||
|
||||
export interface ReconcileReport {
|
||||
topics: number;
|
||||
problems: number;
|
||||
}
|
||||
|
||||
export async function reconcileCatalog(db: D1Database, gh: GitHub): Promise<ReconcileReport> {
|
||||
interface TopicIssue {
|
||||
number: number;
|
||||
title: string;
|
||||
milestone: number | null;
|
||||
}
|
||||
const topics: TopicIssue[] = [];
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if ("pull_request" in raw) continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||
let milestone: number | null = null;
|
||||
if (
|
||||
"milestone" in raw &&
|
||||
raw.milestone &&
|
||||
typeof raw.milestone === "object" &&
|
||||
"number" in raw.milestone &&
|
||||
typeof raw.milestone.number === "number"
|
||||
) {
|
||||
milestone = raw.milestone.number;
|
||||
}
|
||||
topics.push({ number: raw.number, title: raw.title, milestone });
|
||||
}
|
||||
|
||||
const statements: D1PreparedStatement[] = [];
|
||||
for (const t of topics) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO topics (issue, name, milestone) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(issue) DO UPDATE SET name = ?2, milestone = ?3`,
|
||||
)
|
||||
.bind(t.number, t.title.replace(/^Topic \d+ — /, ""), t.milestone),
|
||||
);
|
||||
}
|
||||
|
||||
let problems = 0;
|
||||
let deferredSeen = 0;
|
||||
for (const t of topics) {
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${t.number}/sub_issues`)) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
if (!("number" in raw) || typeof raw.number !== "number") continue;
|
||||
if (!("title" in raw) || typeof raw.title !== "string") continue;
|
||||
const m = raw.title.match(TITLE_RE);
|
||||
if (!m) continue; // non-curriculum sub-issue
|
||||
const lc = Number(m[1]);
|
||||
const set = m[4]!;
|
||||
// Two deferred Hards per day from DEFER_FROM, in catalog walk order —
|
||||
// applied only when the row is first created (SRS owns it afterwards).
|
||||
const defer = set === "deferred" ? addDays(DEFER_FROM, Math.floor(deferredSeen / 2)) : null;
|
||||
if (set === "deferred") deferredSeen++;
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO problems (lc_number, issue, topic_issue, title, difficulty, set_label, defer_until, next_review)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)
|
||||
ON CONFLICT(lc_number) DO UPDATE SET
|
||||
issue = ?2, topic_issue = ?3, title = ?4, difficulty = ?5, set_label = ?6`,
|
||||
)
|
||||
.bind(lc, raw.number, t.number, m[2]!, m[3]!.toLowerCase(), set, defer),
|
||||
);
|
||||
problems++;
|
||||
}
|
||||
}
|
||||
|
||||
// D1 batches are transactional; chunk to stay under statement limits.
|
||||
for (let i = 0; i < statements.length; i += 50) {
|
||||
await db.batch(statements.slice(i, i + 50));
|
||||
}
|
||||
return { topics: topics.length, problems };
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Hand-rolled SVG chart generation for the SRS Worker.
|
||||
*
|
||||
* Feeds four endpoints — /chart/progress.svg, /chart/ladder.svg,
|
||||
* /chart/heatmap.svg, /badge/gate.svg — each served with
|
||||
* `Cache-Control: public, max-age=300`.
|
||||
*
|
||||
* Everything is string-built: no chart library, no runtime imports. The only
|
||||
* dynamic values entering the markup are numbers, percentages, and dates the
|
||||
* Worker itself computes, plus the fixed phase/stage labels — so nothing here
|
||||
* needs XML escaping. GitHub renders these on white; no dark-mode variants.
|
||||
* Every function renders a valid (if empty-looking) SVG on a zero-row DB.
|
||||
*/
|
||||
// D1Database comes from the generated worker-configuration.d.ts runtime types.
|
||||
|
||||
const FONT = "Verdana,DejaVu Sans,sans-serif";
|
||||
|
||||
// Green ramp shared by the heatmap and the ladder (GitHub contribution hues).
|
||||
const GREENS = ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"];
|
||||
|
||||
// Labels are stored SVG-ready: phase II's "&" is pre-escaped since these
|
||||
// strings go straight into markup and nothing else here needs escaping.
|
||||
const PHASES = [
|
||||
{ milestone: 1, name: "I — Linear" },
|
||||
{ milestone: 2, name: "II — Nodal & Grid" },
|
||||
{ milestone: 3, name: "III — Hierarchical" },
|
||||
{ milestone: 4, name: "IV — Relational" },
|
||||
{ milestone: 5, name: "V — Decision Space" },
|
||||
];
|
||||
|
||||
const STAGES = ["new", "+2", "+5", "+10", "retired"];
|
||||
|
||||
// ── svg helpers ──────────────────────────────────────────────────
|
||||
|
||||
/** Opening tag plus the white background rect every chart starts with. */
|
||||
function svgOpen(width: number, height: number): string {
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" ` +
|
||||
`viewBox="0 0 ${width} ${height}" font-family="${FONT}">` +
|
||||
`<rect width="${width}" height="${height}" fill="#ffffff"/>`
|
||||
);
|
||||
}
|
||||
|
||||
/** A <text> element; charts place dozens of these with the same defaults. */
|
||||
function text(
|
||||
x: number,
|
||||
y: number,
|
||||
content: string,
|
||||
attrs: { size?: number; fill?: string; anchor?: string; weight?: string } = {},
|
||||
): string {
|
||||
const { size = 12, fill = "#1f2328", anchor = "start", weight } = attrs;
|
||||
const bold = weight ? ` font-weight="${weight}"` : "";
|
||||
return `<text x="${x}" y="${y}" font-size="${size}" fill="${fill}" text-anchor="${anchor}"${bold}>${content}</text>`;
|
||||
}
|
||||
|
||||
// ── progress: stacked bar per phase ──────────────────────────────
|
||||
|
||||
interface ProgressRow {
|
||||
milestone: number;
|
||||
set_label: string;
|
||||
done: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function progressChart(db: D1Database): Promise<string> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT t.milestone AS milestone, p.set_label AS set_label,
|
||||
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done,
|
||||
COUNT(*) AS total
|
||||
FROM problems p JOIN topics t ON p.topic_issue = t.issue
|
||||
WHERE t.milestone IS NOT NULL
|
||||
GROUP BY t.milestone, p.set_label`,
|
||||
)
|
||||
.all<ProgressRow>();
|
||||
|
||||
// Per phase: core done / optional done / remaining (all-set total − all done).
|
||||
const phases = PHASES.map((phase) => {
|
||||
const rows = results.filter((r) => r.milestone === phase.milestone);
|
||||
const total = rows.reduce((n, r) => n + r.total, 0);
|
||||
const done = rows.reduce((n, r) => n + r.done, 0);
|
||||
return {
|
||||
name: phase.name,
|
||||
coreDone: rows.find((r) => r.set_label === "core")?.done ?? 0,
|
||||
optionalDone: rows.find((r) => r.set_label === "optional")?.done ?? 0,
|
||||
remaining: total - done,
|
||||
done,
|
||||
total,
|
||||
};
|
||||
});
|
||||
|
||||
const width = 640;
|
||||
const height = 300;
|
||||
const barX = 168;
|
||||
const barMaxW = 400;
|
||||
const barH = 22;
|
||||
const rowStep = 48;
|
||||
const scale = Math.max(1, ...phases.map((p) => p.total));
|
||||
|
||||
const parts = [svgOpen(width, height)];
|
||||
|
||||
// Legend on top.
|
||||
const legend: [string, string][] = [
|
||||
["#2da44e", "core done"],
|
||||
["#0969da", "optional done"],
|
||||
["#d0d7de", "remaining"],
|
||||
];
|
||||
let lx = barX;
|
||||
for (const [color, label] of legend) {
|
||||
parts.push(`<rect x="${lx}" y="12" width="12" height="12" rx="2" fill="${color}"/>`);
|
||||
parts.push(text(lx + 17, 22, label, { size: 11, fill: "#57606a" }));
|
||||
lx += 17 + label.length * 7 + 24;
|
||||
}
|
||||
|
||||
phases.forEach((p, i) => {
|
||||
const y = 52 + i * rowStep;
|
||||
const midY = y + barH / 2 + 4;
|
||||
parts.push(text(barX - 12, midY, p.name, { anchor: "end" }));
|
||||
|
||||
let x = barX;
|
||||
const segments: [number, string][] = [
|
||||
[p.coreDone, "#2da44e"],
|
||||
[p.optionalDone, "#0969da"],
|
||||
[p.remaining, "#d0d7de"],
|
||||
];
|
||||
for (const [count, color] of segments) {
|
||||
const w = (count / scale) * barMaxW;
|
||||
if (w > 0) {
|
||||
parts.push(`<rect x="${x.toFixed(1)}" y="${y}" width="${w.toFixed(1)}" height="${barH}" fill="${color}"/>`);
|
||||
// Count inside the segment when it fits; tiny slivers stay unlabeled.
|
||||
if (w >= 20) {
|
||||
const labelFill = color === "#d0d7de" ? "#57606a" : "#ffffff";
|
||||
parts.push(text(x + w / 2, midY, String(count), { size: 11, fill: labelFill, anchor: "middle" }));
|
||||
}
|
||||
x += w;
|
||||
}
|
||||
}
|
||||
parts.push(text(x + 8, midY, `${p.done}/${p.total}`, { size: 11, fill: "#57606a" }));
|
||||
});
|
||||
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// ── ladder: bar per stage ────────────────────────────────────────
|
||||
|
||||
export async function ladderChart(db: D1Database): Promise<string> {
|
||||
const { results } = await db
|
||||
.prepare(`SELECT stage, COUNT(*) AS count FROM problems GROUP BY stage`)
|
||||
.all<{ stage: string; count: number }>();
|
||||
|
||||
const counts = STAGES.map((s) => results.find((r) => r.stage === s)?.count ?? 0);
|
||||
|
||||
const width = 640;
|
||||
const height = 220;
|
||||
const plotTop = 34;
|
||||
const plotBottom = height - 32;
|
||||
const plotH = plotBottom - plotTop;
|
||||
const slotW = (width - 80) / STAGES.length;
|
||||
const barW = 64;
|
||||
const scale = Math.max(1, ...counts);
|
||||
|
||||
const parts = [svgOpen(width, height)];
|
||||
parts.push(text(40, 20, "review ladder", { size: 13, weight: "bold" }));
|
||||
|
||||
counts.forEach((count, i) => {
|
||||
const cx = 40 + slotW * i + slotW / 2;
|
||||
const barH = (count / scale) * plotH;
|
||||
const y = plotBottom - barH;
|
||||
if (barH > 0) {
|
||||
parts.push(
|
||||
`<rect x="${(cx - barW / 2).toFixed(1)}" y="${y.toFixed(1)}" width="${barW}" height="${barH.toFixed(1)}" rx="3" fill="${GREENS[i]}"/>`,
|
||||
);
|
||||
}
|
||||
parts.push(text(cx, y - 6, String(count), { size: 12, anchor: "middle", weight: "bold" }));
|
||||
parts.push(text(cx, plotBottom + 18, STAGES[i]!, { size: 12, fill: "#57606a", anchor: "middle" }));
|
||||
});
|
||||
|
||||
parts.push(`<line x1="40" y1="${plotBottom}" x2="${width - 40}" y2="${plotBottom}" stroke="#d0d7de"/>`);
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// ── heatmap: attempts per campaign day ───────────────────────────
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export async function heatmapChart(db: D1Database, start: string, days: number): Promise<string> {
|
||||
// YYYY-MM-DD → UTC midnight; all per-day dates derive from this timestamp,
|
||||
// so local-timezone drift never shifts a cell.
|
||||
const [sy, sm, sd] = start.split("-").map(Number);
|
||||
const base = Date.UTC(sy!, sm! - 1, sd!);
|
||||
const end = new Date(base + (days - 1) * DAY_MS).toISOString().slice(0, 10);
|
||||
|
||||
const { results } = await db
|
||||
.prepare(`SELECT date, COUNT(*) AS attempts FROM attempts WHERE date >= ?1 AND date <= ?2 GROUP BY date`)
|
||||
.bind(start, end)
|
||||
.all<{ date: string; attempts: number }>();
|
||||
|
||||
const byDate = new Map(results.map((r) => [r.date, r.attempts]));
|
||||
|
||||
const width = 560;
|
||||
const height = 160;
|
||||
const gridX = 34;
|
||||
const gridY = 24;
|
||||
const cell = 16;
|
||||
const step = 19; // cell + 3px gap
|
||||
const weeks = Math.ceil(days / 7);
|
||||
|
||||
const parts = [svgOpen(width, height)];
|
||||
|
||||
// Week numbers across the top, Mon/Wed/Fri down the left.
|
||||
for (let w = 0; w < weeks; w++) {
|
||||
parts.push(text(gridX + w * step + cell / 2, gridY - 7, `W${w + 1}`, { size: 10, fill: "#57606a", anchor: "middle" }));
|
||||
}
|
||||
const weekdays: [number, string][] = [
|
||||
[0, "Mon"],
|
||||
[2, "Wed"],
|
||||
[4, "Fri"],
|
||||
];
|
||||
for (const [row, label] of weekdays) {
|
||||
parts.push(text(gridX - 6, gridY + row * step + cell - 4, label, { size: 10, fill: "#57606a", anchor: "end" }));
|
||||
}
|
||||
|
||||
// One cell per campaign day; the start date is a Monday, so day i sits at
|
||||
// column i/7, row i%7 — but the row is derived from the real weekday so an
|
||||
// off-Monday start still lands correctly.
|
||||
for (let i = 0; i < days; i++) {
|
||||
const day = new Date(base + i * DAY_MS);
|
||||
const attempts = byDate.get(day.toISOString().slice(0, 10)) ?? 0;
|
||||
const col = Math.floor(i / 7);
|
||||
const row = (day.getUTCDay() + 6) % 7; // Mon = 0
|
||||
const color = GREENS[Math.min(attempts, 4)];
|
||||
parts.push(
|
||||
`<rect x="${gridX + col * step}" y="${gridY + row * step}" width="${cell}" height="${cell}" rx="2" fill="${color}"/>`,
|
||||
);
|
||||
}
|
||||
|
||||
// Less → More ramp fills the space right of the grid.
|
||||
const legendX = gridX + weeks * step + 40;
|
||||
const legendY = gridY + 3 * step;
|
||||
parts.push(text(legendX - 6, legendY + cell - 4, "Less", { size: 10, fill: "#57606a", anchor: "end" }));
|
||||
GREENS.forEach((color, i) => {
|
||||
parts.push(`<rect x="${legendX + i * step}" y="${legendY}" width="${cell}" height="${cell}" rx="2" fill="${color}"/>`);
|
||||
});
|
||||
parts.push(text(legendX + GREENS.length * step + 3, legendY + cell - 4, "More", { size: 10, fill: "#57606a" }));
|
||||
|
||||
parts.push("</svg>");
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// ── gate badge: shields.io flat pill ─────────────────────────────
|
||||
|
||||
export async function gateBadge(db: D1Database): Promise<string> {
|
||||
const row = await db
|
||||
.prepare(`SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1`)
|
||||
.first<{ pass_rate: number }>();
|
||||
|
||||
const label = "gate";
|
||||
const value = row ? `${Math.round(row.pass_rate * 100)}%` : "none yet";
|
||||
const color = row ? (row.pass_rate >= 0.7 ? "#4c1" : "#e05d44") : "#9f9f9f";
|
||||
|
||||
// Shields flat geometry: height 20, rx 3, verdana 11px, ~6.5px per char
|
||||
// plus 5px padding each side. Each caption is drawn twice — a dark
|
||||
// 30%-opacity copy 1px low as the shadow, then white on top.
|
||||
const labelW = Math.round(label.length * 6.5) + 10;
|
||||
const valueW = Math.round(value.length * 6.5) + 10;
|
||||
const total = labelW + valueW;
|
||||
const labelX = labelW / 2;
|
||||
const valueX = labelW + valueW / 2;
|
||||
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${total}" height="20" role="img" aria-label="${label}: ${value}">` +
|
||||
`<linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient>` +
|
||||
`<clipPath id="r"><rect width="${total}" height="20" rx="3" fill="#fff"/></clipPath>` +
|
||||
`<g clip-path="url(#r)">` +
|
||||
`<rect width="${labelW}" height="20" fill="#555"/>` +
|
||||
`<rect x="${labelW}" width="${valueW}" height="20" fill="${color}"/>` +
|
||||
`<rect width="${total}" height="20" fill="url(#s)"/>` +
|
||||
`</g>` +
|
||||
`<g fill="#fff" text-anchor="middle" font-family="${FONT}" font-size="11">` +
|
||||
`<text x="${labelX}" y="15" fill="#010101" fill-opacity=".3">${label}</text>` +
|
||||
`<text x="${labelX}" y="14">${label}</text>` +
|
||||
`<text x="${valueX}" y="15" fill="#010101" fill-opacity=".3">${value}</text>` +
|
||||
`<text x="${valueX}" y="14">${value}</text>` +
|
||||
`</g>` +
|
||||
`</svg>`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* The daily digest email — built from D1 + the bundled schedule, sent at
|
||||
* 8 AM ET via the send_email binding. Plain inline-styled HTML that renders
|
||||
* clean in Gmail mobile; always sent with a text alternative.
|
||||
*
|
||||
* Retrieval rules: review and drill lines carry number + difficulty only —
|
||||
* never the topic, never a solution link. The learning day's core list is
|
||||
* the only labeled section. Reviews + drills ≤ 6, reviews first, overflow
|
||||
* simply stays due (oldest tomorrow). Sunday is a two-line rest note.
|
||||
*
|
||||
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
|
||||
* no-op unless forced; the body itself is deterministic (drill picks are
|
||||
* seeded by the date).
|
||||
*/
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
type ProblemRow,
|
||||
SCHEDULE,
|
||||
addDays,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
dueReviews,
|
||||
isoWeek,
|
||||
pickDrills,
|
||||
prettyDate,
|
||||
streak,
|
||||
weekdayOf,
|
||||
} from "./srs.ts";
|
||||
import { signLink } from "./links.ts";
|
||||
|
||||
const DAILY_CAP = 6;
|
||||
|
||||
function cap(difficulty: string): string {
|
||||
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
||||
}
|
||||
|
||||
export interface Digest {
|
||||
subject: string;
|
||||
html: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** One-tap pass/fail anchor pair for a problem line. */
|
||||
async function tapLinks(env: Env, lc: number, date: string): Promise<string> {
|
||||
const pass = await signLink(env.LINK_KEY, lc, "pass", date);
|
||||
const fail = await signLink(env.LINK_KEY, lc, "fail", date);
|
||||
const url = (r: string, sig: string) =>
|
||||
`${env.PUBLIC_URL}/log?p=${lc}&r=${r}&d=${date}&sig=${sig}`;
|
||||
return (
|
||||
`<a href="${url("pass", pass)}" style="color:#1a7f37;font-weight:bold">pass</a>` +
|
||||
` · <a href="${url("fail", fail)}" style="color:#cf222e;font-weight:bold">fail</a>`
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildDigest(env: Env, date: string): Promise<Digest> {
|
||||
const db = env.DB;
|
||||
const day = campaignDay(date);
|
||||
const week = campaignWeek(date);
|
||||
const header = `${prettyDate(date)} — Day ${day}/${CAMPAIGN_DAYS} · Week ${week}`;
|
||||
|
||||
if (weekdayOf(date) === 0) {
|
||||
return {
|
||||
subject: `SRS — ${prettyDate(date)} — rest day`,
|
||||
html: `<p><strong>${header}</strong></p><p>Rest day. Nothing is due. Overdue reviews moved to Monday.</p>`,
|
||||
text: `${header}\nRest day. Nothing is due. Overdue reviews moved to Monday.`,
|
||||
};
|
||||
}
|
||||
|
||||
const due = await dueReviews(db, date);
|
||||
const reviews = due.slice(0, DAILY_CAP);
|
||||
const carried = due.length - reviews.length;
|
||||
const drills = await pickDrills(db, date, DAILY_CAP - reviews.length);
|
||||
const currentStreak = await streak(db, date);
|
||||
|
||||
const htmlParts: string[] = [];
|
||||
const textParts: string[] = [];
|
||||
htmlParts.push(
|
||||
`<h2 style="margin:0 0 4px">${header}</h2>`,
|
||||
`<p style="margin:0 0 16px;color:#57606a">🔥 streak: ${currentStreak} day${currentStreak === 1 ? "" : "s"}</p>`,
|
||||
);
|
||||
textParts.push(header, `streak: ${currentStreak}`);
|
||||
|
||||
// New topic — the only labeled section.
|
||||
const topicIssue = SCHEDULE[date];
|
||||
if (topicIssue !== undefined) {
|
||||
const topic = await db
|
||||
.prepare("SELECT name FROM topics WHERE issue = ?")
|
||||
.bind(topicIssue)
|
||||
.first<{ name: string }>();
|
||||
const { results: core } = await db
|
||||
.prepare(
|
||||
"SELECT * FROM problems WHERE topic_issue = ? AND set_label = 'core' ORDER BY lc_number",
|
||||
)
|
||||
.bind(topicIssue)
|
||||
.all<ProblemRow>();
|
||||
const links = core
|
||||
.map(
|
||||
(p) =>
|
||||
`<a href="https://github.com/${env.REPO}/issues/${p.issue}">LC ${p.lc_number}</a>${p.stage === "new" ? "" : " ✓"}`,
|
||||
)
|
||||
.join(" · ");
|
||||
htmlParts.push(
|
||||
`<h3 style="margin:16px 0 4px">New topic: ${topic?.name ?? `#${topicIssue}`}</h3>`,
|
||||
`<p style="margin:0">Core: ${links}</p>`,
|
||||
);
|
||||
textParts.push(`New topic: ${topic?.name ?? topicIssue} — core: ${core.map((p) => `LC ${p.lc_number}`).join(", ")}`);
|
||||
}
|
||||
|
||||
// Reviews due — unlabeled lines, one-tap links.
|
||||
htmlParts.push(`<h3 style="margin:16px 0 4px">Reviews due (${reviews.length})</h3>`);
|
||||
textParts.push(`Reviews due (${reviews.length})`);
|
||||
if (reviews.length) {
|
||||
htmlParts.push(
|
||||
`<p style="margin:0 0 4px;color:#57606a">Solve each from scratch. Do not open your old solution first.</p>`,
|
||||
);
|
||||
for (const p of reviews) {
|
||||
htmlParts.push(
|
||||
`<p style="margin:2px 0">LC ${p.lc_number} — ${cap(p.difficulty)} — stage ${p.stage} — ${await tapLinks(env, p.lc_number, date)}</p>`,
|
||||
);
|
||||
textParts.push(`- LC ${p.lc_number} — ${cap(p.difficulty)} — stage ${p.stage}`);
|
||||
}
|
||||
if (carried > 0) {
|
||||
htmlParts.push(`<p style="margin:4px 0;color:#57606a">${carried} more carried to tomorrow (cap ${DAILY_CAP}).</p>`);
|
||||
textParts.push(`${carried} more carried to tomorrow.`);
|
||||
}
|
||||
} else {
|
||||
htmlParts.push(`<p style="margin:0">None.</p>`);
|
||||
textParts.push("none");
|
||||
}
|
||||
|
||||
// Blind drills — unlabeled.
|
||||
htmlParts.push(`<h3 style="margin:16px 0 4px">Blind drills (${drills.length})</h3>`);
|
||||
textParts.push(`Blind drills (${drills.length})`);
|
||||
if (drills.length) {
|
||||
htmlParts.push(
|
||||
`<p style="margin:0 0 4px;color:#57606a">No topic given. Name the pattern out loud before you code.</p>`,
|
||||
);
|
||||
for (const p of drills) {
|
||||
htmlParts.push(
|
||||
`<p style="margin:2px 0">LC ${p.lc_number} — ${cap(p.difficulty)} — ${await tapLinks(env, p.lc_number, date)}</p>`,
|
||||
);
|
||||
textParts.push(`- LC ${p.lc_number} — ${cap(p.difficulty)}`);
|
||||
}
|
||||
} else {
|
||||
htmlParts.push(`<p style="margin:0">None today.</p>`);
|
||||
textParts.push("none");
|
||||
}
|
||||
|
||||
// Saturday: the review issue already exists (created at midnight ET).
|
||||
if (weekdayOf(date) === 6) {
|
||||
const gate = await db
|
||||
.prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL")
|
||||
.bind(isoWeek(date))
|
||||
.first<{ issue: number }>();
|
||||
if (gate) {
|
||||
htmlParts.push(
|
||||
`<h3 style="margin:16px 0 4px">Saturday gate</h3>`,
|
||||
`<p style="margin:0"><a href="https://github.com/${env.REPO}/issues/${gate.issue}">Review — Week ${campaignWeek(date)} (#${gate.issue})</a> — timed, blind, then close the issue.</p>`,
|
||||
);
|
||||
textParts.push(`Saturday gate: https://github.com/${env.REPO}/issues/${gate.issue}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Footer: yesterday's log, gate rate to date, progress page.
|
||||
const yesterday = addDays(date, -1);
|
||||
const { results: logged } = await db
|
||||
.prepare("SELECT lc_number, kind, result FROM attempts WHERE date = ? ORDER BY id")
|
||||
.bind(yesterday)
|
||||
.all<{ lc_number: number; kind: string; result: string }>();
|
||||
const lastGate = await db
|
||||
.prepare("SELECT pass_rate FROM gates WHERE pass_rate IS NOT NULL ORDER BY week DESC LIMIT 1")
|
||||
.first<{ pass_rate: number }>();
|
||||
const footerBits = [
|
||||
logged.length
|
||||
? `Yesterday: ${logged.map((a) => `LC ${a.lc_number} ${a.result === "pass" ? "✅" : "❌"}`).join(" · ")}`
|
||||
: "Yesterday: no attempts logged.",
|
||||
lastGate ? `Gate pass rate: ${Math.round(lastGate.pass_rate * 100)}%` : "No gates scored yet.",
|
||||
];
|
||||
htmlParts.push(
|
||||
`<hr style="border:none;border-top:1px solid #d0d7de;margin:16px 0">`,
|
||||
`<p style="margin:0;color:#57606a;font-size:13px">${footerBits.join(" · ")} · <a href="${env.DOCS_URL}/progress">progress page</a></p>`,
|
||||
);
|
||||
textParts.push(...footerBits, `progress: ${env.DOCS_URL}/progress`);
|
||||
|
||||
const load = reviews.length + drills.length;
|
||||
return {
|
||||
subject: `SRS — ${prettyDate(date)} — ${load} on deck`,
|
||||
html: `<div style="font-family:-apple-system,Segoe UI,Helvetica,Arial,sans-serif;max-width:600px;margin:0 auto;padding:12px">${htmlParts.join("\n")}</div>`,
|
||||
text: textParts.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export interface SendReport {
|
||||
sent: boolean;
|
||||
reason: string;
|
||||
digest: Digest;
|
||||
}
|
||||
|
||||
/** Send today's digest exactly once per ET date (unless forced). */
|
||||
export async function sendDigest(
|
||||
env: Env,
|
||||
date: string,
|
||||
opts: { force?: boolean; dry?: boolean } = {},
|
||||
): Promise<SendReport> {
|
||||
const digest = await buildDigest(env, date);
|
||||
if (opts.dry) return { sent: false, reason: "dry run", digest };
|
||||
|
||||
const already = await env.DB.prepare("SELECT sent_at FROM email_log WHERE date = ?")
|
||||
.bind(date)
|
||||
.first();
|
||||
if (already && !opts.force) return { sent: false, reason: "already sent today", digest };
|
||||
|
||||
await env.EMAIL.send({
|
||||
to: env.TO_EMAIL,
|
||||
from: { email: env.FROM_EMAIL, name: "SRS" },
|
||||
subject: digest.subject,
|
||||
html: digest.html,
|
||||
text: digest.text,
|
||||
});
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO email_log (date, sent_at) VALUES (?, ?) ON CONFLICT(date) DO UPDATE SET sent_at = excluded.sent_at",
|
||||
)
|
||||
.bind(date, new Date().toISOString())
|
||||
.run();
|
||||
return { sent: true, reason: already ? "forced re-send" : "sent", digest };
|
||||
}
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Saturday review issue: created at midnight ET so the 8 AM digest can link
|
||||
* to it; scored when the issue closes (webhook).
|
||||
*
|
||||
* The gate half is a topic-blind quiz — one unsolved optional problem per
|
||||
* topic scheduled this week (which is why daily drills only draw from
|
||||
* earlier weeks). The recap half is generated data, deliberately NOT a task
|
||||
* list: the week's solved problems are already scheduled by the +2 ladder,
|
||||
* and re-assigning them on Saturday would be massed practice.
|
||||
*
|
||||
* Label is `review` (the retired Actions system owned `gate`). Creation is
|
||||
* idempotent by week; deferred Hards never appear.
|
||||
*/
|
||||
import {
|
||||
type ProblemRow,
|
||||
type Result,
|
||||
SCHEDULE,
|
||||
campaignWeek,
|
||||
isoWeek,
|
||||
rng,
|
||||
sample,
|
||||
streak,
|
||||
weekMonday,
|
||||
} from "./srs.ts";
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
const PASS_TARGET = 0.7;
|
||||
const REVIEW_LABEL = "review";
|
||||
const MAX_GATE_PROBLEMS = 5;
|
||||
|
||||
function capitalized(difficulty: string): string {
|
||||
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
||||
}
|
||||
|
||||
/** Unsolved problems of a topic in a set — never attempted, never drilled. */
|
||||
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM problems
|
||||
WHERE topic_issue = ?1 AND set_label = ?2 AND stage = 'new' AND defer_until IS NULL
|
||||
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = problems.lc_number)
|
||||
ORDER BY lc_number`,
|
||||
)
|
||||
.bind(topic, set)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface CreateReport {
|
||||
created: boolean;
|
||||
issue?: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export async function createReviewIssue(env: Env, gh: GitHub, date: string): Promise<CreateReport> {
|
||||
const db = env.DB;
|
||||
const week = campaignWeek(date);
|
||||
const iso = isoWeek(date);
|
||||
const title = `Review — Week ${week}`;
|
||||
|
||||
const existing = await db
|
||||
.prepare("SELECT issue FROM gates WHERE week = ? AND issue IS NOT NULL")
|
||||
.bind(iso)
|
||||
.first<{ issue: number }>();
|
||||
if (existing) {
|
||||
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
|
||||
}
|
||||
|
||||
// Topics scheduled this week; review-only weeks (Sep 23 onward) sample
|
||||
// across every topic that still has unsolved optional problems.
|
||||
let topics = Object.entries(SCHEDULE)
|
||||
.filter(([d]) => campaignWeek(d) === week)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([, t]) => t);
|
||||
if (topics.length === 0) {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT DISTINCT topic_issue AS t FROM problems
|
||||
WHERE set_label = 'optional' AND stage = 'new'
|
||||
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||
ORDER BY topic_issue`,
|
||||
)
|
||||
.all<{ t: number }>();
|
||||
topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
}
|
||||
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
|
||||
|
||||
const random = rng(`gate-${iso}`);
|
||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||
for (const topic of topics) {
|
||||
const optional = await freshPool(db, topic, "optional");
|
||||
if (optional.length > 0) {
|
||||
picks.push({ p: sample(optional, 1, random)[0]!, fallback: false });
|
||||
continue;
|
||||
}
|
||||
const core = await freshPool(db, topic, "core");
|
||||
if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true });
|
||||
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
||||
}
|
||||
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
|
||||
|
||||
// Recap: everything attempted this week, plus streak — data, not tasks.
|
||||
const monday = weekMonday(date);
|
||||
const { results: attempts } = await db
|
||||
.prepare(
|
||||
`SELECT a.lc_number, a.date, a.kind, a.result FROM attempts a
|
||||
WHERE a.date >= ? AND a.date < ? AND a.source != 'import'
|
||||
ORDER BY a.date, a.id`,
|
||||
)
|
||||
.bind(monday, date)
|
||||
.all<{ lc_number: number; date: string; kind: string; result: string }>();
|
||||
const reviewsDone = attempts.filter((a) => a.kind === "review" && a.result === "pass").length;
|
||||
const currentStreak = await streak(db, date);
|
||||
|
||||
const gateLines = picks.map(
|
||||
({ p, fallback }) =>
|
||||
`- LC ${p.lc_number} — ${capitalized(p.difficulty)}${fallback ? " *(core fallback — optional pool exhausted)*" : ""}`,
|
||||
);
|
||||
const recapLines = attempts.length
|
||||
? attempts.map(
|
||||
(a) => `| LC ${a.lc_number} | ${a.kind} | ${a.result === "pass" ? "✅" : "❌"} | ${a.date} |`,
|
||||
)
|
||||
: ["| — | no attempts logged this week | | |"];
|
||||
const body = [
|
||||
"## Gate — blind set",
|
||||
`**Target: ${Math.round(PASS_TARGET * 100)}% first-attempt pass rate. Timed. Narrate out loud.**`,
|
||||
"No topics given. Log with `/done <n> pass|fail`, then close this issue.",
|
||||
...gateLines,
|
||||
"",
|
||||
`## Week ${week} recap`,
|
||||
`${attempts.length} attempts · ${reviewsDone} reviews passed · streak ${currentStreak}`,
|
||||
"",
|
||||
"| Problem | Kind | Result | Day |",
|
||||
"| --- | --- | --- | --- |",
|
||||
...recapLines,
|
||||
].join("\n");
|
||||
|
||||
// Ensure the `review` label exists (422 = already there).
|
||||
try {
|
||||
await gh.rest(`/repos/${gh.repo}/labels`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: REVIEW_LABEL,
|
||||
color: "5319e7",
|
||||
description: "Weekly blind gate + recap",
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!String(err).includes("422")) throw err;
|
||||
}
|
||||
|
||||
const milestone = await db
|
||||
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
|
||||
.bind(topics[0])
|
||||
.first<{ milestone: number }>();
|
||||
|
||||
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
body,
|
||||
labels: [REVIEW_LABEL],
|
||||
milestone: milestone?.milestone,
|
||||
}),
|
||||
})) as { number: number };
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO gates (week, issue, problems) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(week) DO UPDATE SET issue = ?2, problems = ?3`,
|
||||
)
|
||||
.bind(iso, created.number, JSON.stringify(picks.map(({ p }) => p.lc_number)))
|
||||
.run();
|
||||
|
||||
// Last week's boosted topics had their remedial week — clear the flags.
|
||||
await db.prepare("UPDATE topics SET boost = 0 WHERE boost = 1").run();
|
||||
|
||||
return { created: true, issue: created.number, reason: `created #${created.number}` };
|
||||
}
|
||||
|
||||
export interface ScoreReport {
|
||||
scored: boolean;
|
||||
rate?: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grade a closed review issue: first-attempt pass rate over its gate set.
|
||||
* Unlogged problems count as failures — skipping a gate problem is not a
|
||||
* pass. Boosts topics that failed a gate problem or reached 2 drill misses;
|
||||
* miss counters reset once consumed. The badge/charts read D1 live, so
|
||||
* "refreshing the badge" is this row update.
|
||||
*/
|
||||
export async function scoreReview(
|
||||
env: Env,
|
||||
gh: GitHub,
|
||||
issueNumber: number,
|
||||
date: string,
|
||||
): Promise<ScoreReport> {
|
||||
const db = env.DB;
|
||||
const gate = await db
|
||||
.prepare("SELECT week, problems, pass_rate FROM gates WHERE issue = ?")
|
||||
.bind(issueNumber)
|
||||
.first<{ week: number; problems: string; pass_rate: number | null }>();
|
||||
if (!gate) return { scored: false, reason: `issue #${issueNumber} has no gate record` };
|
||||
if (gate.pass_rate !== null) return { scored: false, rate: gate.pass_rate, reason: "already scored" };
|
||||
|
||||
const lcs: number[] = JSON.parse(gate.problems);
|
||||
const results: Record<number, Result | undefined> = {};
|
||||
for (const lc of lcs) {
|
||||
const row = await db
|
||||
.prepare("SELECT result FROM attempts WHERE lc_number = ? AND kind = 'gate' ORDER BY id LIMIT 1")
|
||||
.bind(lc)
|
||||
.first<{ result: Result }>();
|
||||
results[lc] = row?.result;
|
||||
}
|
||||
const passes = lcs.filter((lc) => results[lc] === "pass");
|
||||
const unlogged = lcs.filter((lc) => results[lc] === undefined);
|
||||
const rate = passes.length / lcs.length;
|
||||
|
||||
await db
|
||||
.prepare("UPDATE gates SET pass_rate = ?, closed_on = ? WHERE issue = ?")
|
||||
.bind(rate, date, issueNumber)
|
||||
.run();
|
||||
|
||||
const boosted: number[] = [];
|
||||
for (const lc of lcs) {
|
||||
if (results[lc] !== "fail") continue;
|
||||
const p = await db
|
||||
.prepare("SELECT topic_issue FROM problems WHERE lc_number = ?")
|
||||
.bind(lc)
|
||||
.first<{ topic_issue: number }>();
|
||||
if (p) boosted.push(p.topic_issue);
|
||||
}
|
||||
const { results: missed } = await db
|
||||
.prepare("SELECT issue FROM topics WHERE misses >= 2")
|
||||
.all<{ issue: number }>();
|
||||
boosted.push(...missed.map((m) => m.issue));
|
||||
const boostSet = [...new Set(boosted)];
|
||||
if (boostSet.length) {
|
||||
await db.batch(
|
||||
boostSet.map((t) => db.prepare("UPDATE topics SET boost = 1, misses = 0 WHERE issue = ?").bind(t)),
|
||||
);
|
||||
}
|
||||
|
||||
const passed = rate >= PASS_TARGET;
|
||||
const summary = [
|
||||
`## Gate — Week ${gate.week}: **${Math.round(rate * 100)}%** first-attempt (target ${Math.round(PASS_TARGET * 100)}%) — ${passed ? "✅ pass" : "❌ fail"}`,
|
||||
"",
|
||||
...lcs.map((lc) => {
|
||||
const r = results[lc];
|
||||
return `- LC ${lc} — ${r === "pass" ? "✅ pass" : r === "fail" ? "❌ fail" : "⬜ not logged (counted as fail)"}`;
|
||||
}),
|
||||
"",
|
||||
unlogged.length ? `${unlogged.length} problem(s) were never logged.` : "",
|
||||
boostSet.length
|
||||
? `Boosted topics for next week's drills: ${boostSet.map((t) => `#${t}`).join(", ")}.`
|
||||
: "No topics boosted.",
|
||||
"",
|
||||
`Live charts: ${env.PUBLIC_URL}/chart/progress.svg · progress: ${env.DOCS_URL}/progress`,
|
||||
]
|
||||
.filter((l) => l !== "")
|
||||
.join("\n");
|
||||
await gh.comment(issueNumber, summary);
|
||||
|
||||
return { scored: true, rate, reason: `scored ${Math.round(rate * 100)}%` };
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* GitHub REST + GraphQL client on GH_PAT. Hand-rolled fetch, no SDK —
|
||||
* matching the repo's dependency-free automation rule.
|
||||
*/
|
||||
|
||||
export interface GitHub {
|
||||
repo: string;
|
||||
rest(path: string, init?: RequestInit): Promise<unknown>;
|
||||
graphql(query: string, variables?: Record<string, unknown>): Promise<unknown>;
|
||||
list(path: string): AsyncGenerator<unknown, void, void>;
|
||||
/** Comment first, then close — a failed close still leaves a visible note. */
|
||||
closeIssue(issue: number, comment: string): Promise<void>;
|
||||
comment(issue: number, body: string): Promise<void>;
|
||||
react(commentId: number, content: string): Promise<void>;
|
||||
}
|
||||
|
||||
const PER_PAGE = 100;
|
||||
|
||||
export function github(token: string, repo: string): GitHub {
|
||||
async function rest(path: string, init: RequestInit = {}): Promise<unknown> {
|
||||
const res = await fetch(`https://api.github.com${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
authorization: `Bearer ${token}`,
|
||||
"user-agent": "srs-api",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
...(init.body ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`);
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
async function graphql(
|
||||
query: string,
|
||||
variables: Record<string, unknown> = {},
|
||||
): Promise<unknown> {
|
||||
const res = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
"content-type": "application/json",
|
||||
"user-agent": "srs-api",
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
|
||||
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
|
||||
if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; "));
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
async function* list(path: string): AsyncGenerator<unknown, void, void> {
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
for (let page = 1; ; page++) {
|
||||
const batch = await rest(`${path}${sep}per_page=${PER_PAGE}&page=${page}`);
|
||||
if (!Array.isArray(batch)) throw new Error(`unexpected ${path} payload: not an array`);
|
||||
yield* batch;
|
||||
if (batch.length < PER_PAGE) return;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
repo,
|
||||
rest,
|
||||
graphql,
|
||||
list,
|
||||
async closeIssue(issue, comment) {
|
||||
await rest(`/repos/${repo}/issues/${issue}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: comment }),
|
||||
});
|
||||
await rest(`/repos/${repo}/issues/${issue}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ state: "closed", state_reason: "completed" }),
|
||||
});
|
||||
},
|
||||
async comment(issue, body) {
|
||||
await rest(`/repos/${repo}/issues/${issue}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
},
|
||||
async react(commentId, content) {
|
||||
await rest(`/repos/${repo}/issues/comments/${commentId}/reactions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* SRS Worker entry: fetch routes + DST-proof cron dispatch.
|
||||
*
|
||||
* Direction of truth: D1 owns SRS state; GitHub issues own the catalog;
|
||||
* the bundled schedule.json owns the calendar. Every handler writes D1
|
||||
* first and runs GitHub/Project side effects afterwards via ctx.waitUntil —
|
||||
* a mirror failure logs a warning and never loses a state write.
|
||||
*
|
||||
* Writes are owner-only (webhook author check, HMAC one-tap links, bearer
|
||||
* admin routes); everything else is read-only public.
|
||||
*/
|
||||
import { reconcileCatalog } from "./catalog.ts";
|
||||
import { gateBadge, heatmapChart, ladderChart, progressChart } from "./charts.ts";
|
||||
import { sendDigest } from "./digest.ts";
|
||||
import { createReviewIssue, scoreReview } from "./gate.ts";
|
||||
import { type GitHub, github } from "./github.ts";
|
||||
import { timingSafeEqual, verifyLink, verifyWebhook } from "./links.ts";
|
||||
import { type Mirror, projectMirror } from "./mirror.ts";
|
||||
import {
|
||||
CAMPAIGN_START,
|
||||
type LogOutcome,
|
||||
daysBetween,
|
||||
etDate,
|
||||
etHour,
|
||||
logAttempt,
|
||||
weekdayOf,
|
||||
} from "./srs.ts";
|
||||
import { buildStats } from "./stats.ts";
|
||||
|
||||
// ── shared side effects after a state write ──────────────────────
|
||||
|
||||
/**
|
||||
* Mirror one logged attempt into GitHub: Project fields always; on a
|
||||
* first-ever pass also close the problem's sub-issue (comment first — repo
|
||||
* convention). Runs inside ctx.waitUntil; failures are warnings.
|
||||
*/
|
||||
async function mirrorOutcome(env: Env, outcome: LogOutcome, source: string): Promise<void> {
|
||||
if (outcome.error || outcome.duplicate) return;
|
||||
const gh = github(env.GH_PAT, env.REPO);
|
||||
const mirror: Mirror = projectMirror(gh, env.REPO.split("/")[0]!);
|
||||
await mirror.setStage(outcome.issue, outcome.stage);
|
||||
await mirror.setTargetDate(outcome.issue, outcome.next_review);
|
||||
if (outcome.first) await mirror.setFirstAttempt(outcome.issue, outcome.result);
|
||||
if (outcome.first && outcome.result === "pass") {
|
||||
try {
|
||||
await gh.closeIssue(
|
||||
outcome.issue,
|
||||
`First attempt passed (${outcome.kind}, via ${source}) — entering the review ladder at +2. Logged by the SRS Worker.`,
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn(`close #${outcome.issue}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function outcomeLine(o: LogOutcome): string {
|
||||
if (o.error) return o.error;
|
||||
if (o.duplicate) return `LC ${o.lc}: already logged today — nothing changed`;
|
||||
return (
|
||||
`LC ${o.lc}: ${o.kind} ${o.result} → stage ${o.stage}` +
|
||||
(o.next_review ? `, review ${o.next_review}` : " — retired 🎉")
|
||||
);
|
||||
}
|
||||
|
||||
// ── one-tap email links ──────────────────────────────────────────
|
||||
|
||||
function page(title: string, body: string, status = 200): Response {
|
||||
return new Response(
|
||||
`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<body style="font-family:-apple-system,Segoe UI,sans-serif;max-width:420px;margin:15vh auto;padding:0 16px;text-align:center">
|
||||
<h2>${title}</h2><p style="color:#57606a">${body}</p></body>`,
|
||||
{ status, headers: { "content-type": "text/html; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
|
||||
async function handleTap(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const lc = Number(url.searchParams.get("p"));
|
||||
const result = url.searchParams.get("r");
|
||||
const date = url.searchParams.get("d") ?? "";
|
||||
const sig = url.searchParams.get("sig") ?? "";
|
||||
if (!Number.isFinite(lc) || (result !== "pass" && result !== "fail") || !date) {
|
||||
return page("Bad link", "Missing or malformed parameters.", 400);
|
||||
}
|
||||
if (!(await verifyLink(env.LINK_KEY, lc, result, date, sig))) {
|
||||
return page("Bad signature", "This link was not signed by the SRS.", 403);
|
||||
}
|
||||
const today = etDate(new Date());
|
||||
if (daysBetween(date, today) > 3 || daysBetween(today, date) > 1) {
|
||||
return page("Link expired", "Older than 3 days — log it with a /done comment instead.", 410);
|
||||
}
|
||||
|
||||
const outcome = await logAttempt(env.DB, { lc, date, result, source: "email" });
|
||||
if (outcome.error) return page("Not logged", outcome.error, 422);
|
||||
if (outcome.duplicate) {
|
||||
return page("Already logged ✓", `LC ${lc} was already recorded today. Nothing changed.`);
|
||||
}
|
||||
ctx.waitUntil(mirrorOutcome(env, outcome, "email"));
|
||||
return page(
|
||||
result === "pass" ? "Logged ✅" : "Logged — back to +2",
|
||||
outcomeLine(outcome),
|
||||
);
|
||||
}
|
||||
|
||||
// ── GitHub webhook ───────────────────────────────────────────────
|
||||
|
||||
interface CommentEvent {
|
||||
action: string;
|
||||
issue: { number: number; title: string; labels: { name: string }[] };
|
||||
comment: { id: number; body: string; user: { login: string } };
|
||||
}
|
||||
|
||||
async function handleWebhook(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const raw = await request.arrayBuffer();
|
||||
const ok = await verifyWebhook(env.WEBHOOK_SECRET, raw, request.headers.get("x-hub-signature-256"));
|
||||
if (!ok) return new Response("bad signature", { status: 401 });
|
||||
|
||||
const event = request.headers.get("x-github-event");
|
||||
const payload: unknown = JSON.parse(new TextDecoder().decode(raw));
|
||||
const owner = env.REPO.split("/")[0]!;
|
||||
const today = etDate(new Date());
|
||||
|
||||
if (event === "issues") {
|
||||
if (!payload || typeof payload !== "object" || !("action" in payload) || !("issue" in payload)) {
|
||||
return new Response("ignored");
|
||||
}
|
||||
const p = payload as { action: string; issue: { number: number; labels: { name: string }[] } };
|
||||
if (p.action === "closed" && p.issue.labels.some((l) => l.name === "review")) {
|
||||
const gh = github(env.GH_PAT, env.REPO);
|
||||
const report = await scoreReview(env, gh, p.issue.number, today);
|
||||
return Response.json(report);
|
||||
}
|
||||
return new Response("ignored");
|
||||
}
|
||||
|
||||
if (event !== "issue_comment") return new Response("ignored");
|
||||
const p = payload as CommentEvent;
|
||||
if (p.action !== "created") return new Response("ignored");
|
||||
if (p.comment.user.login !== owner) return new Response("ignored (owner only)");
|
||||
|
||||
const commands: { lc: number; result: "pass" | "fail" }[] = [];
|
||||
const errors: string[] = [];
|
||||
for (const rawLine of p.comment.body.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line.startsWith("/done")) continue;
|
||||
const m = line.match(/^\/done\s+(\d+)\s+(pass|fail)\s*$/);
|
||||
if (m) commands.push({ lc: Number(m[1]), result: m[2] as "pass" | "fail" });
|
||||
else errors.push(`cannot parse \`${line}\` — expected \`/done <number> <pass|fail>\``);
|
||||
}
|
||||
if (commands.length === 0 && errors.length === 0) return new Response("no commands");
|
||||
|
||||
// Comments on a review issue log its gate problems as kind='gate'.
|
||||
const gateRow = await env.DB.prepare("SELECT problems FROM gates WHERE issue = ?")
|
||||
.bind(p.issue.number)
|
||||
.first<{ problems: string }>();
|
||||
const gateLcs = new Set<number>(gateRow ? JSON.parse(gateRow.problems) : []);
|
||||
|
||||
const outcomes: LogOutcome[] = [];
|
||||
for (const cmd of commands) {
|
||||
const outcome = await logAttempt(env.DB, {
|
||||
lc: cmd.lc,
|
||||
date: today,
|
||||
result: cmd.result,
|
||||
source: "webhook",
|
||||
gate: gateLcs.has(cmd.lc),
|
||||
});
|
||||
if (outcome.error) errors.push(outcome.error);
|
||||
else outcomes.push(outcome);
|
||||
}
|
||||
|
||||
const gh = github(env.GH_PAT, env.REPO);
|
||||
ctx.waitUntil(
|
||||
(async () => {
|
||||
for (const outcome of outcomes) await mirrorOutcome(env, outcome, "webhook");
|
||||
try {
|
||||
if (errors.length === 0) {
|
||||
await gh.react(p.comment.id, "+1");
|
||||
} else {
|
||||
await gh.comment(
|
||||
p.issue.number,
|
||||
`Could not log everything:\n\n${errors.map((e) => `- ${e}`).join("\n")}` +
|
||||
(outcomes.length
|
||||
? `\n\nApplied anyway:\n\n${outcomes.map((o) => `- ${outcomeLine(o)}`).join("\n")}`
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`webhook feedback: ${err}`);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
return Response.json({ applied: outcomes.map(outcomeLine), errors });
|
||||
}
|
||||
|
||||
// ── admin (bearer LINK_KEY, timing-safe) ─────────────────────────
|
||||
|
||||
async function adminAuthorized(request: Request, env: Env): Promise<boolean> {
|
||||
const header = request.headers.get("authorization") ?? "";
|
||||
if (!header.startsWith("Bearer ")) return false;
|
||||
return timingSafeEqual(header.slice(7), env.LINK_KEY);
|
||||
}
|
||||
|
||||
async function handleAdmin(request: Request, env: Env, path: string): Promise<Response> {
|
||||
if (!(await adminAuthorized(request, env))) return new Response("unauthorized", { status: 401 });
|
||||
const url = new URL(request.url);
|
||||
const date = url.searchParams.get("date") ?? etDate(new Date());
|
||||
const gh = () => github(env.GH_PAT, env.REPO);
|
||||
|
||||
if (path === "/admin/reconcile") {
|
||||
return Response.json(await reconcileCatalog(env.DB, gh()));
|
||||
}
|
||||
if (path === "/admin/digest") {
|
||||
const report = await sendDigest(env, date, {
|
||||
dry: url.searchParams.get("dry") === "1",
|
||||
force: url.searchParams.get("force") === "1",
|
||||
});
|
||||
if (url.searchParams.get("dry") === "1") {
|
||||
return new Response(report.digest.html, { headers: { "content-type": "text/html" } });
|
||||
}
|
||||
return Response.json({ sent: report.sent, reason: report.reason, subject: report.digest.subject });
|
||||
}
|
||||
if (path === "/admin/review") {
|
||||
return Response.json(await createReviewIssue(env, gh(), date));
|
||||
}
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
|
||||
// ── router ───────────────────────────────────────────────────────
|
||||
|
||||
const SVG_HEADERS = {
|
||||
"content-type": "image/svg+xml",
|
||||
// GitHub Camo honors this; 5 minutes is the freshness floor for README charts.
|
||||
"cache-control": "public, max-age=300",
|
||||
};
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
try {
|
||||
if (path === "/log" && request.method === "GET") return await handleTap(request, env, ctx);
|
||||
if (path === "/webhook/github" && request.method === "POST") {
|
||||
return await handleWebhook(request, env, ctx);
|
||||
}
|
||||
if (path.startsWith("/admin/") && request.method === "POST") {
|
||||
return await handleAdmin(request, env, path);
|
||||
}
|
||||
|
||||
if (path === "/chart/progress.svg") {
|
||||
return new Response(await progressChart(env.DB), { headers: SVG_HEADERS });
|
||||
}
|
||||
if (path === "/chart/ladder.svg") {
|
||||
return new Response(await ladderChart(env.DB), { headers: SVG_HEADERS });
|
||||
}
|
||||
if (path === "/chart/heatmap.svg") {
|
||||
return new Response(await heatmapChart(env.DB, CAMPAIGN_START, 56), { headers: SVG_HEADERS });
|
||||
}
|
||||
if (path === "/badge/gate.svg") {
|
||||
return new Response(await gateBadge(env.DB), { headers: SVG_HEADERS });
|
||||
}
|
||||
|
||||
if (path === "/api/stats") {
|
||||
const cors = {
|
||||
"access-control-allow-origin": env.DOCS_ORIGIN,
|
||||
"access-control-allow-methods": "GET",
|
||||
vary: "Origin",
|
||||
};
|
||||
if (request.method === "OPTIONS") return new Response(null, { headers: cors });
|
||||
const stats = await buildStats(env.DB, etDate(new Date()));
|
||||
return Response.json(stats, { headers: cors });
|
||||
}
|
||||
|
||||
return new Response("srs-api", { status: path === "/" ? 200 : 404 });
|
||||
} catch (err) {
|
||||
console.error(`unhandled ${request.method} ${path}: ${err}`);
|
||||
return new Response("internal error", { status: 500 });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* DST-proof cron dispatch: crons fire at both possible UTC hours; the
|
||||
* computed ET hour decides. 8 AM daily → catalog reconcile + digest;
|
||||
* midnight Saturday → review issue (so the 8 AM digest can link to it).
|
||||
*/
|
||||
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||
const now = new Date(controller.scheduledTime);
|
||||
const hour = etHour(now);
|
||||
const date = etDate(now);
|
||||
|
||||
if (hour === 8) {
|
||||
try {
|
||||
const report = await reconcileCatalog(env.DB, github(env.GH_PAT, env.REPO));
|
||||
console.log(`catalog: ${report.topics} topics, ${report.problems} problems`);
|
||||
} catch (err) {
|
||||
console.warn(`catalog reconcile failed (digest still goes out): ${err}`);
|
||||
}
|
||||
const report = await sendDigest(env, date);
|
||||
console.log(`digest ${date}: ${report.reason}`);
|
||||
return;
|
||||
}
|
||||
if (hour === 0 && weekdayOf(date) === 6) {
|
||||
const report = await createReviewIssue(env, github(env.GH_PAT, env.REPO), date);
|
||||
console.log(`review issue ${date}: ${report.reason}`);
|
||||
return;
|
||||
}
|
||||
console.log(`cron at ET hour ${hour} on ${date}: no-op (DST guard)`);
|
||||
void ctx;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* One-tap email links: `GET /log?p=704&r=pass&d=2026-08-31&sig=<hmac>`.
|
||||
* The sig is HMAC-SHA256 over `p|r|d` with LINK_KEY (WebCrypto, hex).
|
||||
* Verification is constant-time; links older than 3 days are rejected.
|
||||
*/
|
||||
|
||||
async function hmacKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
new TextEncoder().encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
}
|
||||
|
||||
export async function signLink(
|
||||
key: string,
|
||||
p: number,
|
||||
r: string,
|
||||
d: string,
|
||||
): Promise<string> {
|
||||
const mac = await crypto.subtle.sign(
|
||||
"HMAC",
|
||||
await hmacKey(key),
|
||||
new TextEncoder().encode(`${p}|${r}|${d}`),
|
||||
);
|
||||
return [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
export async function verifyLink(
|
||||
key: string,
|
||||
p: number,
|
||||
r: string,
|
||||
d: string,
|
||||
sig: string,
|
||||
): Promise<boolean> {
|
||||
if (!/^[0-9a-f]{64}$/.test(sig)) return false;
|
||||
const bytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) bytes[i] = Number.parseInt(sig.slice(i * 2, i * 2 + 2), 16);
|
||||
// crypto.subtle.verify is constant-time; never compare hex strings directly.
|
||||
return crypto.subtle.verify(
|
||||
"HMAC",
|
||||
await hmacKey(key),
|
||||
bytes,
|
||||
new TextEncoder().encode(`${p}|${r}|${d}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Constant-time equality for webhook signatures and admin bearer keys. */
|
||||
export async function timingSafeEqual(a: string, b: string): Promise<boolean> {
|
||||
// HMAC both sides with a random key: unequal-length inputs and content
|
||||
// differences are equally invisible to timing.
|
||||
const key = await crypto.subtle.generateKey({ name: "HMAC", hash: "SHA-256" }, false, [
|
||||
"sign",
|
||||
]);
|
||||
const enc = new TextEncoder();
|
||||
const [ma, mb] = await Promise.all([
|
||||
crypto.subtle.sign("HMAC", key, enc.encode(a)),
|
||||
crypto.subtle.sign("HMAC", key, enc.encode(b)),
|
||||
]);
|
||||
const va = new Uint8Array(ma);
|
||||
const vb = new Uint8Array(mb);
|
||||
let diff = 0;
|
||||
for (let i = 0; i < va.length; i++) diff |= va[i]! ^ vb[i]!;
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
/** GitHub webhook `X-Hub-Signature-256: sha256=<hex>` verification. */
|
||||
export async function verifyWebhook(
|
||||
secret: string,
|
||||
body: ArrayBuffer,
|
||||
header: string | null,
|
||||
): Promise<boolean> {
|
||||
if (!header?.startsWith("sha256=")) return false;
|
||||
const mac = await crypto.subtle.sign("HMAC", await hmacKey(secret), body);
|
||||
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
return timingSafeEqual(header.slice(7), expected);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Best-effort mirror into the "Interview Prep" user Project — ported from
|
||||
* scripts/srs-project.ts. D1 is the truth; a GraphQL failure here becomes a
|
||||
* console warning and never blocks a state write. Only problem issues are
|
||||
* ever passed in, so topic rows' Target Date is never written.
|
||||
*
|
||||
* Field/option IDs are resolved by NAME per invocation (a Worker isolate is
|
||||
* short-lived; the two extra queries per mirror burst are irrelevant at this
|
||||
* volume and survive field re-creation).
|
||||
*/
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
const PROJECT_TITLE = "Interview Prep";
|
||||
|
||||
interface SelectField {
|
||||
id: string;
|
||||
options: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ProjectInfo {
|
||||
id: string;
|
||||
targetDate: string;
|
||||
srsStage: SelectField;
|
||||
firstAttempt: SelectField;
|
||||
}
|
||||
|
||||
export interface Mirror {
|
||||
setTargetDate(issue: number, date: string | null): Promise<void>;
|
||||
setStage(issue: number, stage: string): Promise<void>;
|
||||
setFirstAttempt(issue: number, result: string): Promise<void>;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function projectMirror(gh: GitHub, owner: string): Mirror {
|
||||
const warnings: string[] = [];
|
||||
let info: ProjectInfo | undefined;
|
||||
const itemIds = new Map<number, string | undefined>();
|
||||
|
||||
async function resolve(): Promise<ProjectInfo> {
|
||||
if (info) return info;
|
||||
const data = (await gh.graphql(
|
||||
`query($owner: String!, $title: String!) {
|
||||
user(login: $owner) {
|
||||
projectsV2(first: 10, query: $title) {
|
||||
nodes {
|
||||
id title
|
||||
fields(first: 30) {
|
||||
nodes {
|
||||
... on ProjectV2FieldCommon { id name dataType }
|
||||
... on ProjectV2SingleSelectField { id name options { id name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, title: PROJECT_TITLE },
|
||||
)) as {
|
||||
user: {
|
||||
projectsV2: {
|
||||
nodes: {
|
||||
id: string;
|
||||
title: string;
|
||||
fields: {
|
||||
nodes: { id: string; name: string; options?: { id: string; name: string }[] }[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
|
||||
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
|
||||
const select = (name: string): SelectField => {
|
||||
const f = node.fields.nodes.find((n) => n.name === name);
|
||||
if (!f?.options) throw new Error(`single-select field "${name}" missing`);
|
||||
return { id: f.id, options: Object.fromEntries(f.options.map((o) => [o.name, o.id])) };
|
||||
};
|
||||
const date = node.fields.nodes.find((n) => n.name === "Target Date");
|
||||
if (!date) throw new Error(`date field "Target Date" missing`);
|
||||
info = {
|
||||
id: node.id,
|
||||
targetDate: date.id,
|
||||
srsStage: select("SRS Stage"),
|
||||
firstAttempt: select("First Attempt"),
|
||||
};
|
||||
return info;
|
||||
}
|
||||
|
||||
async function itemId(issue: number): Promise<string> {
|
||||
if (itemIds.has(issue)) {
|
||||
const cached = itemIds.get(issue);
|
||||
if (!cached) throw new Error(`issue #${issue} is not in the project`);
|
||||
return cached;
|
||||
}
|
||||
const project = await resolve();
|
||||
const [repoOwner, repoName] = gh.repo.split("/");
|
||||
const data = (await gh.graphql(
|
||||
`query($owner: String!, $name: String!, $issue: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issue(number: $issue) {
|
||||
projectItems(first: 10, includeArchived: true) {
|
||||
nodes { id project { id } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner: repoOwner, name: repoName, issue },
|
||||
)) as {
|
||||
repository: { issue: { projectItems: { nodes: { id: string; project: { id: string } }[] } } };
|
||||
};
|
||||
const item = data.repository.issue.projectItems.nodes.find((n) => n.project.id === project.id);
|
||||
itemIds.set(issue, item?.id);
|
||||
if (!item) throw new Error(`issue #${issue} is not in the project`);
|
||||
return item.id;
|
||||
}
|
||||
|
||||
async function setField(issue: number, fieldId: string, value: object): Promise<void> {
|
||||
const project = await resolve();
|
||||
await gh.graphql(
|
||||
`mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
|
||||
updateProjectV2ItemFieldValue(
|
||||
input: { projectId: $project, itemId: $item, fieldId: $field, value: $value }
|
||||
) { projectV2Item { id } }
|
||||
}`,
|
||||
{ project: project.id, item: await itemId(issue), field: fieldId, value },
|
||||
);
|
||||
}
|
||||
|
||||
async function attempt(what: string, op: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await op();
|
||||
} catch (err) {
|
||||
const message = `mirror ${what}: ${err instanceof Error ? err.message : String(err)}`;
|
||||
warnings.push(message);
|
||||
console.warn(message); // observability picks this up; never rethrow
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
warnings,
|
||||
setTargetDate: (issue, date) =>
|
||||
attempt(`Target Date #${issue}`, async () => {
|
||||
const project = await resolve();
|
||||
if (date === null) {
|
||||
await gh.graphql(
|
||||
`mutation($project: ID!, $item: ID!, $field: ID!) {
|
||||
clearProjectV2ItemFieldValue(
|
||||
input: { projectId: $project, itemId: $item, fieldId: $field }
|
||||
) { projectV2Item { id } }
|
||||
}`,
|
||||
{ project: project.id, item: await itemId(issue), field: project.targetDate },
|
||||
);
|
||||
} else {
|
||||
await setField(issue, project.targetDate, { date });
|
||||
}
|
||||
}),
|
||||
setStage: (issue, stage) =>
|
||||
attempt(`SRS Stage #${issue}`, async () => {
|
||||
const project = await resolve();
|
||||
const option = project.srsStage.options[stage];
|
||||
if (!option) throw new Error(`no option "${stage}"`);
|
||||
await setField(issue, project.srsStage.id, { singleSelectOptionId: option });
|
||||
}),
|
||||
setFirstAttempt: (issue, result) =>
|
||||
attempt(`First Attempt #${issue}`, async () => {
|
||||
const project = await resolve();
|
||||
const option = project.firstAttempt.options[result];
|
||||
if (!option) throw new Error(`no option "${result}"`);
|
||||
await setField(issue, project.firstAttempt.id, { singleSelectOptionId: option });
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* DST-guard and date-math checks with fixed instants — the acceptance proof
|
||||
* that the double crons fire exactly once at 8 AM ET (digest) and midnight
|
||||
* Saturday ET (review issue) on BOTH UTC offsets.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { addDays, campaignDay, campaignWeek, etDate, etHour, isoWeek, rng, sample, weekdayOf } from "./srs.ts";
|
||||
|
||||
describe("DST-proof cron guard", () => {
|
||||
test("digest fires only at ET hour 8 — EDT (UTC-4)", () => {
|
||||
// Cron pair 12:00 / 13:00 UTC during EDT (2026-08-29 is EDT).
|
||||
expect(etHour(new Date("2026-08-29T12:00:00Z"))).toBe(8); // fires
|
||||
expect(etHour(new Date("2026-08-29T13:00:00Z"))).toBe(9); // no-op
|
||||
});
|
||||
test("digest fires only at ET hour 8 — EST (UTC-5)", () => {
|
||||
// DST ends 2026-11-01; 2026-11-05 is EST.
|
||||
expect(etHour(new Date("2026-11-05T12:00:00Z"))).toBe(7); // no-op
|
||||
expect(etHour(new Date("2026-11-05T13:00:00Z"))).toBe(8); // fires
|
||||
});
|
||||
test("saturday review fires only at ET midnight — both offsets", () => {
|
||||
// EDT Saturday: 4 UTC = 0 ET fires, 5 UTC = 1 ET no-op.
|
||||
expect(etHour(new Date("2026-08-29T04:00:00Z"))).toBe(0);
|
||||
expect(weekdayOf(etDate(new Date("2026-08-29T04:00:00Z")))).toBe(6);
|
||||
expect(etHour(new Date("2026-08-29T05:00:00Z"))).toBe(1);
|
||||
// EST Saturday (2026-11-07): 5 UTC = 0 ET fires, 4 UTC = 11 PM FRIDAY ET.
|
||||
expect(etHour(new Date("2026-11-07T05:00:00Z"))).toBe(0);
|
||||
expect(weekdayOf(etDate(new Date("2026-11-07T05:00:00Z")))).toBe(6);
|
||||
expect(etHour(new Date("2026-11-07T04:00:00Z"))).toBe(23);
|
||||
expect(etDate(new Date("2026-11-07T04:00:00Z"))).toBe("2026-11-06"); // still Friday ET
|
||||
});
|
||||
});
|
||||
|
||||
describe("date math (noon-UTC anchored)", () => {
|
||||
test("addDays crosses the DST-end boundary without drift", () => {
|
||||
expect(addDays("2026-10-31", 2)).toBe("2026-11-02");
|
||||
expect(addDays("2026-08-24", 2)).toBe("2026-08-26");
|
||||
});
|
||||
test("campaign math", () => {
|
||||
expect(campaignDay("2026-08-17")).toBe(1);
|
||||
expect(campaignDay("2026-08-24")).toBe(8);
|
||||
expect(campaignWeek("2026-08-24")).toBe(2);
|
||||
expect(campaignWeek("2026-08-30")).toBe(2);
|
||||
expect(campaignWeek("2026-08-31")).toBe(3);
|
||||
});
|
||||
test("iso week is stable across a week", () => {
|
||||
expect(isoWeek("2026-08-24")).toBe(isoWeek("2026-08-29"));
|
||||
expect(isoWeek("2026-08-30")).toBe(isoWeek("2026-08-24")); // Sun ends the ISO week
|
||||
expect(isoWeek("2026-08-31")).toBe(isoWeek("2026-08-24") + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deterministic sampling", () => {
|
||||
test("same seed, same picks", () => {
|
||||
const pool = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
expect(sample(pool, 3, rng("drill-2026-08-31"))).toEqual(sample(pool, 3, rng("drill-2026-08-31")));
|
||||
expect(sample(pool, 3, rng("drill-2026-09-01"))).not.toEqual(sample(pool, 3, rng("drill-2026-08-31")));
|
||||
});
|
||||
});
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* SRS domain: ET dates, the interval ladder, deterministic sampling, and the
|
||||
* one write path for attempts — ported from scripts/srs.ts, re-homed on D1.
|
||||
*
|
||||
* D1 is the single source of truth. The stage names the review a problem must
|
||||
* pass NEXT (`+2` = due 2 days after last clean solve). Passing advances
|
||||
* new → +2 → +5 → +10 → retired; any failure resets to +2. A problem's
|
||||
* FIRST-ever log enters the ladder at +2 regardless of result: a pass earns
|
||||
* a +2 review, a fail must be re-solved just as soon.
|
||||
*
|
||||
* All dates are America/New_York calendar strings (YYYY-MM-DD); arithmetic is
|
||||
* anchored at noon UTC so DST edges cannot shift a date. Never raw Date math.
|
||||
*/
|
||||
import scheduleJson from "../data/schedule.json";
|
||||
|
||||
// ── dates (America/New_York) ─────────────────────────────────────
|
||||
|
||||
export const CAMPAIGN_START = "2026-08-17";
|
||||
export const CAMPAIGN_DAYS = 56;
|
||||
|
||||
const ET_DATE = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "America/New_York",
|
||||
dateStyle: "short",
|
||||
});
|
||||
const ET_HOUR = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "America/New_York",
|
||||
hour: "numeric",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
|
||||
/** ET calendar date of an instant. */
|
||||
export function etDate(now: Date): string {
|
||||
return ET_DATE.format(now);
|
||||
}
|
||||
|
||||
/** ET hour 0–23 of an instant — the DST-proof cron guard. */
|
||||
export function etHour(now: Date): number {
|
||||
return Number(ET_HOUR.format(now));
|
||||
}
|
||||
|
||||
/** Noon-UTC anchor: date-only arithmetic immune to DST edges. */
|
||||
function atNoon(date: string): Date {
|
||||
return new Date(`${date}T12:00:00Z`);
|
||||
}
|
||||
|
||||
export function addDays(date: string, days: number): string {
|
||||
return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function daysBetween(from: string, to: string): number {
|
||||
return Math.round((atNoon(to).getTime() - atNoon(from).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** 0 = Sunday … 6 = Saturday. */
|
||||
export function weekdayOf(date: string): number {
|
||||
return atNoon(date).getUTCDay();
|
||||
}
|
||||
|
||||
export function campaignDay(date: string): number {
|
||||
return daysBetween(CAMPAIGN_START, date) + 1;
|
||||
}
|
||||
|
||||
/** 1-based campaign week (Mon–Sun), aligned to CAMPAIGN_START. */
|
||||
export function campaignWeek(date: string): number {
|
||||
return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1;
|
||||
}
|
||||
|
||||
/** Monday of the date's campaign week. */
|
||||
export function weekMonday(date: string): string {
|
||||
return addDays(CAMPAIGN_START, (campaignWeek(date) - 1) * 7);
|
||||
}
|
||||
|
||||
export function isoWeek(date: string): number {
|
||||
const d = atNoon(date);
|
||||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||
const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
|
||||
}
|
||||
|
||||
export function prettyDate(date: string): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "UTC",
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(atNoon(date));
|
||||
}
|
||||
|
||||
// ── the schedule (bundled; humans edit api/data/schedule.json) ───
|
||||
|
||||
export const SCHEDULE: Record<string, number> = scheduleJson;
|
||||
|
||||
/** Week in which a topic was (or will be) taught. */
|
||||
export function topicWeek(topic: number): number | undefined {
|
||||
for (const [date, t] of Object.entries(SCHEDULE)) {
|
||||
if (t === topic) return campaignWeek(date);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── deterministic sampling ───────────────────────────────────────
|
||||
|
||||
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
||||
export function rng(seed: string): () => number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return () => {
|
||||
h = Math.imul(h ^ (h >>> 15), h | 1);
|
||||
h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
|
||||
return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** Up to n elements, Fisher–Yates order driven by the seeded PRNG. */
|
||||
export function sample<T>(pool: T[], n: number, random: () => number): T[] {
|
||||
const copy = [...pool];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j]!, copy[i]!];
|
||||
}
|
||||
return copy.slice(0, n);
|
||||
}
|
||||
|
||||
// ── rows ─────────────────────────────────────────────────────────
|
||||
|
||||
export type Stage = "new" | "+2" | "+5" | "+10" | "retired";
|
||||
export type Result = "pass" | "fail";
|
||||
export type Kind = "first" | "review" | "drill" | "gate";
|
||||
|
||||
export interface ProblemRow {
|
||||
lc_number: number;
|
||||
issue: number;
|
||||
topic_issue: number;
|
||||
title: string;
|
||||
difficulty: string;
|
||||
set_label: string;
|
||||
stage: Stage;
|
||||
next_review: string | null;
|
||||
defer_until: string | null;
|
||||
}
|
||||
|
||||
export const INTERVAL: Record<string, number> = { "+2": 2, "+5": 5, "+10": 10 };
|
||||
const NEXT_STAGE: Record<string, Stage> = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" };
|
||||
|
||||
// ── queries ──────────────────────────────────────────────────────
|
||||
|
||||
export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow | null> {
|
||||
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
||||
}
|
||||
|
||||
/** Reviews due on/before `date`, oldest first — the overflow carry order. */
|
||||
export async function dueReviews(db: D1Database, date: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM problems
|
||||
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||
AND (defer_until IS NULL OR defer_until <= ?1)
|
||||
ORDER BY next_review, lc_number`,
|
||||
)
|
||||
.bind(date)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blind drills: unsolved optional problems from topics taught in EARLIER
|
||||
* weeks (the current week's optional pool is reserved for Saturday's gate),
|
||||
* never repeated, seeded by date. Boosted topics contribute up to 2 extra.
|
||||
*/
|
||||
export async function pickDrills(
|
||||
db: D1Database,
|
||||
date: string,
|
||||
budget: number,
|
||||
): Promise<ProblemRow[]> {
|
||||
if (budget <= 0) return [];
|
||||
const week = campaignWeek(date);
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT p.*, t.boost AS boost FROM problems p
|
||||
JOIN topics t ON t.issue = p.topic_issue
|
||||
WHERE p.set_label = 'optional' AND p.stage = 'new'
|
||||
AND p.lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||
AND NOT EXISTS (SELECT 1 FROM attempts a WHERE a.lc_number = p.lc_number)
|
||||
ORDER BY p.lc_number`,
|
||||
)
|
||||
.all<ProblemRow & { boost: number }>();
|
||||
const pool = results.filter((p) => {
|
||||
const w = topicWeek(p.topic_issue);
|
||||
return w !== undefined && w < week;
|
||||
});
|
||||
const boosted = pool.filter((p) => p.boost === 1);
|
||||
const regular = pool.filter((p) => p.boost !== 1);
|
||||
const boostPicks = sample(boosted, Math.min(2, budget), rng(`boost-${date}`));
|
||||
const regularPicks = sample(
|
||||
regular,
|
||||
Math.min(2, Math.max(0, budget - boostPicks.length)),
|
||||
rng(`drill-${date}`),
|
||||
);
|
||||
return [...boostPicks, ...regularPicks].slice(0, budget);
|
||||
}
|
||||
|
||||
/** Consecutive days with ≥1 attempt, ending today or yesterday. */
|
||||
export async function streak(db: D1Database, today: string): Promise<number> {
|
||||
const { results } = await db
|
||||
.prepare("SELECT DISTINCT date FROM attempts ORDER BY date DESC LIMIT 90")
|
||||
.all<{ date: string }>();
|
||||
const days = new Set(results.map((r) => r.date));
|
||||
let cursor = days.has(today) ? today : addDays(today, -1);
|
||||
let n = 0;
|
||||
while (days.has(cursor)) {
|
||||
n++;
|
||||
cursor = addDays(cursor, -1);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── the one write path ───────────────────────────────────────────
|
||||
|
||||
export interface LogOutcome {
|
||||
lc: number;
|
||||
title: string;
|
||||
kind: Kind;
|
||||
result: Result;
|
||||
stage: Stage;
|
||||
next_review: string | null;
|
||||
/** First-ever attempt — caller closes the sub-issue on pass. */
|
||||
first: boolean;
|
||||
issue: number;
|
||||
/** Email one-tap replay: nothing changed. */
|
||||
duplicate: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an attempt and move the ladder. Ladder semantics live here and only
|
||||
* here — email one-taps, webhook /done lines, and gate scoring all converge.
|
||||
*
|
||||
* Email idempotency comes from the partial unique index on
|
||||
* (lc_number, date, kind) WHERE source='email': a replayed link inserts
|
||||
* nothing and must not touch the ladder. Webhook corrections (pass then fail
|
||||
* on the same day) remain legal — every webhook attempt appends.
|
||||
*/
|
||||
export async function logAttempt(
|
||||
db: D1Database,
|
||||
opts: { lc: number; date: string; result: Result; source: "email" | "webhook"; gate?: boolean },
|
||||
): Promise<LogOutcome> {
|
||||
const p = await getProblem(db, opts.lc);
|
||||
const nothing: LogOutcome = {
|
||||
lc: opts.lc,
|
||||
title: "",
|
||||
kind: "review",
|
||||
result: opts.result,
|
||||
stage: "new",
|
||||
next_review: null,
|
||||
first: false,
|
||||
issue: 0,
|
||||
duplicate: false,
|
||||
};
|
||||
if (!p) return { ...nothing, error: `LC ${opts.lc} is not in the curriculum` };
|
||||
if (p.stage === "retired") {
|
||||
return { ...nothing, title: p.title, issue: p.issue, error: `LC ${opts.lc} is already retired` };
|
||||
}
|
||||
|
||||
const attempted = await db
|
||||
.prepare("SELECT 1 AS x FROM attempts WHERE lc_number = ? LIMIT 1")
|
||||
.bind(opts.lc)
|
||||
.first();
|
||||
const first = !attempted && p.stage === "new";
|
||||
const kind: Kind = opts.gate ? "gate" : first ? (p.set_label === "optional" ? "drill" : "first") : "review";
|
||||
|
||||
const inserted = await db
|
||||
.prepare(
|
||||
`INSERT INTO attempts (lc_number, date, kind, result, source) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
)
|
||||
.bind(opts.lc, opts.date, kind, opts.result, opts.source)
|
||||
.run();
|
||||
if (opts.source === "email" && inserted.meta.changes === 0) {
|
||||
return { ...nothing, title: p.title, kind, stage: p.stage, next_review: p.next_review, issue: p.issue, duplicate: true };
|
||||
}
|
||||
|
||||
// Ladder move. First-ever logs enter at +2 for pass AND fail.
|
||||
let stage: Stage;
|
||||
if (first) {
|
||||
stage = "+2";
|
||||
} else if (opts.result === "pass") {
|
||||
stage = NEXT_STAGE[p.stage] ?? "retired";
|
||||
} else {
|
||||
stage = "+2";
|
||||
}
|
||||
const next = stage === "retired" ? null : addDays(opts.date, INTERVAL[stage]!);
|
||||
|
||||
const writes = [
|
||||
db.prepare(
|
||||
"UPDATE problems SET stage = ?, next_review = ?, defer_until = NULL WHERE lc_number = ?",
|
||||
).bind(stage, next, opts.lc),
|
||||
];
|
||||
if (first && kind === "drill") {
|
||||
writes.push(
|
||||
db.prepare("INSERT OR IGNORE INTO drill_pool_used (lc_number) VALUES (?)").bind(opts.lc),
|
||||
);
|
||||
if (opts.result === "fail") {
|
||||
writes.push(
|
||||
db.prepare("UPDATE topics SET misses = misses + 1 WHERE issue = ?").bind(p.topic_issue),
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.batch(writes);
|
||||
|
||||
return {
|
||||
lc: opts.lc,
|
||||
title: p.title,
|
||||
kind,
|
||||
result: opts.result,
|
||||
stage,
|
||||
next_review: next,
|
||||
first,
|
||||
issue: p.issue,
|
||||
duplicate: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* GET /api/stats — the one JSON document behind the docs progress page.
|
||||
* Read-only aggregation over D1; shape is the page's contract, change both
|
||||
* together. A problem counts as "done" once it entered the ladder
|
||||
* (stage != 'new').
|
||||
*/
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
CAMPAIGN_START,
|
||||
addDays,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
streak,
|
||||
} from "./srs.ts";
|
||||
|
||||
const PHASE_NAMES: Record<number, string> = {
|
||||
1: "I — Linear",
|
||||
2: "II — Nodal & Grid",
|
||||
3: "III — Hierarchical",
|
||||
4: "IV — Relational",
|
||||
5: "V — Decision Space",
|
||||
};
|
||||
|
||||
export async function buildStats(db: D1Database, today: string): Promise<object> {
|
||||
const { results: phaseRows } = await db
|
||||
.prepare(
|
||||
`SELECT t.milestone AS milestone, p.set_label AS set_label,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN p.stage != 'new' THEN 1 ELSE 0 END) AS done
|
||||
FROM problems p JOIN topics t ON t.issue = p.topic_issue
|
||||
WHERE t.milestone IS NOT NULL
|
||||
GROUP BY t.milestone, p.set_label
|
||||
ORDER BY t.milestone`,
|
||||
)
|
||||
.all<{ milestone: number; set_label: string; total: number; done: number }>();
|
||||
const phases = new Map<
|
||||
number,
|
||||
{ milestone: number; name: string } & Record<string, number | string>
|
||||
>();
|
||||
for (const row of phaseRows) {
|
||||
const phase =
|
||||
phases.get(row.milestone) ??
|
||||
({
|
||||
milestone: row.milestone,
|
||||
name: PHASE_NAMES[row.milestone] ?? `Phase ${row.milestone}`,
|
||||
core_done: 0,
|
||||
core_total: 0,
|
||||
optional_done: 0,
|
||||
optional_total: 0,
|
||||
deferred_done: 0,
|
||||
deferred_total: 0,
|
||||
} as { milestone: number; name: string } & Record<string, number | string>);
|
||||
phase[`${row.set_label}_done`] = row.done;
|
||||
phase[`${row.set_label}_total`] = row.total;
|
||||
phases.set(row.milestone, phase);
|
||||
}
|
||||
|
||||
const { results: ladderRows } = await db
|
||||
.prepare("SELECT stage, COUNT(*) AS n FROM problems GROUP BY stage")
|
||||
.all<{ stage: string; n: number }>();
|
||||
const ladder: Record<string, number> = { new: 0, "+2": 0, "+5": 0, "+10": 0, retired: 0 };
|
||||
for (const row of ladderRows) ladder[row.stage] = row.n;
|
||||
|
||||
const { results: gates } = await db
|
||||
.prepare("SELECT week, issue, pass_rate, closed_on FROM gates ORDER BY week")
|
||||
.all<{ week: number; issue: number | null; pass_rate: number | null; closed_on: string | null }>();
|
||||
|
||||
// Review-queue depth for the next 14 days: everything due by that day.
|
||||
const queue: { date: string; due: number }[] = [];
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const date = addDays(today, i);
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM problems
|
||||
WHERE stage != 'retired' AND next_review IS NOT NULL AND next_review <= ?1
|
||||
AND (defer_until IS NULL OR defer_until <= ?1)`,
|
||||
)
|
||||
.bind(date)
|
||||
.first<{ n: number }>();
|
||||
queue.push({ date, due: row?.n ?? 0 });
|
||||
}
|
||||
|
||||
const recent: { date: string; attempts: number; passes: number }[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = addDays(today, -i);
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS attempts,
|
||||
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) AS passes
|
||||
FROM attempts WHERE date = ?`,
|
||||
)
|
||||
.bind(date)
|
||||
.first<{ attempts: number; passes: number | null }>();
|
||||
recent.push({ date, attempts: row?.attempts ?? 0, passes: row?.passes ?? 0 });
|
||||
}
|
||||
|
||||
return {
|
||||
generated: new Date().toISOString(),
|
||||
campaign: { day: campaignDay(today), week: campaignWeek(today), start: CAMPAIGN_START, days: CAMPAIGN_DAYS },
|
||||
phases: [...phases.values()],
|
||||
ladder,
|
||||
gates,
|
||||
streak: await streak(db, today),
|
||||
queue,
|
||||
recent,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["esnext"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["./worker-configuration.d.ts", "bun"]
|
||||
},
|
||||
"include": ["src", "scripts"]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// SRS Worker — daily digest email, one-tap logging, weekly review issues,
|
||||
// live SVG charts, and the /api/stats feed for the docs progress page.
|
||||
//
|
||||
// D1 is the source of truth for SRS state; GitHub issues own the catalog;
|
||||
// api/data/schedule.json (bundled) owns the schedule. JSONC over TOML per
|
||||
// Workers best practices (newer features are JSON-only).
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "srs-api",
|
||||
"main": "src/index.ts",
|
||||
"compatibility_date": "2026-08-24",
|
||||
"observability": { "enabled": true },
|
||||
|
||||
"d1_databases": [
|
||||
{ "binding": "DB", "database_name": "srs", "database_id": "2ee2c34a-c2e2-4ebb-934e-c9e7dc8aa4f1" }
|
||||
],
|
||||
|
||||
// Sender domain prdlk.com is onboarded to Email Sending; the binding is
|
||||
// restricted to the one verified destination inbox.
|
||||
"send_email": [
|
||||
{ "name": "EMAIL", "allowed_destination_addresses": ["prnk28@gmail.com"] }
|
||||
],
|
||||
|
||||
// Double-cron per event makes each DST-proof: the code fires only when the
|
||||
// computed ET hour matches (8 AM daily digest, midnight-Saturday review).
|
||||
// 8 AM ET = 12:00 UTC in EDT, 13:00 UTC in EST; midnight ET = 4/5 UTC.
|
||||
"triggers": {
|
||||
"crons": ["0 12 * * *", "0 13 * * *", "0 4 * * 6", "0 5 * * 6"]
|
||||
},
|
||||
|
||||
"vars": {
|
||||
"REPO": "prdlk/leetcode",
|
||||
"FROM_EMAIL": "srs@prdlk.com",
|
||||
"TO_EMAIL": "prnk28@gmail.com",
|
||||
"DOCS_ORIGIN": "https://prdlk.github.io",
|
||||
"DOCS_URL": "https://prdlk.github.io/leetcode",
|
||||
// Filled in after first deploy; used for one-tap links in the digest.
|
||||
"PUBLIC_URL": "https://srs-api.prdlk.workers.dev"
|
||||
}
|
||||
|
||||
// Secrets (wrangler secret put): GH_PAT, WEBHOOK_SECRET, LINK_KEY.
|
||||
}
|
||||
@@ -13,6 +13,10 @@ export default defineConfig({
|
||||
label: "Solutions",
|
||||
path: "/solutions",
|
||||
},
|
||||
{
|
||||
label: "Progress",
|
||||
path: "/progress",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
# Gate log
|
||||
|
||||
Weekly topic-blind gates. Target: 70% first-attempt pass rate. Rows appended
|
||||
automatically by `gate.yml` when a gate issue closes.
|
||||
|
||||
| Week | Date | Problems | First-attempt rate | Result |
|
||||
| --- | --- | --- | --- | --- |
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: Progress
|
||||
description: Live SRS campaign dashboard — phases, ladder, review queue, gates, and recent attempts, fetched from the SRS Worker.
|
||||
---
|
||||
|
||||
Live view of the 56-day campaign. Everything below is fetched client-side
|
||||
from the SRS Worker, so it is always current — no rebuild required.
|
||||
|
||||
<ProgressDashboard />
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Progress dashboard island — the expanded, always-current version of the
|
||||
* daily email footer. Rendered on /progress; fetches campaign state from the
|
||||
* SRS Worker at page load, entirely client-side, so `blume build` never
|
||||
* depends on the API being up. API_BASE is a placeholder stamped in after the
|
||||
* Worker deploys — do not inline it anywhere else.
|
||||
*
|
||||
* Invariants: the component never throws on malformed API data (a shape guard
|
||||
* downgrades to the error state), and the first frame is always the loading
|
||||
* state so server and client render identically.
|
||||
*/
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
|
||||
export const client = "load";
|
||||
|
||||
const API_BASE = "https://srs-api.prdlk.workers.dev";
|
||||
|
||||
// ── /api/stats response shape ────────────────────────────────────
|
||||
|
||||
const STAGES = ["new", "+2", "+5", "+10", "retired"] as const;
|
||||
|
||||
type Stage = (typeof STAGES)[number];
|
||||
|
||||
interface Stats {
|
||||
generated: string;
|
||||
campaign: { day: number; week: number; start: string; days: number };
|
||||
phases: {
|
||||
milestone: number;
|
||||
name: string;
|
||||
core_done: number;
|
||||
core_total: number;
|
||||
optional_done: number;
|
||||
optional_total: number;
|
||||
deferred_done: number;
|
||||
deferred_total: number;
|
||||
}[];
|
||||
ladder: Record<Stage, number>;
|
||||
gates: {
|
||||
week: number;
|
||||
issue: number | null;
|
||||
pass_rate: number | null;
|
||||
closed_on: string | null;
|
||||
}[];
|
||||
streak: number;
|
||||
queue: { date: string; due: number }[];
|
||||
recent: { date: string; attempts: number; passes: number }[];
|
||||
}
|
||||
|
||||
function isStats(value: unknown): value is Stats {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof v.campaign === "object" &&
|
||||
v.campaign !== null &&
|
||||
typeof v.ladder === "object" &&
|
||||
v.ladder !== null &&
|
||||
Array.isArray(v.phases) &&
|
||||
Array.isArray(v.gates) &&
|
||||
Array.isArray(v.queue) &&
|
||||
Array.isArray(v.recent) &&
|
||||
typeof v.streak === "number"
|
||||
);
|
||||
}
|
||||
|
||||
// ── formatting ───────────────────────────────────────────────────
|
||||
|
||||
/** Legacy gates stored integer percent; the Worker may emit a 0–1 fraction. */
|
||||
function percent(rate: number): string {
|
||||
return `${Math.round(rate <= 1 ? rate * 100 : rate)}%`;
|
||||
}
|
||||
|
||||
/** "2026-08-24" → "Mon 24" without timezone drift. */
|
||||
function dayLabel(iso: string): string {
|
||||
const d = new Date(`${iso}T00:00:00`);
|
||||
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
|
||||
return `${weekday} ${iso.slice(8)}`;
|
||||
}
|
||||
|
||||
// ── shared styles (theme tokens only — no hand-rolled palette) ───
|
||||
|
||||
const card: CSSProperties = {
|
||||
border: "1px solid var(--blume-border)",
|
||||
borderRadius: "var(--blume-radius)",
|
||||
padding: "0.75rem 1rem",
|
||||
marginBottom: "1rem",
|
||||
};
|
||||
|
||||
const muted: CSSProperties = {
|
||||
color: "var(--blume-muted-foreground)",
|
||||
fontSize: "0.85em",
|
||||
};
|
||||
|
||||
const heading: CSSProperties = {
|
||||
fontWeight: 600,
|
||||
marginBottom: "0.5rem",
|
||||
};
|
||||
|
||||
function Bar({ done, total }: { done: number; total: number }) {
|
||||
const ratio = total > 0 ? Math.min(done / total, 1) : 0;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: "10rem",
|
||||
maxWidth: "40vw",
|
||||
height: "0.5rem",
|
||||
borderRadius: "var(--blume-radius)",
|
||||
background: "var(--blume-muted)",
|
||||
verticalAlign: "middle",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
width: `${ratio * 100}%`,
|
||||
height: "100%",
|
||||
background: "var(--blume-accent)",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── sections ─────────────────────────────────────────────────────
|
||||
|
||||
function Header({ stats }: { stats: Stats }) {
|
||||
const { campaign, streak } = stats;
|
||||
return (
|
||||
<div style={{ ...card, display: "flex", gap: "1.5rem", flexWrap: "wrap" }}>
|
||||
<span>
|
||||
<strong>
|
||||
Day {campaign.day}/{campaign.days}
|
||||
</strong>
|
||||
</span>
|
||||
<span>Week {campaign.week}</span>
|
||||
<span>
|
||||
Streak: <strong>{streak}</strong> day{streak === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span style={muted}>started {campaign.start}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Phases({ stats }: { stats: Stats }) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>Phases</div>
|
||||
<table style={{ borderCollapse: "collapse", width: "100%" }}>
|
||||
<tbody>
|
||||
{stats.phases.map((p) => (
|
||||
<tr key={p.milestone}>
|
||||
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>{p.name}</td>
|
||||
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
|
||||
<Bar done={p.core_done} total={p.core_total} />{" "}
|
||||
<span style={muted}>
|
||||
core {p.core_done}/{p.core_total}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: "0.2rem 0" }}>
|
||||
<span style={muted}>
|
||||
optional {p.optional_done}/{p.optional_total}
|
||||
{p.deferred_total > 0 &&
|
||||
` · deferred ${p.deferred_done}/${p.deferred_total}`}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Ladder({ stats }: { stats: Stats }) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>SRS ladder</div>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{STAGES.map((stage) => (
|
||||
<span
|
||||
key={stage}
|
||||
style={{
|
||||
background: "var(--blume-muted)",
|
||||
borderRadius: "var(--blume-radius)",
|
||||
padding: "0.35rem 0.75rem",
|
||||
}}
|
||||
>
|
||||
<span style={muted}>{stage}</span>{" "}
|
||||
<strong>{stats.ladder[stage] ?? 0}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Queue({ stats }: { stats: Stats }) {
|
||||
const max = Math.max(1, ...stats.queue.map((q) => q.due));
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>Review queue — next 14 days</div>
|
||||
<table style={{ borderCollapse: "collapse" }}>
|
||||
<tbody>
|
||||
{stats.queue.map((q) => (
|
||||
<tr key={q.date}>
|
||||
<td style={{ ...muted, padding: "0.1rem 1rem 0.1rem 0" }}>
|
||||
{dayLabel(q.date)}
|
||||
</td>
|
||||
<td style={{ padding: "0.1rem 0.75rem 0.1rem 0" }}>
|
||||
<Bar done={q.due} total={max} />
|
||||
</td>
|
||||
<td>{q.due}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Gates({ stats }: { stats: Stats }) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>Gate log</div>
|
||||
{stats.gates.length === 0 ? (
|
||||
<span style={muted}>No gates yet.</span>
|
||||
) : (
|
||||
<table style={{ borderCollapse: "collapse", width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{["Week", "Issue", "Pass rate", "Closed"].map((h) => (
|
||||
<th key={h} style={{ ...muted, textAlign: "left", padding: "0.2rem 1rem 0.2rem 0" }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stats.gates.map((g) => (
|
||||
<tr key={g.week}>
|
||||
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>{g.week}</td>
|
||||
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
|
||||
{g.issue === null ? (
|
||||
<span style={muted}>—</span>
|
||||
) : (
|
||||
<a
|
||||
href={`https://github.com/prdlk/leetcode/issues/${g.issue}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
#{g.issue}
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: "0.2rem 1rem 0.2rem 0" }}>
|
||||
{g.pass_rate === null ? (
|
||||
<span style={muted}>open</span>
|
||||
) : (
|
||||
percent(g.pass_rate)
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: "0.2rem 0" }}>
|
||||
{g.closed_on ?? <span style={muted}>—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Recent({ stats }: { stats: Stats }) {
|
||||
return (
|
||||
<div style={card}>
|
||||
<div style={heading}>Last 7 days</div>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{stats.recent.map((r) => (
|
||||
<span
|
||||
key={r.date}
|
||||
style={{
|
||||
border: "1px solid var(--blume-border)",
|
||||
borderRadius: "var(--blume-radius)",
|
||||
padding: "0.35rem 0.6rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ ...muted, display: "block" }}>{dayLabel(r.date)}</span>
|
||||
<strong>{r.passes}</strong>
|
||||
<span style={muted}>/{r.attempts}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ ...muted, marginTop: "0.4rem" }}>passes / attempts</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── dashboard ────────────────────────────────────────────────────
|
||||
|
||||
type Load =
|
||||
| { phase: "loading" }
|
||||
| { phase: "error" }
|
||||
| { phase: "ready"; stats: Stats };
|
||||
|
||||
export default function ProgressDashboard() {
|
||||
const [load, setLoad] = useState<Load>({ phase: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetch(`${API_BASE}/api/stats`, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.then((body: unknown) => {
|
||||
setLoad(isStats(body) ? { phase: "ready", stats: body } : { phase: "error" });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!controller.signal.aborted) setLoad({ phase: "error" });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
if (load.phase === "loading") {
|
||||
return <p style={muted}>Loading progress…</p>;
|
||||
}
|
||||
if (load.phase === "error") {
|
||||
return (
|
||||
<p style={muted}>
|
||||
API unreachable — stats are served live from the SRS Worker and it did
|
||||
not respond.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const { stats } = load;
|
||||
return (
|
||||
<div>
|
||||
<Header stats={stats} />
|
||||
<Phases stats={stats} />
|
||||
<Ladder stats={stats} />
|
||||
<Queue stats={stats} />
|
||||
<Gates stats={stats} />
|
||||
<Recent stats={stats} />
|
||||
<p style={muted}>Generated {stats.generated}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-4
@@ -8,10 +8,9 @@
|
||||
"doctor": "blume doctor",
|
||||
"close-solved": "bun scripts/close-solved.ts",
|
||||
"close-topics": "bun scripts/close-topics.ts",
|
||||
"srs-scheduler": "bun scripts/srs-scheduler.ts",
|
||||
"srs-logger": "bun scripts/srs-logger.ts",
|
||||
"srs-gate": "bun scripts/srs-gate.ts",
|
||||
"srs-setup": "bun scripts/srs-setup.ts",
|
||||
"api:dev": "cd api && bun run dev",
|
||||
"api:deploy": "cd api && bun run deploy",
|
||||
"api:test": "cd api && bun test src",
|
||||
"pick": "bun scripts/pick.ts",
|
||||
"sync": "bun scripts/sync.ts",
|
||||
"test": "bun scripts/test.ts"
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Weekly gate: create Saturday's topic-blind quiz, and grade it on close.
|
||||
*
|
||||
* bun scripts/srs-gate.ts create # Trigger A (cron Sat / dispatch)
|
||||
* bun scripts/srs-gate.ts close --issue N # Trigger B (issues: closed)
|
||||
*
|
||||
* create — samples ONE unsolved `set:optional` problem per topic scheduled
|
||||
* THIS week (daily drills only ever draw from earlier weeks, so every gate
|
||||
* problem is a first encounter). Seeded by ISO week number: re-runs pick the
|
||||
* same problems, and an existing `Gate — Week N` issue makes the run a no-op.
|
||||
* Exhausted optional pools fall back to an unsolved core problem, and the
|
||||
* body says so. Boost flags + miss counters from LAST week's gate are cleared
|
||||
* here — the boosted topics had their remedial week.
|
||||
*
|
||||
* close — reads this gate's `/done` results (recorded by srs-logger) from
|
||||
* srs.json, computes the first-attempt pass rate (unlogged problems count as
|
||||
* failures — skipping a gate problem is not a pass), appends docs/gate-log.md,
|
||||
* comments a summary, sets `boost: true` on every topic that failed a gate
|
||||
* problem or accumulated ≥ 2 drill misses, and refreshes the README badge
|
||||
* endpoint at .github/srs/badge.json.
|
||||
*/
|
||||
import { join } from "node:path";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { github } from "./github.ts";
|
||||
import {
|
||||
campaignWeek,
|
||||
fetchCatalog,
|
||||
isoWeek,
|
||||
loadSchedule,
|
||||
loadState,
|
||||
rng,
|
||||
sample,
|
||||
saveState,
|
||||
todayET,
|
||||
} from "./srs.ts";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
const GATE_LOG = process.env.SRS_GATE_LOG ?? join(ROOT, "docs", "gate-log.md");
|
||||
const BADGE = process.env.SRS_BADGE ?? join(ROOT, ".github", "srs", "badge.json");
|
||||
const PASS_TARGET = 70; // percent, first-attempt
|
||||
|
||||
const { positionals, values } = parseArgs({
|
||||
allowPositionals: true,
|
||||
options: { issue: { type: "string" } },
|
||||
});
|
||||
const mode = positionals[0];
|
||||
|
||||
const gh = await github();
|
||||
const state = await loadState();
|
||||
const today = todayET();
|
||||
|
||||
// ── Trigger A: create this week's gate ───────────────────────────
|
||||
|
||||
async function create(): Promise<void> {
|
||||
const week = campaignWeek(today);
|
||||
const title = `Gate — Week ${week}`;
|
||||
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=gate&state=all`)) {
|
||||
const issue = raw as { number: number; title: string };
|
||||
if (issue.title === title) {
|
||||
console.log(`${title} already exists (#${issue.number}); nothing to do`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const schedule = await loadSchedule();
|
||||
const topics = Object.entries(schedule)
|
||||
.filter(([date]) => campaignWeek(date) === week)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([, topic]) => topic);
|
||||
if (topics.length === 0) {
|
||||
console.log(`no topics scheduled in week ${week}; no gate to create`);
|
||||
return;
|
||||
}
|
||||
|
||||
const catalog = await fetchCatalog(gh);
|
||||
const random = rng(`gate-${isoWeek(today)}`);
|
||||
const picks: { lc: number; fallback: boolean }[] = [];
|
||||
for (const topic of topics) {
|
||||
const fresh = (set: string) =>
|
||||
[...catalog.problems.values()]
|
||||
.filter(
|
||||
(p) =>
|
||||
p.topic === topic &&
|
||||
p.set === set &&
|
||||
p.open &&
|
||||
!state.problems[p.lc] &&
|
||||
!state.drill_pool_used.includes(p.lc),
|
||||
)
|
||||
.sort((a, b) => a.lc - b.lc);
|
||||
const optional = fresh("optional");
|
||||
if (optional.length > 0) {
|
||||
picks.push({ lc: sample(optional, 1, random)[0]!.lc, fallback: false });
|
||||
continue;
|
||||
}
|
||||
const core = fresh("core");
|
||||
if (core.length > 0) picks.push({ lc: sample(core, 1, random)[0]!.lc, fallback: true });
|
||||
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
||||
}
|
||||
if (picks.length === 0) {
|
||||
console.log(`every pool for week ${week} is exhausted; no gate to create`);
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = picks.map(({ lc, fallback }) => {
|
||||
const p = catalog.problems.get(lc)!;
|
||||
return `- [LC ${p.lc}](${p.url}) — ${p.difficulty}${fallback ? " *(core fallback — optional pool exhausted)*" : ""}`;
|
||||
});
|
||||
const body = [
|
||||
`**Target: ${PASS_TARGET}% first-attempt pass rate. Timed. Narrate out loud.**`,
|
||||
"",
|
||||
...lines,
|
||||
"",
|
||||
"Log with `/done <number> <pass|fail>` comments, then close this issue.",
|
||||
].join("\n");
|
||||
|
||||
const milestone = catalog.topics.get(topics[0]!)?.milestone;
|
||||
const created = (await gh.api(`/repos/${gh.repo}/issues`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title, body, labels: ["gate"], milestone }),
|
||||
})) as { number: number };
|
||||
|
||||
state.gates.push({
|
||||
week,
|
||||
issue: created.number,
|
||||
date: today,
|
||||
problems: picks.map((p) => p.lc),
|
||||
fallbacks: picks.filter((p) => p.fallback).map((p) => p.lc),
|
||||
results: {},
|
||||
});
|
||||
|
||||
// Last week's boosted topics had their remedial week: clear flags/counters.
|
||||
for (const t of Object.values(state.topics)) {
|
||||
if (t.boost) {
|
||||
t.boost = false;
|
||||
t.misses = 0;
|
||||
}
|
||||
}
|
||||
|
||||
await saveState(state);
|
||||
console.log(`created #${created.number} ${title}: ${picks.map((p) => `LC ${p.lc}`).join(", ")}`);
|
||||
}
|
||||
|
||||
// ── Trigger B: grade a closed gate ───────────────────────────────
|
||||
|
||||
async function close(issueNumber: number): Promise<void> {
|
||||
const gate = state.gates.find((g) => g.issue === issueNumber);
|
||||
if (!gate) {
|
||||
console.log(`issue #${issueNumber} has no gate record in srs.json; ignoring`);
|
||||
return;
|
||||
}
|
||||
if (gate.rate !== undefined) {
|
||||
console.log(`gate #${issueNumber} already graded (${gate.rate}%); nothing to do`);
|
||||
return;
|
||||
}
|
||||
|
||||
const catalog = await fetchCatalog(gh);
|
||||
const passes = gate.problems.filter((lc) => gate.results[lc] === "pass");
|
||||
const unlogged = gate.problems.filter((lc) => gate.results[lc] === undefined);
|
||||
const rate = Math.round((passes.length / gate.problems.length) * 100);
|
||||
gate.rate = rate;
|
||||
const passed = rate >= PASS_TARGET;
|
||||
|
||||
// Boost: explicit gate failures, plus topics with ≥ 2 recognition misses.
|
||||
const boosted: number[] = [];
|
||||
for (const lc of gate.problems) {
|
||||
if (gate.results[lc] === "fail") {
|
||||
const topic = catalog.problems.get(lc)?.topic;
|
||||
if (topic !== undefined) {
|
||||
(state.topics[topic] ??= { misses: 0, boost: false }).boost = true;
|
||||
boosted.push(topic);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const [topic, t] of Object.entries(state.topics)) {
|
||||
if (t.misses >= 2 && !t.boost) {
|
||||
t.boost = true;
|
||||
boosted.push(Number(topic));
|
||||
}
|
||||
}
|
||||
|
||||
// Gate log row.
|
||||
const log = await Bun.file(GATE_LOG).text();
|
||||
const problems = gate.problems.map((lc) => `LC ${lc}`).join(" · ");
|
||||
await Bun.write(
|
||||
GATE_LOG,
|
||||
`${log.trimEnd()}\n| ${gate.week} | ${today} | ${problems} | ${rate}% | ${passed ? "pass" : "fail"} |\n`,
|
||||
);
|
||||
|
||||
// Badge endpoint (shields.io schema).
|
||||
await Bun.write(
|
||||
BADGE,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
label: "gate pass rate",
|
||||
message: `${rate}%`,
|
||||
color: passed ? "brightgreen" : "red",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
await saveState(state);
|
||||
|
||||
const summary = [
|
||||
`## Gate — Week ${gate.week}: **${rate}%** first-attempt (target ${PASS_TARGET}%) — ${passed ? "✅ pass" : "❌ fail"}`,
|
||||
"",
|
||||
...gate.problems.map((lc) => {
|
||||
const r = gate.results[lc];
|
||||
return `- LC ${lc} — ${r === "pass" ? "✅ pass" : r === "fail" ? "❌ fail" : "⬜ not logged (counted as fail)"}`;
|
||||
}),
|
||||
"",
|
||||
unlogged.length ? `${unlogged.length} problem(s) were never logged.` : "",
|
||||
boosted.length
|
||||
? `Boosted topics for next week's drills: ${[...new Set(boosted)].map((t) => `#${t}`).join(", ")}.`
|
||||
: "No topics boosted.",
|
||||
"",
|
||||
"Logged to `docs/gate-log.md`.",
|
||||
]
|
||||
.filter((l) => l !== "")
|
||||
.join("\n");
|
||||
await gh.api(`/repos/${gh.repo}/issues/${issueNumber}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ body: summary }),
|
||||
});
|
||||
console.log(`graded gate #${issueNumber}: ${rate}% (${passed ? "pass" : "fail"})`);
|
||||
}
|
||||
|
||||
// ── dispatch ─────────────────────────────────────────────────────
|
||||
|
||||
async function eventIssueNumber(): Promise<number> {
|
||||
if (values.issue) return Number(values.issue);
|
||||
if (!process.env.GITHUB_EVENT_PATH) return NaN;
|
||||
const event: unknown = JSON.parse(await Bun.file(process.env.GITHUB_EVENT_PATH).text());
|
||||
if (
|
||||
event &&
|
||||
typeof event === "object" &&
|
||||
"issue" in event &&
|
||||
event.issue &&
|
||||
typeof event.issue === "object" &&
|
||||
"number" in event.issue &&
|
||||
typeof event.issue.number === "number"
|
||||
) {
|
||||
return event.issue.number;
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
|
||||
if (mode === "create") {
|
||||
await create();
|
||||
} else if (mode === "close") {
|
||||
const issueNumber = await eventIssueNumber();
|
||||
if (!Number.isFinite(issueNumber)) throw new Error("close mode needs --issue N or an event");
|
||||
await close(issueNumber);
|
||||
} else {
|
||||
throw new Error(`usage: srs-gate.ts create | close --issue N (got "${mode}")`);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Process `/done <lc> <pass|fail>` comments on the `📋 Today` brief and on
|
||||
* gate issues, and move problems along the interval ladder.
|
||||
*
|
||||
* Transitions (see srs.ts):
|
||||
* - pass: new → +2 → +5 → +10 → retired; next_review = today + new interval.
|
||||
* - fail: back to +2, due in 2 days.
|
||||
* - First-ever log of a problem enters the ladder at +2 either way; a failed
|
||||
* blind drill additionally increments its topic's recognition-miss counter
|
||||
* (a signal the gate's boost logic reads). Drills join drill_pool_used so
|
||||
* they are never served twice.
|
||||
* - A clean first PASS also closes the problem's sub-issue.
|
||||
*
|
||||
* Feedback is never silent: a clean parse gets a 👍 reaction, anything else
|
||||
* gets a reply naming the exact parse error. Only the repo owner is heard.
|
||||
*
|
||||
* Input: GITHUB_EVENT_PATH (issue_comment payload) in Actions, or
|
||||
* --issue N --author LOGIN --comment-id N --body "..." locally.
|
||||
*/
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
import { github } from "./github.ts";
|
||||
import { projectMirror, reportMirror } from "./srs-project.ts";
|
||||
import {
|
||||
type Attempt,
|
||||
addDays,
|
||||
fail,
|
||||
fetchCatalog,
|
||||
loadState,
|
||||
pass,
|
||||
saveState,
|
||||
TODAY_TITLE,
|
||||
todayET,
|
||||
} from "./srs.ts";
|
||||
|
||||
const DRY = process.env.SRS_DRY === "1";
|
||||
|
||||
// ── event input ──────────────────────────────────────────────────
|
||||
|
||||
interface Input {
|
||||
issue: number;
|
||||
issueTitle: string;
|
||||
issueLabels: string[];
|
||||
author: string;
|
||||
commentId: number;
|
||||
body: string;
|
||||
}
|
||||
|
||||
async function readInput(): Promise<Input> {
|
||||
if (process.env.GITHUB_EVENT_PATH) {
|
||||
const event = JSON.parse(await Bun.file(process.env.GITHUB_EVENT_PATH).text());
|
||||
return {
|
||||
issue: event.issue.number,
|
||||
issueTitle: event.issue.title,
|
||||
issueLabels: (event.issue.labels ?? []).map((l: { name: string }) => l.name),
|
||||
author: event.comment.user.login,
|
||||
commentId: event.comment.id,
|
||||
body: event.comment.body ?? "",
|
||||
};
|
||||
}
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
issue: { type: "string" },
|
||||
author: { type: "string" },
|
||||
"comment-id": { type: "string" },
|
||||
body: { type: "string" },
|
||||
title: { type: "string" },
|
||||
labels: { type: "string" },
|
||||
},
|
||||
});
|
||||
return {
|
||||
issue: Number(values.issue),
|
||||
issueTitle: values.title ?? TODAY_TITLE,
|
||||
issueLabels: values.labels?.split(",") ?? [],
|
||||
author: values.author ?? "",
|
||||
commentId: Number(values["comment-id"] ?? 0),
|
||||
body: values.body ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
const gh = await github();
|
||||
const input = await readInput();
|
||||
const owner = gh.repo.split("/")[0]!;
|
||||
|
||||
if (input.author !== owner) {
|
||||
console.log(`ignoring comment by @${input.author} (owner only)`);
|
||||
process.exit(0);
|
||||
}
|
||||
const isGate = input.issueLabels.includes("gate");
|
||||
const isToday = input.issueTitle === TODAY_TITLE;
|
||||
if (!isGate && !isToday) {
|
||||
console.log("not the Today brief or a gate issue; ignoring");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── parse ────────────────────────────────────────────────────────
|
||||
|
||||
interface Command {
|
||||
lc: number;
|
||||
result: "pass" | "fail";
|
||||
}
|
||||
|
||||
const commands: Command[] = [];
|
||||
const errors: string[] = [];
|
||||
for (const raw of input.body.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line.startsWith("/done")) continue;
|
||||
const m = line.match(/^\/done\s+(\d+)\s+(pass|fail)\s*$/);
|
||||
if (m) commands.push({ lc: Number(m[1]), result: m[2] as Command["result"] });
|
||||
else errors.push(`cannot parse \`${line}\` — expected \`/done <number> <pass|fail>\``);
|
||||
}
|
||||
|
||||
if (commands.length === 0 && errors.length === 0) {
|
||||
console.log("no /done lines; nothing to do");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ── apply ────────────────────────────────────────────────────────
|
||||
|
||||
const state = await loadState();
|
||||
const catalog = await fetchCatalog(gh);
|
||||
const today = todayET();
|
||||
const mirror = await projectMirror(owner, gh.repo);
|
||||
const applied: string[] = [];
|
||||
|
||||
const gate = isGate ? state.gates.find((g) => g.issue === input.issue) : undefined;
|
||||
if (isGate && !gate) errors.push(`no gate record for issue #${input.issue} in srs.json`);
|
||||
|
||||
for (const cmd of commands) {
|
||||
const known = catalog.problems.get(cmd.lc);
|
||||
const entry = state.problems[cmd.lc];
|
||||
|
||||
if (!entry && !known) {
|
||||
errors.push(`LC ${cmd.lc} is not in the curriculum`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (gate && known && gate.problems.includes(cmd.lc)) {
|
||||
gate.results[cmd.lc] = cmd.result;
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
// First-ever attempt: enters the ladder at +2 regardless of result — a
|
||||
// pass earns a +2 review, a fail must be re-solved just as soon.
|
||||
const kind: Attempt["kind"] = gate ? "gate" : known!.set === "optional" ? "drill" : "first";
|
||||
const p: (typeof state.problems)[string] = {
|
||||
issue: known!.issue,
|
||||
topic: known!.topic,
|
||||
difficulty: known!.difficulty.toLowerCase(),
|
||||
set: known!.set,
|
||||
solved_on: today,
|
||||
stage: "+2",
|
||||
next_review: addDays(today, 2),
|
||||
history: [{ date: today, kind, result: cmd.result }],
|
||||
};
|
||||
state.problems[cmd.lc] = p;
|
||||
if (kind === "drill") {
|
||||
if (!state.drill_pool_used.includes(cmd.lc)) state.drill_pool_used.push(cmd.lc);
|
||||
if (cmd.result === "fail") {
|
||||
const t = (state.topics[known!.topic] ??= { misses: 0, boost: false });
|
||||
t.misses++;
|
||||
applied.push(`LC ${cmd.lc}: recognition miss — topic #${known!.topic} misses=${t.misses}`);
|
||||
}
|
||||
}
|
||||
if (!DRY) {
|
||||
await mirror.setFirstAttempt(known!.issue, cmd.result);
|
||||
await mirror.setStage(known!.issue, "+2");
|
||||
await mirror.setTargetDate(known!.issue, p.next_review!);
|
||||
if (cmd.result === "pass" && known!.open) {
|
||||
await gh.closeIssue(
|
||||
known!.issue,
|
||||
`First attempt passed (${kind}) — entering the review ladder at +2. Logged by \`srs-logger\`.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
applied.push(`LC ${cmd.lc}: first ${kind} ${cmd.result} → stage +2, review ${p.next_review}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Already on the ladder: a review (or a gate/drill re-encounter).
|
||||
if (entry.stage === "retired") {
|
||||
errors.push(`LC ${cmd.lc} is already retired`);
|
||||
continue;
|
||||
}
|
||||
entry.history.push({ date: today, kind: "review", result: cmd.result });
|
||||
if (cmd.result === "pass") {
|
||||
pass(entry, today);
|
||||
} else {
|
||||
fail(entry, today);
|
||||
}
|
||||
delete entry.defer_until; // once touched, it lives by next_review alone
|
||||
if (!DRY) {
|
||||
await mirror.setStage(entry.issue, entry.stage);
|
||||
await mirror.setTargetDate(entry.issue, entry.next_review ?? null);
|
||||
}
|
||||
applied.push(
|
||||
`LC ${cmd.lc}: review ${cmd.result} → stage ${entry.stage}` +
|
||||
(entry.next_review ? `, review ${entry.next_review}` : " — retired 🎉"),
|
||||
);
|
||||
}
|
||||
|
||||
// ── persist + respond ────────────────────────────────────────────
|
||||
|
||||
if (!DRY) await saveState(state);
|
||||
|
||||
for (const line of applied) console.log(line);
|
||||
for (const line of errors) console.log(`error: ${line}`);
|
||||
|
||||
if (!DRY && input.commentId) {
|
||||
if (errors.length === 0) {
|
||||
await gh.api(`/repos/${gh.repo}/issues/comments/${input.commentId}/reactions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: "+1" }),
|
||||
});
|
||||
} else {
|
||||
await gh.api(`/repos/${gh.repo}/issues/${input.issue}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
body: `Could not log everything:\n\n${errors.map((e) => `- ${e}`).join("\n")}${applied.length ? `\n\nApplied anyway:\n\n${applied.map((a) => `- ${a}`).join("\n")}` : ""}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await reportMirror(mirror);
|
||||
@@ -1,222 +0,0 @@
|
||||
/**
|
||||
* Best-effort mirror of SRS state into the user-level GitHub Project
|
||||
* ("Interview Prep").
|
||||
*
|
||||
* The JSON state file is the truth; these fields only make the project's
|
||||
* table and roadmap views double as a live review calendar. Every operation
|
||||
* is wrapped: a failure records a warning and the run continues — a workflow
|
||||
* must never lose an srs.json update because a GraphQL mutation failed.
|
||||
*
|
||||
* Auth: PROJECT_PAT (the default GITHUB_TOKEN cannot touch user projects),
|
||||
* falling back to GH_TOKEN / `gh auth token` for local runs.
|
||||
*
|
||||
* Guardrail: only problem issues are ever passed in here, so topic rows'
|
||||
* Target Date is never written.
|
||||
*/
|
||||
import { $ } from "bun";
|
||||
|
||||
const PROJECT_TITLE = "Interview Prep";
|
||||
|
||||
interface SelectField {
|
||||
id: string;
|
||||
options: Record<string, string>; // option name -> option id
|
||||
}
|
||||
|
||||
interface ProjectInfo {
|
||||
id: string;
|
||||
targetDate: string; // field id
|
||||
srsStage: SelectField;
|
||||
firstAttempt: SelectField;
|
||||
}
|
||||
|
||||
export interface ProjectMirror {
|
||||
/** Set (or clear, with null) a problem row's Target Date. */
|
||||
setTargetDate(issue: number, date: string | null): Promise<void>;
|
||||
setStage(issue: number, stage: string): Promise<void>;
|
||||
setFirstAttempt(issue: number, result: string): Promise<void>;
|
||||
/** Accumulated failures; print + step-summarize these, never throw. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
async function resolveToken(): Promise<string> {
|
||||
const env = process.env.PROJECT_PAT || process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
||||
if (env) return env;
|
||||
return (await $`gh auth token`.text()).trim();
|
||||
}
|
||||
|
||||
export async function projectMirror(owner: string, repo: string): Promise<ProjectMirror> {
|
||||
const warnings: string[] = [];
|
||||
let token = "";
|
||||
let project: ProjectInfo | undefined;
|
||||
const itemIds = new Map<number, string | undefined>();
|
||||
|
||||
async function graphql(query: string, variables: Record<string, unknown>): Promise<unknown> {
|
||||
const res = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
|
||||
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
|
||||
if (payload.errors?.length) {
|
||||
throw new Error(payload.errors.map((e) => e.message).join("; "));
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
/** Resolve project + field/option ids once; by name, never hardcoded. */
|
||||
async function resolve(): Promise<ProjectInfo> {
|
||||
if (project) return project;
|
||||
token ||= await resolveToken();
|
||||
if (!token) throw new Error("no PROJECT_PAT / token available");
|
||||
|
||||
const data = (await graphql(
|
||||
`query($owner: String!, $title: String!) {
|
||||
user(login: $owner) {
|
||||
projectsV2(first: 10, query: $title) {
|
||||
nodes {
|
||||
id title
|
||||
fields(first: 30) {
|
||||
nodes {
|
||||
... on ProjectV2FieldCommon { id name dataType }
|
||||
... on ProjectV2SingleSelectField { id name options { id name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, title: PROJECT_TITLE },
|
||||
)) as {
|
||||
user: {
|
||||
projectsV2: {
|
||||
nodes: {
|
||||
id: string;
|
||||
title: string;
|
||||
fields: {
|
||||
nodes: { id: string; name: string; options?: { id: string; name: string }[] }[];
|
||||
};
|
||||
}[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
|
||||
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
|
||||
|
||||
const select = (name: string): SelectField => {
|
||||
const f = node.fields.nodes.find((n) => n.name === name);
|
||||
if (!f?.options) throw new Error(`single-select field "${name}" missing — run srs-setup`);
|
||||
return { id: f.id, options: Object.fromEntries(f.options.map((o) => [o.name, o.id])) };
|
||||
};
|
||||
const date = node.fields.nodes.find((n) => n.name === "Target Date");
|
||||
if (!date) throw new Error(`date field "Target Date" missing from project`);
|
||||
|
||||
project = {
|
||||
id: node.id,
|
||||
targetDate: date.id,
|
||||
srsStage: select("SRS Stage"),
|
||||
firstAttempt: select("First Attempt"),
|
||||
};
|
||||
return project;
|
||||
}
|
||||
|
||||
/** The issue's item id in THIS project (an issue can be in several). */
|
||||
async function itemId(issue: number): Promise<string> {
|
||||
if (itemIds.has(issue)) {
|
||||
const cached = itemIds.get(issue);
|
||||
if (!cached) throw new Error(`issue #${issue} is not in the project`);
|
||||
return cached;
|
||||
}
|
||||
const info = await resolve();
|
||||
const [repoOwner, repoName] = repo.split("/");
|
||||
const data = (await graphql(
|
||||
`query($owner: String!, $name: String!, $issue: Int!) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
issue(number: $issue) {
|
||||
projectItems(first: 10, includeArchived: true) {
|
||||
nodes { id project { id } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner: repoOwner, name: repoName, issue },
|
||||
)) as {
|
||||
repository: { issue: { projectItems: { nodes: { id: string; project: { id: string } }[] } } };
|
||||
};
|
||||
const item = data.repository.issue.projectItems.nodes.find((n) => n.project.id === info.id);
|
||||
itemIds.set(issue, item?.id);
|
||||
if (!item) throw new Error(`issue #${issue} is not in the project`);
|
||||
return item.id;
|
||||
}
|
||||
|
||||
async function setField(issue: number, fieldId: string, value: object): Promise<void> {
|
||||
const info = await resolve();
|
||||
await graphql(
|
||||
`mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
|
||||
updateProjectV2ItemFieldValue(
|
||||
input: { projectId: $project, itemId: $item, fieldId: $field, value: $value }
|
||||
) { projectV2Item { id } }
|
||||
}`,
|
||||
{ project: info.id, item: await itemId(issue), field: fieldId, value },
|
||||
);
|
||||
}
|
||||
|
||||
async function clearField(issue: number, fieldId: string): Promise<void> {
|
||||
const info = await resolve();
|
||||
await graphql(
|
||||
`mutation($project: ID!, $item: ID!, $field: ID!) {
|
||||
clearProjectV2ItemFieldValue(
|
||||
input: { projectId: $project, itemId: $item, fieldId: $field }
|
||||
) { projectV2Item { id } }
|
||||
}`,
|
||||
{ project: info.id, item: await itemId(issue), field: fieldId },
|
||||
);
|
||||
}
|
||||
|
||||
/** Run op; on failure record a warning instead of propagating. */
|
||||
async function attempt(what: string, op: () => Promise<void>): Promise<void> {
|
||||
try {
|
||||
await op();
|
||||
} catch (err) {
|
||||
warnings.push(`${what}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
warnings,
|
||||
setTargetDate: (issue, date) =>
|
||||
attempt(`Target Date #${issue}`, async () => {
|
||||
const info = await resolve();
|
||||
if (date === null) await clearField(issue, info.targetDate);
|
||||
else await setField(issue, info.targetDate, { date });
|
||||
}),
|
||||
setStage: (issue, stage) =>
|
||||
attempt(`SRS Stage #${issue}`, async () => {
|
||||
const info = await resolve();
|
||||
const option = info.srsStage.options[stage];
|
||||
if (!option) throw new Error(`no option "${stage}"`);
|
||||
await setField(issue, info.srsStage.id, { singleSelectOptionId: option });
|
||||
}),
|
||||
setFirstAttempt: (issue, result) =>
|
||||
attempt(`First Attempt #${issue}`, async () => {
|
||||
const info = await resolve();
|
||||
const option = info.firstAttempt.options[result];
|
||||
if (!option) throw new Error(`no option "${result}"`);
|
||||
await setField(issue, info.firstAttempt.id, { singleSelectOptionId: option });
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Print mirror warnings and surface them in the Actions run summary. */
|
||||
export async function reportMirror(mirror: ProjectMirror): Promise<void> {
|
||||
if (mirror.warnings.length === 0) return;
|
||||
console.warn(`\nproject mirror: ${mirror.warnings.length} warning(s) — srs.json is the truth`);
|
||||
for (const w of mirror.warnings) console.warn(` ⚠ ${w}`);
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
await Bun.write(
|
||||
process.env.GITHUB_STEP_SUMMARY,
|
||||
`### ⚠ project mirror warnings\n\nsrs.json was updated; these project mutations failed:\n\n${mirror.warnings.map((w) => `- ${w}`).join("\n")}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Rewrite the pinned `📋 Today` daily-brief issue from SRS state.
|
||||
*
|
||||
* Runs every day at 10:00 UTC (6 AM ET) and on demand. Stateless by design:
|
||||
* it reads srs.json + schedule.json and rewrites one issue body, so running
|
||||
* it twice in a day is a byte-identical no-op (drill picks are seeded by the
|
||||
* date). Overflow needs no bookkeeping either — anything past the daily cap
|
||||
* simply stays due and is the oldest entry tomorrow.
|
||||
*
|
||||
* Retrieval rules enforced here:
|
||||
* - Review and drill lines carry number + difficulty only. Never the topic,
|
||||
* never a link — recognizing the pattern unaided is the exercise.
|
||||
* - Blind drills come only from optional pools of topics covered in EARLIER
|
||||
* weeks; the current week's optional pool is reserved for Saturday's gate.
|
||||
* - Boosted topics (failed gate problem / repeated drill misses) inject up to
|
||||
* 2 extra drills, inside the same daily cap.
|
||||
*
|
||||
* bun scripts/srs-scheduler.ts # rewrite the live issue
|
||||
* SRS_DRY=1 SRS_TODAY=2026-08-30 bun ... # print the body, touch nothing
|
||||
*/
|
||||
import { github } from "./github.ts";
|
||||
import { projectMirror, reportMirror } from "./srs-project.ts";
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
type Catalog,
|
||||
type State,
|
||||
TODAY_TITLE,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
dueReviews,
|
||||
fetchCatalog,
|
||||
loadSchedule,
|
||||
loadState,
|
||||
prettyDate,
|
||||
rng,
|
||||
sample,
|
||||
todayET,
|
||||
weekdayOf,
|
||||
} from "./srs.ts";
|
||||
|
||||
const DRY = process.env.SRS_DRY === "1";
|
||||
const DAILY_CAP = 6;
|
||||
|
||||
const gh = await github();
|
||||
const state = await loadState();
|
||||
const schedule = await loadSchedule();
|
||||
const today = todayET();
|
||||
const week = campaignWeek(today);
|
||||
|
||||
// ── compose the body ─────────────────────────────────────────────
|
||||
|
||||
/** Week in which a topic was (or will be) taught, from the schedule file. */
|
||||
function topicWeek(topic: number): number | undefined {
|
||||
for (const [date, t] of Object.entries(schedule)) {
|
||||
if (t === topic) return campaignWeek(date);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface Drill {
|
||||
lc: number;
|
||||
difficulty: string;
|
||||
}
|
||||
|
||||
function pickDrills(catalog: Catalog, budget: number): Drill[] {
|
||||
// Optional problems from earlier weeks, unseen by both ladder and drills.
|
||||
const pool = [...catalog.problems.values()]
|
||||
.filter((p) => {
|
||||
if (p.set !== "optional" || !p.open) return false;
|
||||
if (state.problems[p.lc] || state.drill_pool_used.includes(p.lc)) return false;
|
||||
const w = topicWeek(p.topic);
|
||||
return w !== undefined && w < week;
|
||||
})
|
||||
.sort((a, b) => a.lc - b.lc); // stable base order for the seeded shuffle
|
||||
|
||||
const boosted = pool.filter((p) => state.topics[p.topic]?.boost);
|
||||
const regular = pool.filter((p) => !state.topics[p.topic]?.boost);
|
||||
|
||||
const picks = [
|
||||
...sample(boosted, Math.min(2, budget), rng(`boost-${today}`)),
|
||||
...sample(regular, Math.min(2, Math.max(0, budget - Math.min(2, boosted.length))), rng(`drill-${today}`)),
|
||||
].slice(0, budget);
|
||||
return picks.map((p) => ({ lc: p.lc, difficulty: p.difficulty }));
|
||||
}
|
||||
|
||||
function composeBody(catalog: Catalog): string {
|
||||
if (weekdayOf(today) === 0) {
|
||||
return "Rest day. Nothing is due. Overdue reviews moved to Monday.";
|
||||
}
|
||||
|
||||
const due = dueReviews(state, today);
|
||||
const reviews = due.slice(0, DAILY_CAP);
|
||||
const carried = due.length - reviews.length;
|
||||
const drills = pickDrills(catalog, DAILY_CAP - reviews.length);
|
||||
|
||||
const lines: string[] = [
|
||||
`## ${prettyDate(today)} — Day ${campaignDay(today)}/${CAMPAIGN_DAYS} · Week ${week}`,
|
||||
"",
|
||||
];
|
||||
|
||||
const topicIssue = schedule[today];
|
||||
const topic = topicIssue === undefined ? undefined : catalog.topics.get(topicIssue);
|
||||
if (topic) {
|
||||
const core = [...catalog.problems.values()]
|
||||
.filter((p) => p.topic === topic.issue && p.set === "core")
|
||||
.sort((a, b) => a.lc - b.lc)
|
||||
.map((p) => `[LC ${p.lc}](${p.url})${p.open ? "" : " ✓"}`);
|
||||
lines.push(`### New topic: ${topic.name} (#${topic.issue})`, `Core: ${core.join(" · ")}`, "");
|
||||
} else {
|
||||
lines.push("_No new topic today — reviews and drills only._", "");
|
||||
}
|
||||
|
||||
lines.push(`### Reviews due (${reviews.length})`);
|
||||
if (reviews.length) {
|
||||
lines.push("Solve each from scratch. Do not open your old solution first.");
|
||||
for (const [lc, p] of reviews) {
|
||||
const difficulty = catalog.problems.get(Number(lc))?.difficulty ?? p.difficulty;
|
||||
lines.push(`- LC ${lc} — ${difficulty} — stage ${p.stage}`);
|
||||
}
|
||||
if (carried > 0) lines.push(`\n${carried} more carried to tomorrow (cap ${DAILY_CAP}).`);
|
||||
} else {
|
||||
lines.push("None.");
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push(`### Blind drills (${drills.length})`);
|
||||
if (drills.length) {
|
||||
lines.push("No topic given. Name the pattern out loud before you code.");
|
||||
for (const d of drills) lines.push(`- LC ${d.lc} — ${d.difficulty}`);
|
||||
} else {
|
||||
lines.push("None today.");
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
lines.push(
|
||||
"### Log your results",
|
||||
"Comment on this issue, one line per problem:",
|
||||
"`/done 704 pass` · `/done 15 fail`",
|
||||
"",
|
||||
"### Rules",
|
||||
"90-minute cap · core first, then reviews, then drills · close-out",
|
||||
"ritual on every submit.",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ── find / create / pin the Today issue ──────────────────────────
|
||||
|
||||
async function todayIssue(): Promise<{ number: number; created: boolean }> {
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?state=open`)) {
|
||||
const issue = raw as { number: number; title: string; pull_request?: unknown };
|
||||
if (!issue.pull_request && issue.title === TODAY_TITLE) {
|
||||
return { number: issue.number, created: false };
|
||||
}
|
||||
}
|
||||
const created = (await gh.api(`/repos/${gh.repo}/issues`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ title: TODAY_TITLE, body: "(initializing)" }),
|
||||
})) as { number: number; node_id: string };
|
||||
try {
|
||||
await gh.api("/graphql", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
query: `mutation($id: ID!) { pinIssue(input: { issueId: $id }) { issue { number } } }`,
|
||||
variables: { id: created.node_id },
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`could not pin #${created.number}: ${err} — pin it by hand`);
|
||||
}
|
||||
return { number: created.number, created: true };
|
||||
}
|
||||
|
||||
// ── run ──────────────────────────────────────────────────────────
|
||||
|
||||
const catalog = await fetchCatalog(gh);
|
||||
const body = composeBody(catalog);
|
||||
|
||||
if (DRY) {
|
||||
console.log(body);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const issue = await todayIssue();
|
||||
await gh.api(`/repos/${gh.repo}/issues/${issue.number}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ body }),
|
||||
});
|
||||
console.log(`${issue.created ? "created + pinned" : "rewrote"} #${issue.number} ${TODAY_TITLE}`);
|
||||
|
||||
// Mirror every due problem's next_review so the project's Target Date views
|
||||
// stay a live review calendar (problem rows only — never topic rows).
|
||||
const mirror = await projectMirror(gh.repo.split("/")[0]!, gh.repo);
|
||||
if (weekdayOf(today) !== 0) {
|
||||
for (const [, p] of dueReviews(state as State, today)) {
|
||||
await mirror.setTargetDate(p.issue, p.next_review!);
|
||||
}
|
||||
}
|
||||
await reportMirror(mirror);
|
||||
console.log(body);
|
||||
@@ -1,146 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* One-shot (idempotent) SRS setup + PROJECT_PAT verification.
|
||||
*
|
||||
* 1. Proves the token can talk GraphQL (viewer login) — the acceptance test
|
||||
* for the PROJECT_PAT repo secret when dispatched as a workflow.
|
||||
* 2. Resolves the "Interview Prep" user project and its built-in Target Date
|
||||
* field (which the SRS reuses — no custom date field is ever created).
|
||||
* 3. Creates the `SRS Stage` and `First Attempt` single-selects when missing.
|
||||
* 4. Mirrors every laddered problem's state into the project fields.
|
||||
*
|
||||
* Prints every resolved ID so they are on record in the run log.
|
||||
*/
|
||||
import { $ } from "bun";
|
||||
|
||||
import { github } from "./github.ts";
|
||||
import { projectMirror, reportMirror } from "./srs-project.ts";
|
||||
import { loadState } from "./srs.ts";
|
||||
|
||||
const PROJECT_TITLE = "Interview Prep";
|
||||
|
||||
const token =
|
||||
process.env.PROJECT_PAT ||
|
||||
process.env.GH_TOKEN ||
|
||||
process.env.GITHUB_TOKEN ||
|
||||
(await $`gh auth token`.text()).trim();
|
||||
|
||||
async function graphql(query: string, variables: Record<string, unknown> = {}): Promise<unknown> {
|
||||
const res = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
|
||||
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
|
||||
if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; "));
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
// 1. token check — hard fail here means PROJECT_PAT is missing or scopeless.
|
||||
const viewerData = (await graphql(`{ viewer { login } }`)) as { viewer: { login: string } };
|
||||
console.log(`token OK — authenticated as @${viewerData.viewer.login}`);
|
||||
|
||||
const gh = await github();
|
||||
const owner = gh.repo.split("/")[0]!;
|
||||
|
||||
// 2. resolve the project and its fields.
|
||||
interface FieldNode {
|
||||
id: string;
|
||||
name: string;
|
||||
dataType?: string;
|
||||
options?: { id: string; name: string }[];
|
||||
}
|
||||
const projectData = (await graphql(
|
||||
`query($owner: String!, $title: String!) {
|
||||
user(login: $owner) {
|
||||
projectsV2(first: 10, query: $title) {
|
||||
nodes {
|
||||
id number title
|
||||
fields(first: 30) {
|
||||
nodes {
|
||||
... on ProjectV2FieldCommon { id name dataType }
|
||||
... on ProjectV2SingleSelectField { id name options { id name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, title: PROJECT_TITLE },
|
||||
)) as {
|
||||
user: {
|
||||
projectsV2: { nodes: { id: string; number: number; title: string; fields: { nodes: FieldNode[] } }[] };
|
||||
};
|
||||
};
|
||||
const project = projectData.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
|
||||
if (!project) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
|
||||
console.log(`project: "${project.title}" #${project.number} — ${project.id}`);
|
||||
|
||||
let fields = project.fields.nodes;
|
||||
const targetDate = fields.find((f) => f.name === "Target Date" && f.dataType === "DATE");
|
||||
if (!targetDate) {
|
||||
throw new Error('the built-in "Target Date" field is missing — add it in the project UI');
|
||||
}
|
||||
console.log(`Target Date field: ${targetDate.id} (reused, not created)`);
|
||||
|
||||
// 3. create the two SRS single-selects when absent.
|
||||
const WANTED: Record<string, { name: string; color: string; description: string }[]> = {
|
||||
"SRS Stage": [
|
||||
{ name: "new", color: "GRAY", description: "not yet on the ladder" },
|
||||
{ name: "+2", color: "YELLOW", description: "review due 2 days after last solve" },
|
||||
{ name: "+5", color: "ORANGE", description: "review due 5 days after last solve" },
|
||||
{ name: "+10", color: "BLUE", description: "review due 10 days after last solve" },
|
||||
{ name: "retired", color: "GREEN", description: "passed all three stages" },
|
||||
],
|
||||
"First Attempt": [
|
||||
{ name: "pass", color: "GREEN", description: "first timed solve passed" },
|
||||
{ name: "fail", color: "RED", description: "first timed solve failed" },
|
||||
],
|
||||
};
|
||||
|
||||
for (const [name, options] of Object.entries(WANTED)) {
|
||||
const existing = fields.find((f) => f.name === name);
|
||||
if (existing) {
|
||||
console.log(`${name} field: ${existing.id} (already exists)`);
|
||||
continue;
|
||||
}
|
||||
const optionsArg = options
|
||||
.map((o) => `{name:"${o.name}",color:${o.color},description:"${o.description}"}`)
|
||||
.join(",");
|
||||
const created = (await graphql(
|
||||
`mutation($project: ID!, $name: String!) {
|
||||
createProjectV2Field(input: {
|
||||
projectId: $project, dataType: SINGLE_SELECT, name: $name,
|
||||
singleSelectOptions: [${optionsArg}]
|
||||
}) {
|
||||
projectV2Field { ... on ProjectV2SingleSelectField { id name } }
|
||||
}
|
||||
}`,
|
||||
{ project: project.id, name },
|
||||
)) as { createProjectV2Field: { projectV2Field: { id: string } } };
|
||||
console.log(`${name} field: ${created.createProjectV2Field.projectV2Field.id} (created)`);
|
||||
fields = [...fields, { id: created.createProjectV2Field.projectV2Field.id, name }];
|
||||
}
|
||||
|
||||
// 4. mirror every laddered problem into the project.
|
||||
const state = await loadState();
|
||||
const mirror = await projectMirror(owner, gh.repo);
|
||||
let mirrored = 0;
|
||||
for (const [lc, p] of Object.entries(state.problems)) {
|
||||
await mirror.setStage(p.issue, p.stage);
|
||||
await mirror.setTargetDate(p.issue, p.stage === "retired" ? null : (p.next_review ?? null));
|
||||
const first = p.history[0];
|
||||
if (first) await mirror.setFirstAttempt(p.issue, first.result);
|
||||
mirrored++;
|
||||
if (mirrored % 10 === 0) console.log(` mirrored ${mirrored} problems… (last LC ${lc})`);
|
||||
}
|
||||
console.log(`mirrored ${mirrored} laddered problems into the project`);
|
||||
await reportMirror(mirror);
|
||||
|
||||
if (process.env.GITHUB_STEP_SUMMARY && mirror.warnings.length === 0) {
|
||||
await Bun.write(
|
||||
process.env.GITHUB_STEP_SUMMARY,
|
||||
`### srs-setup ✅\n\n- token: @${viewerData.viewer.login}\n- project: ${project.id} (#${project.number})\n- Target Date: ${targetDate.id}\n- mirrored: ${mirrored} problems\n`,
|
||||
);
|
||||
}
|
||||
-300
@@ -1,300 +0,0 @@
|
||||
/**
|
||||
* Domain library for the spaced-repetition system (SRS).
|
||||
*
|
||||
* State model — `.github/srs/srs.json` is the single source of truth; the
|
||||
* GitHub Project fields are a best-effort mirror (see srs-project.ts).
|
||||
*
|
||||
* The stage names the review a problem must pass NEXT: a problem at `+2` has
|
||||
* a review due 2 days after its last clean solve. Passing advances
|
||||
* new → +2 → +5 → +10 → retired; any failure resets to +2. `new` is reserved
|
||||
* for deferred Hards that have never been solved — `defer_until` keeps them
|
||||
* out of the queue until Sep 28.
|
||||
*
|
||||
* All dates are America/New_York calendar dates (YYYY-MM-DD): the cron fires
|
||||
* at 10:00 UTC = 6 AM ET, and the campaign is lived in ET.
|
||||
*/
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { GitHub } from "./github.ts";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..");
|
||||
|
||||
// ── state ────────────────────────────────────────────────────────
|
||||
|
||||
export type Stage = "new" | "+2" | "+5" | "+10" | "retired";
|
||||
export type Result = "pass" | "fail";
|
||||
|
||||
export interface Attempt {
|
||||
date: string;
|
||||
/** first = learning-day solve, drill = blind drill, gate = gate problem. */
|
||||
kind: "first" | "review" | "drill" | "gate";
|
||||
result: Result;
|
||||
}
|
||||
|
||||
export interface ProblemState {
|
||||
issue: number;
|
||||
topic: number;
|
||||
difficulty: string;
|
||||
set: string;
|
||||
solved_on?: string;
|
||||
stage: Stage;
|
||||
next_review?: string;
|
||||
/** Deferred Hards stay out of the due queue until this date. */
|
||||
defer_until?: string;
|
||||
history: Attempt[];
|
||||
}
|
||||
|
||||
export interface TopicState {
|
||||
/** Blind-drill recognition misses since the last boost reset. */
|
||||
misses: number;
|
||||
/** Set at gate close; scheduler injects extra drills; next gate clears it. */
|
||||
boost: boolean;
|
||||
}
|
||||
|
||||
export interface GateState {
|
||||
week: number;
|
||||
issue: number;
|
||||
date: string;
|
||||
problems: number[];
|
||||
/** Problems that fell back to the core set (optional pool exhausted). */
|
||||
fallbacks: number[];
|
||||
results: Record<string, Result>;
|
||||
rate?: number;
|
||||
}
|
||||
|
||||
export interface State {
|
||||
problems: Record<string, ProblemState>;
|
||||
topics: Record<string, TopicState>;
|
||||
drill_pool_used: number[];
|
||||
gates: GateState[];
|
||||
}
|
||||
|
||||
export const STATE_PATH = process.env.SRS_STATE ?? join(ROOT, ".github", "srs", "srs.json");
|
||||
export const SCHEDULE_PATH =
|
||||
process.env.SRS_SCHEDULE ?? join(ROOT, ".github", "srs", "schedule.json");
|
||||
|
||||
export async function loadState(): Promise<State> {
|
||||
return JSON.parse(await Bun.file(STATE_PATH).text());
|
||||
}
|
||||
|
||||
export async function saveState(state: State): Promise<void> {
|
||||
await Bun.write(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`);
|
||||
}
|
||||
|
||||
/** Day (YYYY-MM-DD) → topic issue number. Human-edited when life happens. */
|
||||
export async function loadSchedule(): Promise<Record<string, number>> {
|
||||
return JSON.parse(await Bun.file(SCHEDULE_PATH).text());
|
||||
}
|
||||
|
||||
// ── dates (America/New_York) ─────────────────────────────────────
|
||||
|
||||
/** Campaign day 1 — Monday of Phase I week 1. Day 56 = Oct 11. */
|
||||
export const CAMPAIGN_START = "2026-08-17";
|
||||
export const CAMPAIGN_DAYS = 56;
|
||||
|
||||
/** Today's ET calendar date; SRS_TODAY overrides for tests and reruns. */
|
||||
export function todayET(): string {
|
||||
if (process.env.SRS_TODAY) return process.env.SRS_TODAY;
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "America/New_York",
|
||||
dateStyle: "short",
|
||||
}).format(new Date());
|
||||
}
|
||||
|
||||
/** Noon UTC anchor: date-only arithmetic immune to DST edges. */
|
||||
function atNoon(date: string): Date {
|
||||
return new Date(`${date}T12:00:00Z`);
|
||||
}
|
||||
|
||||
export function addDays(date: string, days: number): string {
|
||||
return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function daysBetween(from: string, to: string): number {
|
||||
return Math.round((atNoon(to).getTime() - atNoon(from).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** 0 = Sunday … 6 = Saturday. */
|
||||
export function weekdayOf(date: string): number {
|
||||
return atNoon(date).getUTCDay();
|
||||
}
|
||||
|
||||
/** 1-based campaign day; may exceed CAMPAIGN_DAYS after the camp ends. */
|
||||
export function campaignDay(date: string): number {
|
||||
return daysBetween(CAMPAIGN_START, date) + 1;
|
||||
}
|
||||
|
||||
/** 1-based campaign week (Mon–Sun), aligned to CAMPAIGN_START. */
|
||||
export function campaignWeek(date: string): number {
|
||||
return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1;
|
||||
}
|
||||
|
||||
export function isoWeek(date: string): number {
|
||||
const d = atNoon(date);
|
||||
// ISO 8601: week containing the year's first Thursday is week 1.
|
||||
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||
const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
|
||||
}
|
||||
|
||||
export function prettyDate(date: string): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "UTC",
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(atNoon(date));
|
||||
}
|
||||
|
||||
// ── the interval ladder ──────────────────────────────────────────
|
||||
|
||||
export const INTERVAL: Record<string, number> = { "+2": 2, "+5": 5, "+10": 10 };
|
||||
const NEXT_STAGE: Record<string, Stage> = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" };
|
||||
|
||||
/** Advance on a pass; returns false when the problem was already retired. */
|
||||
export function pass(p: ProblemState, today: string): boolean {
|
||||
const next = NEXT_STAGE[p.stage];
|
||||
if (!next) return false;
|
||||
p.stage = next;
|
||||
p.solved_on = today;
|
||||
if (next === "retired") {
|
||||
delete p.next_review;
|
||||
} else {
|
||||
p.next_review = addDays(today, INTERVAL[next]!);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Reset on a failure: back to the +2 stage, due again in 2 days. */
|
||||
export function fail(p: ProblemState, today: string): void {
|
||||
p.stage = "+2";
|
||||
p.next_review = addDays(today, 2);
|
||||
}
|
||||
|
||||
/** Reviews due today or earlier, oldest first — the overflow carry order. */
|
||||
export function dueReviews(state: State, today: string): [string, ProblemState][] {
|
||||
return Object.entries(state.problems)
|
||||
.filter(
|
||||
([, p]) =>
|
||||
p.stage !== "retired" &&
|
||||
p.next_review !== undefined &&
|
||||
p.next_review <= today &&
|
||||
(p.defer_until === undefined || p.defer_until <= today),
|
||||
)
|
||||
.sort(([a, pa], [b, pb]) =>
|
||||
pa.next_review === pb.next_review
|
||||
? Number(a) - Number(b)
|
||||
: pa.next_review! < pb.next_review!
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
}
|
||||
|
||||
// ── deterministic sampling ───────────────────────────────────────
|
||||
|
||||
/** FNV-1a → mulberry32: a seeded PRNG so re-runs pick identical problems. */
|
||||
export function rng(seed: string): () => number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
h ^= seed.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return () => {
|
||||
h = Math.imul(h ^ (h >>> 15), h | 1);
|
||||
h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
|
||||
return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** Take up to n elements, Fisher–Yates order driven by the seeded PRNG. */
|
||||
export function sample<T>(pool: T[], n: number, random: () => number): T[] {
|
||||
const copy = [...pool];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j]!, copy[i]!];
|
||||
}
|
||||
return copy.slice(0, n);
|
||||
}
|
||||
|
||||
// ── the live catalog (topics + problem sub-issues) ───────────────
|
||||
|
||||
export interface CatalogProblem {
|
||||
lc: number;
|
||||
issue: number;
|
||||
name: string;
|
||||
difficulty: "Easy" | "Medium" | "Hard";
|
||||
set: "core" | "optional" | "deferred";
|
||||
topic: number;
|
||||
open: boolean;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CatalogTopic {
|
||||
issue: number;
|
||||
/** e.g. "Topic 03 — Two Pointers". */
|
||||
title: string;
|
||||
/** Bare name, e.g. "Two Pointers". */
|
||||
name: string;
|
||||
milestone: number | undefined;
|
||||
}
|
||||
|
||||
export interface Catalog {
|
||||
topics: Map<number, CatalogTopic>;
|
||||
problems: Map<number, CatalogProblem>;
|
||||
byIssue: Map<number, CatalogProblem>;
|
||||
}
|
||||
|
||||
const TITLE_RE = /^LC (\d+) · (.+) · (Easy|Medium|Hard) · (core|optional|deferred)$/;
|
||||
|
||||
/**
|
||||
* Walk every `topic` issue and its sub-issues into one lookup structure.
|
||||
* The sub-issue linkage is authoritative: every problem hangs off exactly
|
||||
* one topic (verified against the full issue inventory).
|
||||
*/
|
||||
export async function fetchCatalog(gh: GitHub): Promise<Catalog> {
|
||||
const topics = new Map<number, CatalogTopic>();
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
||||
const t = raw as {
|
||||
number: number;
|
||||
title: string;
|
||||
pull_request?: unknown;
|
||||
milestone?: { number: number } | null;
|
||||
};
|
||||
if (t.pull_request) continue;
|
||||
const name = t.title.replace(/^Topic \d+ — /, "");
|
||||
topics.set(t.number, {
|
||||
issue: t.number,
|
||||
title: t.title,
|
||||
name,
|
||||
milestone: t.milestone?.number,
|
||||
});
|
||||
}
|
||||
|
||||
const problems = new Map<number, CatalogProblem>();
|
||||
const byIssue = new Map<number, CatalogProblem>();
|
||||
for (const topic of topics.keys()) {
|
||||
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
|
||||
const s = raw as { number: number; title: string; state: string; body?: string };
|
||||
const m = s.title.match(TITLE_RE);
|
||||
if (!m) continue; // non-curriculum sub-issue
|
||||
const url = (s.body ?? "").match(/https:\/\/leetcode\.com\/problems\/[a-z0-9-]+\/?/)?.[0];
|
||||
const p: CatalogProblem = {
|
||||
lc: Number(m[1]),
|
||||
issue: s.number,
|
||||
name: m[2]!,
|
||||
difficulty: m[3] as CatalogProblem["difficulty"],
|
||||
set: m[4] as CatalogProblem["set"],
|
||||
topic,
|
||||
open: s.state === "open",
|
||||
url: url ?? "",
|
||||
};
|
||||
problems.set(p.lc, p);
|
||||
byIssue.set(p.issue, p);
|
||||
}
|
||||
}
|
||||
return { topics, problems, byIssue };
|
||||
}
|
||||
|
||||
// ── shared constants ─────────────────────────────────────────────
|
||||
|
||||
export const TODAY_TITLE = "📋 Today";
|
||||
Reference in New Issue
Block a user