Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"name": "Sight Reading Trainer",
"image": "mcr.microsoft.com/devcontainers/javascript-node:22-bookworm",
"forwardPorts": [
3000
],
"portsAttributes": {
"3000": {
"label": "Sight Reading Trainer",
"onAutoForward": "openBrowser"
}
},
"postCreateCommand": "npm ci",
"postStartCommand": "bash dev/start_preview.sh"
}
2 changes: 1 addition & 1 deletion .github/FUNDING.yml
Original file line number Diff line number Diff line change
@@ -1 +1 @@
github: leafo
buy_me_a_coffee: naphier
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
node_modules/
logs/
dev/out/
# Generated by `npm run build_assets` for frontend-only development.
static/guides/*.json
static/js/st/song_parser_peg.js
static/js/st/staff_assets.jsx
.tup
*.css
!static/js/**/*.css
Expand Down
22 changes: 22 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Development and test previews

- GitHub Codespaces is the user's established way to test this site. Prefer a
Codespaces preview when asked to deploy a test version.
- Use the existing checkout: cloud tasks are already isolated. Do not create a
Git worktree unless explicitly requested.
- The default devcontainer runs the frontend-only React/esbuild workflow on port
3000. It installs locked dependencies and automatically starts the preview.
`bash dev/start_preview.sh` can safely check or restart it; logs are in
`dev/out/preview.log`.
- From a personal GitHub CLI login with Codespaces permission, run
`bash dev/create_codespace.sh [branch]` to create or reuse a preview. Keep port
3000 private unless the user explicitly requests public sharing. Codespaces can
consume the account's quota; use the smallest available machine and an idle
timeout. Do not delete existing codespaces or user work automatically.
- A frontend-only preview supports guest preferences, session reset and streak
displays. Account settings sync and account Stats require the Lua/Lapis backend,
PostgreSQL, and `lapis migrate`; do not claim they work in the default preview.
- The repository Dockerfile runs CI, not a persistent production deployment.
Production database files must live in persistent storage across deployments.
- Git push and repository API access do not prove Codespaces access. Check the
specific API permission and report any denial. Never ask for tokens in chat.
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,78 @@ watches for changes, and reloads the page automatically. Backend-only features
(login, stats, the play-along song library) will not function in this mode;
everything else works, including MIDI input and output.

To generate a CSV table of every Note Math interval calculation for review and
include it in a pull request as verification:

```bash
mkdir -p dev/out && npm run --silent note_math_csv > dev/out/note-math.csv
```

## Remote development with GitHub Codespaces

To test a pull-request branch without installing anything locally:

1. Open the pull request on GitHub, select **Code**, then **Codespaces**, and
create a codespace on the pull-request branch.
2. Wait for the container setup to finish. Locked dependencies are installed and the frontend preview starts
automatically.
3. If the preview is not running, run `bash dev/start_preview.sh` in the Codespaces terminal.
4. When Codespaces forwards port 3000, select **Open in Browser**. If the
notification is missed, open the **Ports** panel and use the globe icon next
to **Sight Reading Trainer**.
5. Visit `/flash-cards/note-math` on the forwarded HTTPS URL.

The forwarded site runs in the codespace; only the browser is local. The
frontend-only limitations below still apply.

The full site build (backend + minified production assets) uses the
[tup](https://gittup.org/tup/) build system, see the Tupfiles in the repo.


## Trainer preferences and hit statistics

Signed-in accounts store trainer preferences in PostgreSQL; guests store them in
localStorage. Configure panel choices, Wait/Scroll mode, speed, keyboard visibility,
and the panel's open state are restored. Account preferences load with the session
and save automatically, with a separate per-account device cache. The page reports
failed account saves and retries when another setting changes or connectivity returns.
Account settings use last-write-wins behavior across devices.

Run `lapis migrate` before deploying this version: the migration adds the
`users.trainer_preferences` JSONB column. Production PostgreSQL must use persistent
storage (a volume or an external database) to retain preferences and existing hit
history across container replacement. The repository's Dockerfile is for CI;
it does not define a persistent production deployment.

Reset clears the current practice session's hits, misses, streak, last streak, and
per-note timing data. It preserves pending uploads and account history. Last streak
shows the most recently ended nonzero streak, including after consecutive misses.
The Stats page reports the best daily hits and average hits per active day across
all account history, grouped using the viewer's timezone offset. An active day has
at least one recorded hit or miss; inactive days are excluded from the average.

### Create a Codespaces preview from the command line

With a personal GitHub CLI login that has Codespaces access:

```bash
gh auth login
gh auth refresh -h github.com -s codespace
bash dev/create_codespace.sh codex/trainer-memory-and-stats
```

The helper reuses a codespace for that branch or creates one using the smallest
available machine, a 30-minute idle timeout and seven-day stopped retention.
Creating or running a codespace consumes your GitHub Codespaces quota. The branch
must already be pushed. Open the printed URL, wait for setup, then use **Ports →
3000 → Open in Browser**. New forwarded ports default to private; verify
**Port Visibility → Private** when reusing a codespace. The devcontainer automatically
starts the preview when the codespace starts; `dev/out/preview.log` contains logs.
When reusing a codespace, update its checkout to the latest branch commit after
preserving any local work. If it predates this devcontainer update, rebuild its container
or run `bash dev/start_preview.sh` manually after updating the checkout.

This is a frontend-only preview. Check guest settings by changing Configure options,
Mode, Speed and keyboard visibility, then reloading. Build a streak, miss a note,
and verify Last Streak stays visible through more misses. Reset should zero the
session counters. Account sync and account Stats need a backend-enabled deployment.
7 changes: 6 additions & 1 deletion app.moon
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ class extends lapis.Application
@flow("hits")\register_hits!
json: { success: true }

"/trainer-preferences.json": post =>
@flow("trainer_preferences")\save!
json: { success: true }

"/stats.json": get =>
assert_error @current_user, "must be logged in"
stats = @flow("hits")\get_stats {
Expand All @@ -55,7 +59,8 @@ class extends lapis.Application

stats = nil unless next stats

json: { success: true, :stats }
summary = @flow("hits")\get_summary { offset: @params.offset }
json: { success: true, :stats, :summary }

"/presets.json": get =>
assert_error @current_user, "must be logged in"
Expand Down
43 changes: 43 additions & 0 deletions dev/create_codespace.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
if [[ "${1:-}" == "--help" ]]; then
echo "Usage: bash dev/create_codespace.sh [branch]"
echo "Create or reuse a private frontend preview using your GitHub Codespaces quota."
exit 0
fi
repo=Naphier/sightreading.training
branch=${1:-$(git branch --show-current)}
[[ -n "$branch" ]] || { echo "Specify a pushed branch." >&2; exit 1; }
command -v gh >/dev/null || { echo "Install GitHub CLI and run gh auth login first." >&2; exit 1; }
# Check scoped access before creating a billable resource.
if ! machine=$(gh api "repos/$repo/codespaces/machines" --jq '.machines | sort_by(.cpus, .memory_in_bytes) | .[0].name'); then
echo "Codespaces access is required. With your personal GitHub CLI login, run: gh auth refresh -h github.com -s codespace" >&2
exit 1
fi
[[ -n "$machine" && "$machine" != null ]] || { echo "No available Codespaces machine." >&2; exit 1; }
# Reuse an existing preview for this branch, including stopped codespaces.
name=$(gh api --paginate --slurp user/codespaces | node -e '
let input = ""; process.stdin.on("data", chunk => input += chunk);
process.stdin.on("end", () => {
const [repo, branch] = process.argv.slice(1);
const space = JSON.parse(input).flatMap(page => page.codespaces).find(space =>
space.repository.full_name === repo && space.git_status.ref === branch && space.state !== "Deleted");
if (space) process.stdout.write(space.name);
});
' "$repo" "$branch")
if [[ -z "$name" ]]; then
name=$(gh api --method POST "repos/$repo/codespaces" \
-f ref="$branch" -f machine="$machine" \
-f devcontainer_path=.devcontainer/devcontainer.json \
-F idle_timeout_minutes=30 -F retention_period_minutes=10080 --jq .name)
echo "Created codespace: $name"
else
echo "Reusing codespace: $name"
echo "Update the checkout to the latest branch commit after preserving any local work."
fi
# Opening a stopped codespace resumes it. postStartCommand launches the app.
echo "Open: https://github.com/codespaces/$name"
echo "After setup finishes, open port 3000 from the Ports panel (set Port Visibility to Private)."
echo "If needed, run: bash dev/start_preview.sh"
echo "This preview has no account backend: test guest settings, reset and Last Streak."
3 changes: 3 additions & 0 deletions dev/note_math_csv.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {noteMathCsv} from "../static/js/st/note_math_helpers.mjs"

process.stdout.write(`${noteMathCsv()}\n`)
32 changes: 32 additions & 0 deletions dev/start_preview.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p dev/out
# Serialize startup calls from Codespaces lifecycle hooks and manual commands.
exec 9>dev/out/preview.lock
flock -w 60 9
ready() {
curl --noproxy '*' -fsS --max-time 3 http://127.0.0.1:3000/ | grep -q '<title>Sight Reading Trainer</title>' &&
curl --noproxy '*' -fsS --max-time 3 http://127.0.0.1:3000/dev/out/main.js -o /dev/null &&
curl --noproxy '*' -fsS --max-time 3 http://127.0.0.1:3000/dev/out/main.css -o /dev/null
}
if ready 2>/dev/null; then
echo "Frontend preview is ready on port 3000."
exit 0
fi
# Do not stop any existing process. A conflicting port will produce a clear error.
nohup node dev/serve.mjs > dev/out/preview.log 2>&1 9>&- &
preview_pid=$!
for ((attempt=0; attempt<60; attempt++)); do
if ready 2>/dev/null; then
echo "Frontend preview is ready on port 3000. Open it from the Codespaces Ports panel."
exit 0
fi
if ! kill -0 "$preview_pid" 2>/dev/null; then
cat dev/out/preview.log >&2
exit 1
fi
sleep 1
done
echo "Preview did not become ready. See dev/out/preview.log." >&2
exit 1
1 change: 1 addition & 0 deletions flows/formatter.moon
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ class FormatterFlow extends Flow
{
id: user.id
username: user.username
trainerPreferences: user.trainer_preferences
}


19 changes: 19 additions & 0 deletions flows/hits.moon
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,22 @@ class HitsFlow extends Flow
", offset, HourlyHits.types.hit, HourlyHits.types.miss
}


-- Full-history daily totals in the same local timezone as the chart.
get_summary: (opts={}) =>
assert_error @current_user
assert_valid opts, {
{"offset", is_integer: true, optional: true}
}
offset = "#{opts.offset or "0"} minutes"
rows = db.query "
select coalesce(max(hits), 0) as best_daily_hits,
coalesce(avg(hits), 0) as average_daily_hits
from (
select coalesce(sum(count) filter (where type = ?), 0) as hits
from hourly_hits
where user_id = ?
group by (hour - ?::interval)::date
) daily
", HourlyHits.types.hit, @current_user.id, offset
rows[1]
31 changes: 31 additions & 0 deletions flows/trainer_preferences.moon
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Flow from require "lapis.flow"
import assert_error from require "lapis.application"
import from_json, to_json from require "lapis.util"

class TrainerPreferencesFlow extends Flow
save: =>
assert_error @current_user, "must be logged in"
payload = @params.preferences
assert_error type(payload) == "string" and #payload <= 16384, "invalid preferences"
ok, preferences = pcall from_json, payload
assert_error ok and type(preferences) == "table", "invalid preferences"
assert_error preferences.version == 1, "unsupported preferences version"

-- Whitelist the preference document; never accept a user id or runtime stats.
out = { version: 1 }
for field in *{"staff", "generator", "key"}
value = preferences[field]
assert_error type(value) == "string" and #value <= 80, "invalid #{field}"
out[field] = value

assert_error preferences.mode == "wait" or preferences.mode == "scroll", "invalid mode"
assert_error type(preferences.speed) == "number" and preferences.speed >= 50 and preferences.speed <= 300, "invalid speed"
assert_error type(preferences.generatorSettings) == "table", "invalid generator settings"
out.mode = preferences.mode
out.speed = preferences.speed
out.generatorSettings = preferences.generatorSettings
for field in *{"keyboardOpen", "settingsOpen"}
assert_error type(preferences[field]) == "boolean", "invalid #{field}"
out[field] = preferences[field]

@current_user\update trainer_preferences: to_json out
3 changes: 3 additions & 0 deletions migrations.moon
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,6 @@ package.loaded.migrations =
"PRIMARY KEY (user_id, song_id)"
}


[1792195200]: =>
add_column "users", "trainer_preferences", "jsonb not null default '{}'::jsonb"
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"scripts": {
"dev": "node dev/serve.mjs",
"build_assets": "node dev/build_assets.mjs",
"note_math_csv": "node dev/note_math_csv.mjs",
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "leaf corcoran",
Expand Down
72 changes: 72 additions & 0 deletions spec/app_spec.moon
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,75 @@ describe "app", ->
}
}, res


describe "all-time hit statistics", ->
it "aggregates full history by local active day and isolates users", ->
db = require "lapis.db"
user = factory.Users!
other = factory.Users!
for row in *{
{user.id, "2000-01-01 23:00:00", "hit", 10}
{user.id, "2000-01-02 01:00:00", "hit", 20}
{user.id, "2000-01-03 12:00:00", "miss", 5}
{other.id, "2000-01-01 23:00:00", "hit", 999}
}
db.insert "hourly_hits", {
user_id: row[1], hour: row[2], type: HourlyHits.types\for_db(row[3]), count: row[4]
}
status, res = request_as user, "/stats.json?offset=120", expect: "json"
assert.same 200, status
assert.same 30, tonumber res.summary.best_daily_hits
assert.same 15, tonumber res.summary.average_daily_hits

it "returns zero summaries for accounts without activity", ->
user = factory.Users!
status, res = request_as user, "/stats.json", expect: "json"
assert.same 200, status
assert.same 0, tonumber res.summary.best_daily_hits
assert.same 0, tonumber res.summary.average_daily_hits

describe "trainer preferences", ->
it "requires an account", ->
status, res = request "/trainer-preferences.json", {
expect: "json", post: { preferences: "{}" }
}
assert.same 200, status
assert.truthy res.errors

it "saves preferences only for the authenticated account", ->
import to_json from require "lapis.util"
user = factory.Users!
other = factory.Users!
preferences = {
version: 1, staff: "bass", generator: "random", key: "Gb"
generatorSettings: { hands: 1 }
mode: "scroll", speed: 175, keyboardOpen: false, settingsOpen: true
}
status, res = request_as user, "/trainer-preferences.json", {
expect: "json", post: { preferences: to_json preferences, user_id: other.id }
}
assert.same 200, status
assert.true res.success
user\refresh!
other\refresh!
assert.same preferences, user.trainer_preferences
assert.same {}, other.trainer_preferences

it "rejects invalid preference documents", ->
user = factory.Users!
for payload in *{"bad json", "null", "{}", string.rep("x", 16385)}
status, res = request_as user, "/trainer-preferences.json", {
expect: "json", post: { preferences: payload }
}
assert.same 200, status
assert.truthy res.errors

it "rejects preference writes with invalid CSRF tokens", ->
user = factory.Users!
status, res = request_as user, "/trainer-preferences.json", {
expect: "json", post: { preferences: "{}", csrf_token: "invalid" }
}
assert.same 200, status
assert.same {"invalid csrf"}, res.errors
user\refresh!
assert.same {}, user.trainer_preferences
4 changes: 4 additions & 0 deletions static/guides/about.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,7 @@ memory. Reading ahead gives you time to figure out how to move your hands as
you're playing.



## Icon credits

<a href="https://www.flaticon.com/free-icons/reset" title="reset icons">Reset icons created by Rahul Kaklotar - Flaticon</a>
Loading