EEsther Nam
← Back to How I built it
Dance Coach · Project docs

VECTOR.md and CLAUDE.md

Claude Code read both files at the start of every session. VECTOR.md is the source of truth for the product: what it is, who it's for, the data model, and the current state of the build. CLAUDE.md is the shorter working brief: the stack, the user flows, the constraints, and what not to build yet.

These are the current versions from the project repo, unedited.

VECTOR.md Source of truth for the product 572 lines Last edited Sep 8, 2026

Project Overview

Dance Coach is a mobile-first dance coaching app for Latin dance teams (bachata and salsa). It solves the gap between class and practice: instructors teach choreo in class, but have no visibility into how students practice during the week, and students have no structured feedback until the next class.

Instructors upload a reference video of a choreo piece. Students record themselves and get immediate AI timing feedback during practice — as many times as they want. When ready, they submit a final version. The instructor reviews the AI analysis, adds their own note, and releases feedback to the student.

The AI does the grunt work. The instructor owns the relationship.

Users

Instructors — teach bachata and salsa at studios and/or lead dance teams. Currently manage student practice informally via Band app (video uploads, group chats). Pain point: no visibility into student practice, spend time on feedback that students may forget by next class.

Students — range from recreational to competitive. Film themselves constantly but may need guidance between classes. Want faster feedback and a way to self-assess before showing the instructor.

Jobs to Be Done

  • Instructor: assign choreo homework without managing video DMs
  • Instructor: review student submissions efficiently with AI pre-analysis
  • Instructor: add context and voice to AI output before releasing to student
  • Student: practice with real feedback, not just filming into the void
  • Student: know if they're ready to submit before the instructor sees it
  • Student: understand specifically what counts they're hitting late or early

Architecture

  • Mobile app: React Native + Expo (TypeScript) — student facing
  • Dashboard: React — instructor facing. Platform resolved 2026-08-18: both roles get both device types, so every screen works at phone and desk width. Breakpoints live in packages/ui tokens — 900px for navigation and My Videos, 1024px for the instructor's two-column review

  • Backend: Python FastAPI on Railway

  • Pose estimation: MediaPipe BlazePose
  • Video storage: Cloudflare R2 — direct upload via presigned PUT
  • Auth: Clerk, wired in. Roles come from Clerk public metadata
  • Shared package: packages/metrics — feedback thresholds and math
  • Monorepo: npm workspaces (app/, backend/, dashboard/, packages/)

Key architecture decisions:

  • Server-side processing only — no on-device inference for MVP
  • FRAME_STRIDE=3 default — performance vs accuracy tradeoff on Railway shared CPU
  • Reference pose extracted once at instructor upload, not per student submission
  • Direct upload to storage with signed URLs — video never passes through FastAPI
  • Instructors upload a music reference video, a counts reference video, or both. At least one is required; neither individually is. Whichever is analysed supplies both the pose sequence (MediaPipe) and the beat grid (librosa). The music version is preferred whenever present, because a track's pulse places each beat more precisely than a spoken count does. A piece analysed from counts alone is flagged, and its timing is presented with the extra uncertainty that carries.
  • Filming direction is detected, not prescribed. Whether each video was shot from the front or from behind is read from the pose data, and the backend mirrors the student's landmarks horizontally only when the two clips face opposite ways. Instructors and students can film whichever way suits them. No partner detection in v1.

MVP Scope — Direction 1

Direction 1 measures both timing AND technique, relative to the instructor's reference video. The instructor's movement at each moment is the standard — no separate absolute thresholds are needed.

Measured per count/beat:

  • Timing — did the student hit the movement at the right moment
  • Hip displacement — does lateral hip movement match the reference
  • Weight transfer — does the shift happen at the same point in the phrase
  • Footwork — does the student's pattern align with the reference
  • Frame and posture — does upper body position match
  • Knee flexion — does depth match the instructor's

Not in scope for v1:

  • Partner/duo detection
  • Aesthetic or stylistic feedback beyond what pose data captures
  • Absolute technique thresholds (Direction 2)
  • Frame-perfect synchronization

Feedback Model

There are two distinct feedback tiers with different audiences, tone, and purpose. The underlying AI analysis is the same raw data — what changes is framing and authority.

Practice feedback (immediate, student-facing):

  • Delivered instantly after each practice attempt
  • No instructor involvement
  • Tone: encouraging and directional — answers "what do I fix right now?"
  • Scope: top 1-2 actionable items, not exhaustive
  • Explicitly labeled as AI feedback
  • Example: "Your weight transfer on counts 3 and 7 is landing late — try initiating the shift a beat earlier"

Released feedback (instructor-gated, formal):

  • Only delivered after instructor reviews and approves the final submission
  • Instructor adds their own note before releasing
  • Tone: authoritative — carries the instructor's voice, not just AI output
  • Scope: more comprehensive, includes instructor context and nuance
  • Labeled as instructor feedback, with AI analysis as supporting detail
  • Triggers a push notification to the student when released

The distinction that matters: Practice attempts are informal and self-directed — student sees AI immediately, instructor never sees them. Final submissions are formal — instructor reviews AI analysis, annotates, and releases. The gate applies to final submissions only, not practice.

Data Model

User — id, role (instructor/student), name, email, clerkId

Team — id, name, instructorId

Enrollment — studentId, teamId

ChoreoPiece — id, teamId, name, weekNumber, referenceVideoCountsUrl, referenceVideoMusicUrl (at least one required), analysisSource (music/counts — which video was analysed), referencePoseSequence (stored JSON, null until analysed), beatTimestamps (extracted from that video's audio via librosa, null until analysed), status (draft/published), analysisState (processing/ready/failed) with frame counts, measured fps and a failure reason for reporting progress

PracticeAttempt — id, studentId, choreoPieceId, videoUrl, analysisResult (JSON), createdAt (informal — student sees AI feedback immediately, instructor never sees)

Submission — id, studentId, choreoPieceId, videoUrl, analysisResult (JSON), status (processing/failed/pending/reviewed/released)

InstructorNote — id, submissionId, instructorId, note, createdAt

Constraints

  • Filming direction is detected, not prescribed. Whether each video was shot from the front or from behind is read from the pose data, and the backend mirrors the student's landmarks horizontally only when the two clips face opposite ways. Instructors and students can film whichever way suits them. No partner detection in v1.

  • At least one reference video is required, either version. The music version is analysed whenever it exists; with counts only, the counts video is analysed instead and flagged as a lower-confidence beat grid. Both are surfaced to students as learning references.

  • Clip length is not artificially capped — typical choreo phrases run 20-60 seconds; longer clips increase processing time on Railway's shared CPU (mitigated by FRAME_STRIDE=3, properly solved by background job queue which is not yet built)

  • No on-device processing — requires internet connection

  • MediaPipe accuracy degrades with fast movement and baggy clothing
  • Technique feedback is relative to instructor reference, not absolute thresholds — the instructor's movement IS the standard

  • No payments or subscriptions in v1

  • No social or community features in v1
  • Practice feedback comes back on the response that files the attempt, and stays that way until a real job queue exists. Backgrounding it would give the student a progress bar as honest as the instructor's, but it turns "record and know immediately" into post, poll, then navigate — and immediacy is the point of the tier, not an accident of how it was built. The in-process task behind the reference analysis is not a good enough foundation to reshape it on. Revisit when the queue is real.

  • Direction 2 (fundamentals library + progress tracking) is next phase — do not build yet

Future: Partner Work

Partner detection is out of scope for v1 and v2 but is central to Latin dance teams long term. Planned phases:

Phase 2 — Role-separated solo analysis. Leader and follower each submit their own solo video of the same piece. The app analyses each against a role-specific reference: the instructor films the leader part and the follower part separately. No multi-person detection needed.

Phase 3 — Duo detection. Two tracked skeletons in one frame. Requires multi-person pose estimation (OpenPose or its successor) with enough accuracy for close-contact Latin dance movement. Dependent on computer vision tooling maturity.

Phase 4 — Interaction analysis. Connection quality, lead clarity, follower responsiveness. Research-level today, productizable in 3-5 years.

Architecture note: keep role (leader/follower) as a field on Enrollment and Submission from Phase 2 onward — it costs nothing now and makes Phase 3 significantly cleaner to build.

Current State

All thirteen build prompts are delivered, and Direction 1 works end to end: an instructor sets a piece, a student practises against it and gets feedback in seconds, hands a take in, and reads their instructor's note when it is released. Deployed and running on Railway.

That is the feature sequence finished, not the product. What is missing below was never part of those prompts — auth, notifications and a job queue are the difference between a working demo and something a real team could use on Monday, and none of them are started.

Built:

  • Expo mobile app — navigation shell, Record screen (assignment picker, camera + camera roll, preview with the practice/submit choice), and a Feedback screen with both real templates: practice feedback (score, one or two corrections, labelled AI, with a Submit for Review button) and released feedback (instructor's note first, then the score, markers and count-by-count detail behind it).

  • My Videos — three sections: active assignments, practice history, and submissions with their status. An assignment card says whether it has been submitted and flags a counts-only reference. Practice entries reopen the feedback they were given rather than re-analysing. Submissions open a holding screen whose wording matches the status, so reviewed reads as almost ready rather than as the same wait as pending.

  • The app opens on My Videos, and the bar reads My Videos, Record, Feedback — the order the week runs in. A student opening the app is more often checking whether feedback landed than ready to film that second.

  • Reference viewing, between the assignment list and the camera. Tapping an assignment opens whichever reference videos exist, labelled and each with its own play button; starting one pauses the other. From there Start Recording goes to the camera with the piece already chosen. A student can come back and rewatch as often as they like, which is most of how a piece gets learned. Each video has its own play and fullscreen button rather than leaving fullscreen to the native overlay, which only appears after a tap and fades again — watching footwork on a phone-width card is the whole reason to want it. Built as a screen hidden from the tab bar rather than as a stack screen: adding @react-navigation/native-stack pulls @react-navigation/native forward and breaks the React 19 JSX types across every file in the app. Noted in navigation/types.ts so it isn't "fixed" by adding the stack back.

  • Student-scoped listing routes, kept apart from the instructor's. GET /submissions is the instructor's queue and is ungated by design; the app reads /students/{id}/... instead, which goes through the same gated view as every other student-facing route.

  • FastAPI backend on Railway, healthy. Pose estimation via MediaPipe at FRAME_STRIDE=3 everywhere, local and deployed, so a reference and a student clip are always sampled the same way. (/analyze still exists but nothing in either client calls it — the student flow goes through /practice-attempts and /submissions.)

  • ChoreoPiece flow — instructor creates a piece, registers reference video URLs, and publishes it. Pose sequence (MediaPipe) and beat grid (librosa) are extracted once at registration and stored on the row.

  • Either reference video is enough. Music, counts, or both; at least one is required and neither individually is. The music version is analysed whenever it exists. With counts only, the counts video is analysed instead — librosa reads a grid off the onset of each spoken number — and the piece is flagged beats_low_confidence. That flag travels: it is stored on the row, returned by the API, threaded into the comparison, and softens the timing correction a student is shown, so nothing presents a voice-derived beat with a track's precision.

  • Video storage — Cloudflare R2 presigned-PUT direct upload, live in production since 2026-08-10. Video never passes through FastAPI: the client uploads straight to storage and only the URL comes back. Reference and student clips go to separate prefixes, so a retention policy on student takes can never sweep up an instructor's reference. Every URL is checked against our own storage before being stored or fetched. Without credentials it falls back to local disk so the flow runs with no accounts set up, and that fallback closes itself off as soon as real storage exists; /health reports which mode is live.

    R2 replaced Cloudinary because Cloudinary's plan refuses a single file over 100 MB and three minutes of 4K from a phone is 1.5-2 GB, which was asking instructors to compress their own reference videos. R2 takes 5 GiB in one PUT and charges nothing for egress — the deciding cost here, since every student comparison reads the reference back, so downloads vastly outnumber uploads. The size limit is reported by the backend with each signature rather than hardcoded in the clients, because it differed by two orders of magnitude between the two.

    Cloudinary was removed on 2026-08-12. It had been kept configured because pieces uploaded before the switch still held Cloudinary URLs — but those pieces were deleted rather than backfilled, and a query against production confirmed no Cloudinary URL survives on any piece, submission or practice attempt. Keeping a dormant provider meant every storage change had to be made twice and reasoned about twice.

    The local-disk fallback stayed, but only when asked for: ALLOW_LOCAL_STORAGE=true and no R2 credentials. R2 wins whenever it is configured, so the flag can never take storage away from a real bucket. It used to happen automatically whenever no provider was set, and automatic was the bug — a deploy that lost its R2 variables would keep accepting student takes and write them to a container disk that does not survive a restart. Nothing failed; the videos simply stopped existing. /health reports r2, local or unconfigured, so the wrong mode is visible before it costs anything.

  • Clerk auth, across all three pieces. Every route except /health requires a verified session token; the id on a request is the one in the token rather than one the client named. Verification is local against Clerk's JWKS, so no call to Clerk sits on the request path and Clerk being briefly unreachable does not sign anyone out. With the Clerk variables unset the backend refuses everything rather than waving requests through — a deploy that forgets them should serve nothing, not everything.

    Role comes out of the token, never a request body. A Clerk user's public metadata carries {"role": "instructor"}, set in the Clerk dashboard; anyone without it is a student, which is the answer that can only see its own work. The spec this was built from had /users/sync accept a role from the client, which would have let any student POST themselves into the review queue — the one route that returns every submission's analysis before release. The role column on User is a mirror kept for querying and is refreshed on every sync; it is never what an endpoint decides on.

    Scope is by team throughout: an instructor sees their own team's queue, a student their own team's published pieces and their own work alone. Someone else's piece or submission answers 404 rather than 403, since the ids are UUIDs and confirming one exists tells an outsider about another team for no benefit.

    An instructor signing in on the phone gets a screen pointing at the dashboard rather than a set of tabs. Whether reviewing belongs on a phone is still open, and building the tabs would answer it by accident. That screen reads the dashboard's address from GET /client-config rather than carrying it: Expo inlines EXPO_PUBLIC_ variables at build time, so deploying the dashboard for the first time — or moving it — would otherwise cost an EAS build and a store round trip to change one string. With nothing configured the screen says the address has not been set rather than offering a dead link.

  • User, Team and Enrollment exist as tables for the first time — migration 0008. They had been in this document's data model since the beginning without ever being built. Nothing existing was deleted: the three placeholder ids became real rows, so every piece, submission and note made before auth still resolves, and the first instructor to sign in takes over the placeholder team along with its work.

  • Student flow — the student picks a published piece before filming, then chooses what to do with the take. Practice attempts come back with AI feedback immediately and the instructor never sees them; submissions enter the review queue and stay hidden from the student until an instructor releases them. Separate tables, separate endpoints.

  • A clip out of the camera roll is re-encoded to 720p on the phone before it is uploaded, starting the moment it is picked rather than when the student decides what to do with it. A recorded take is already capped at capture and skips it. Measured on a real submission the three waits were 14s encoding, 9s uploading and 12s of backend analysis; the encode needs nothing from the student and they spend about as long watching the take back, so the two now overlap and the encode costs almost nothing in wall-clock. Submitting before it finishes waits on the encode already running instead of starting a second one, and discarding the clip cancels it — a cancel is told apart from a failure in the trace, since otherwise every discarded clip would log the line that matters for spotting a silent fallback. The picker's own videoQuality would have handled this, but that option is iOS-only — on Android it is ignored and a 4K original comes through whole, which is the input to the heap exhaustion this flow has been killed by twice. The encode is not itself a memory risk, which was checked rather than assumed: on Android frames go decoder → GL surface → encoder and never reach the Java heap as pixel buffers, on iOS AVAssetReader/AVAssetWriter pull one sample at a time, and both hand back a file path rather than bytes. The bar names the step, because re-encoding a long take is not instant and "Uploading" sitting at 0% reads as a hang. A failed transcode uploads the original rather than refusing the take, and says which happened in the trace — transcoded from=… to=… against transcode failed … uploading=original — since a silent fallback and a working transcode otherwise look identical.

  • POST /compare — the Direction 1 engine. Runs pose estimation on the student's video, mirrors it when the two were filmed from opposite sides, finds the downbeat and the student's own start offset, and reports all six markers per beat plus an overall score. Tolerance on each marker is a fraction of how much the instructor moved it, so the standard is the reference and never a fixed number. Returns both feedback tiers from one measurement: practice_feedback (one or two items, in dance language) and full_analysis (every beat, for instructor review). Whether the student was mirrored is decided once and then reported — student_mirrored on the comparison — so the review screen can say the two clips face opposite ways instead of asserting a filming convention that orientation detection replaced. It says nothing when they agree, which is most pairs, and nothing on a submission analysed before the flag existed: absent means unknown, not matching.

  • Both feedback tiers, end to end. Practice attempts run the real comparison and return the practice tier only. Submissions run it, store the whole thing, and withhold it — along with the instructor's note — until release. The instructor dashboard reviews the full analysis beside both videos, saves a note, and releases.

  • InstructorNote — its own table, one per submission. Writing and releasing are separate acts, so a note can be drafted and revisited without the student seeing a word of it.

  • Database — SQLAlchemy + Alembic. Postgres on Railway, SQLite locally. Ten migrations, ending at 0010_scoring_version. scripts/check_docs.py verifies that count and that revision against the repo, because both were wrong here for weeks.

  • Reference analysis runs in the background, reporting real progress. POST /choreo-pieces returns in milliseconds rather than holding open for the whole analysis; the row carries frames done against frames expected and the frames per second the machine is managing. The dashboard polls that and shows one bar across upload and analysis, weighted by estimated time so neither half dominates, with no countdown — an estimate that turns out wrong costs more trust than it buys, so it only decides how much of the bar each half occupies. Publishing is refused while a piece is processing, and a failed analysis records a reason an instructor can act on.

  • Deploys migrate themselves. Railway's preDeployCommand runs backend/migrate.py after the build and before any traffic reaches the new version, so the schema is never behind the code reading it and a failed migration blocks the release. It also adopts a database that create_tables() built outside Alembic, which is the state the first Postgres arrived in.

  • Instructor dashboard — both tabs on the real backend. Create Assignment, and a review queue showing the student's take beside the reference, the markers summary, the count-by-count breakdown, the note box and the release button.

  • The dashboard is responsive, one codebase and one URL. Wider on large screens, because judging two videos against each other is what a desktop is for and 820px gave each clip under 400px. On a phone the videos stack and cap their height, the markers table becomes blocks rather than four columns in 360px, and the note and release button go full width — reviewing and releasing between classes is the job a phone is actually good for here.

  • Shared metrics package — feedback thresholds, monorepo wired

  • npm hoisting no longer breaks the app's TypeScript build. Adding any package to the app workspace used to re-hoist react-native to the root while @types/react stayed in app/node_modules, at which point React Native's own types could not resolve React's and every View and Text stopped being valid JSX — several hundred errors naming nothing relevant. @types/react is declared at the root so it lands where the hoisted react-native can find it. Written up in the README, because a root devDependency in a repo whose root imports nothing looks exactly like something to tidy away.

  • EAS Update configured. Project @enam91/dance-coach, expo-updates installed, runtime version tied to app version so a JS bundle cannot land on a build whose native code cannot run it. The preview profile builds an internal-distribution APK on the preview channel with EXPO_PUBLIC_API_URL pointed at Railway, so an installed build works anywhere rather than only on the same network as a laptop. App version 1.1.0 — bumped with the transcoder because the runtime version follows it, and a JS bundle calling a native module that the installed 1.0.0 build does not carry is exactly what that policy exists to stop.

  • Android largeHeap is in a built APK as of 1.1.0. It had been configured in app.json for some time and was only ever waiting on a build.

  • Git — monorepo with direction-2-baseline branch as snapshot

Production, confirmed working (2026-08-08):

  • Railway service healthy and serving the current main.
  • Postgres attached, at revision 0010_scoring_version. Migrations run themselves before each deploy; a failure blocks the release rather than leaving schema and code out of step.

  • R2 live (2026-08-10), replacing Cloudinary. /health reports r2, the signature endpoint returns a real SigV4 presigned PUT against the account with the content type signed in, and a live PUT was accepted — so credentials, permissions and signing are all confirmed end to end. Clips file under reference-videos/ and student-videos/. The local-disk fallback refuses uploads, so the one path where video bytes could travel through the service is unreachable in production.

  • Cloudinary was removed on 2026-08-12, after a query against production found no Cloudinary URL on any piece, submission or practice attempt.

  • Nothing is stranded from the local-disk period: the database was empty when Cloudinary went in, so no row points at a video that no longer exists.

Repair tooling:

  • backend/scripts/backfill_playback.py gives a piece the 720p playback copy it never got. Every piece analysed from 2026-08-10 onward gets one at upload time, so this is for the case where that transcode failed and left a piece falling back to its 4K original.

  • It only transcodes, uploads, and writes the two playback URL columns — never analysis_state, the pose sequence, the beat grid or the publish status — so a failure leaves a working published piece exactly as it was. Idempotent, and it commits per piece, so an interrupted run keeps what it finished. --dry-run lists what it would touch.

  • It needs to run inside the deployed container. Railway's Postgres is postgres.railway.internal with no public proxy, so the database is unreachable from a laptop, and the script needs the database, R2 credentials and ffmpeg in the same place. Running it means railway ssh --service web -- python scripts/backfill_playback.py, which requires an SSH key registered with the Railway account — there is deliberately no admin endpoint for this, since it would mean a public, CPU-expensive operation standing permanently in front of an API that has no auth yet.

Known limits in what is built:

  • (Fixed 2026-08-10) Timing was measured against the beat grid rather than against the reference, making it the one marker judged by an absolute standard — and one the reference itself did not meet. The same video uploaded as both reference and submission scored 75 with 160 of 192 beats called off, because the instructor's own movements sat more than 100ms from the detected beat. Dancers hold and syncopate, and librosa's grid tracks the music's pulse rather than the moment a body arrives. Both dancers are now measured from their own beat and only the difference is reported; that same video now scores 100. Drifting takes are still caught — 3% slow scores 47.

  • A student who is uniformly behind the music reads as on time. /compare estimates one global offset between the two takes and subtracts it, because a student who pressed record a moment late has not thereby danced late — but a constant lag is indistinguishable from a late start, so it is absorbed along with it. Drifting, and being off on particular counts, are both still caught. Detecting steady lateness needs a beat grid from the student's own audio, measuring each dancer against their own music rather than against each other. Not built.

  • (Fixed 2026-08-11) Every assignment used to land on one placeholder team, every student was the same placeholder student, and every note was attributed to the same placeholder instructor, so the review queue showed one team's work to anyone who opened it. Clerk auth replaced all three. The placeholder rows still exist and still own the work made before sign-in existed; the first instructor to sign in adopts them.

  • Student video analysis still runs inside the request, so a practice attempt or a submission holds the connection open for its duration. Reference analysis no longer does — see below.

  • The reference analysis job is in-process, not a queue. A container restarting mid-analysis strands a piece at whatever fraction it had reached, because nothing outside the process knows the job existed. analysis_updated_at is written on every step so a stalled piece can be identified; retries need a real broker.

  • Background upload is not supported. A student's upload survives switching tabs and even the Record screen remounting — it belongs to a module rather than to the screen — but not the app being killed, which takes the JavaScript streaming the bytes with it. Surviving that needs a native background upload session, which only pays off once the job queue above exists to hand a completed upload to: an upload that outlives the app is not much use while the analysis it feeds still runs inside a request.

  • Releasing a submission has no notification behind it yet — the call site is marked in the release endpoint

  • (Resolved 2026-08-10) The EAS preview build is installed on a phone and three updates have been published to it successfully, so "preview from anywhere" is proven rather than configured.

  • A beat grid read from a counts video is looser than one from music. Someone counting aloud drifts in a way a recording does not, and a spoken number places a beat less precisely than a drum. Pieces built that way are flagged and their timing feedback is hedged, but the underlying grid is still the looser one.

  • The transcode is best-effort by design, so a 4K original can still go up. A student whose encode fails has still filmed their take, and refusing it would be worse than sending it large — but that means the size ceiling is a strong default rather than a guarantee, and the backend's own limits are still what actually bounds a request. The trace says which path ran; nothing else does.

  • Encoding on pick means the preview player and the encoder now run at the same time, where before the player was unmounted before any encoding began. Both are native and neither holds frames on the JS or Java heap, but it is three codec sessions at once on the picked-clip path — preview decode, encode decode, encode — and devices do cap concurrent instances. Not seen to fail, not measured either.

  • Nothing has measured the heap on a real 4K take yet, and until 2026-08-12 it could not have been. The [paso] traces used console.log, which does not reach logcat in a release build — the only kind installed on a phone — so every adb logcat | grep paso instruction in this repo was greping for lines nobody was emitting where they could be read. Measured, not assumed: a launch showed Clerk's own console.warn arriving as W ReactNativeJS with no info-level line from any trace beside it. trace() now uses console.warn.

    So the claim that the encode streams rather than buffering still rests on both platforms' source — GL surfaces between decoder and encoder on Android, one sample at a time on iOS — and dumpsys meminfo during an actual transcode remains the evidence that has not been taken. This is the third fix aimed at the same 256 MB and the first two were also reasonable before they were wrong.

  • The mirror note on the review screen only appears for submissions analysed after student_mirrored existed. Older rows have no such key in their stored analysis and are read as unknown, so they say nothing — correct, since their orientations were never recorded, but it does mean the screen is quieter about old work than about new.

Not built yet:

Still in dependency order. Auth is now done, which unblocks the rest: push tokens have a real user to attach to. The queue in turn depends on notifications, because backgrounding analysis without a way to deliver the result turns "record and know immediately" into post-then-poll. JOB-QUEUE.md holds the scope for the last of these.

  • Student enrollment by invitation. A student signing in today joins the only team if the database has exactly one, and is left unenrolled when there are none or several — which is right, but it is a single-studio answer rather than an enrollment flow.

  • Push notifications — nothing is built; the release flow has no delivery mechanism yet

  • Background job queue for video processing — scoped in JOB-QUEUE.md, deliberately not started

CLAUDE.md Working brief for Claude Code 161 lines Last edited Sep 8, 2026

Project Overview

Dance Coach is a mobile-first dance coaching app for Latin dance teams (bachata and salsa). Instructors upload reference videos of choreo pieces. Students practice against the reference and get immediate AI timing feedback, then submit a final version for instructor review and release.

VECTOR.md is the source of truth for what we are building and why. Read it alongside this file at the start of every session.

Two companions: ARCHITECTURE-DEBT.md is what is wrong with what exists and what it will cost; JOB-QUEUE.md scopes the background job queue, which is deliberately not built.

Run npm run check-docs after editing VECTOR.md or CLAUDE.md. It verifies the claims in them that can be checked against the repo — migration count, head revision, dependencies named as installed, storage providers, "Not built yet" entries, API paths. Both files have been wrong about all of those at some point.

Tech Stack

  • Frontend: React Native with Expo (TypeScript) — student facing
  • Dashboard: React — instructor facing. Platform resolved 2026-08-18: both roles get both device types. An instructor may review on a phone and a student may open their feedback on a laptop, so every screen has to work at both ends. Two breakpoints, in packages/ui tokens: 900px moves navigation from a bottom tab bar to a left sidebar and splits My Videos into two columns; 1024px turns instructor review into a two-column layout. Everything else adapts by capping a column rather than switching layout

  • Backend: Python FastAPI on Railway

  • Pose Estimation: MediaPipe BlazePose
  • Video Storage: Cloudflare R2 — direct upload via presigned PUT only; video never passes through FastAPI. Cloudinary was removed 2026-08-12 once production held no Cloudinary URLs. A local-disk fallback exists for development behind ALLOW_LOCAL_STORAGE, off by default; R2 wins whenever it is configured

  • Deployment settings the clients need are served at GET /client-config, not baked into builds — Expo inlines EXPO_PUBLIC_ variables, so a value built in costs an EAS build to change

  • Auth: Clerk — wired in across backend, app and dashboard. Role (instructor/student) comes from Clerk public metadata, read from the verified token and never from a request body

  • Hosting: Railway (backend)

Project Structure

Single repository, npm workspaces for JavaScript projects.

  • /app — Expo React Native frontend (student facing)
  • /backend — Python FastAPI server
  • /dashboard — Instructor dashboard (React)
  • /packages/metrics — Shared analysis types and feedback thresholds, used by both /app and /dashboard. Note: src/feedbackMetrics.ts holds absolute technique thresholds (hip displacement, consistency, knee flexion) that belong to Direction 2, not Direction 1's reference-based comparison. They are kept dormant on purpose as groundwork for the fundamentals assessment feature — do not remove them.

Core User Flows

Instructor Flow

  1. Create a Choreo Piece (name, week number)
  2. Upload reference video — music version, counts version, or both (at least one; filmed from whichever side suits — the backend works out which)

  3. Backend extracts pose sequence and beat timestamps at upload time — not per student submission

  4. Publish — students can now see and submit against it

  5. Review student final submissions: AI comparison results + add note
  6. Release feedback — triggers push notification to student

Student Flow

  1. See active assignments on My Videos screen
  2. Select a Choreo Piece
  3. Record or upload a video, filmed from either side
  4. Receive immediate AI practice feedback (no instructor involvement)
  5. Repeat as many times as needed
  6. When ready, submit a final version
  7. Wait for instructor review and release
  8. View released feedback — AI analysis + instructor note

Feedback Model

Two tiers — same underlying AI analysis, different framing and authority.

Practice feedback (immediate, student-facing):

  • Delivered instantly after each practice attempt
  • No instructor involvement
  • Encouraging and directional — answers "what do I fix right now?"
  • Surfaces top 1-2 actionable items only
  • Explicitly labeled as AI feedback

Released feedback (instructor-gated, formal):

  • Delivered only after instructor reviews and annotates final submission
  • Carries instructor's voice and authority
  • More comprehensive — includes instructor note alongside AI analysis
  • Triggers push notification to student

Data Model (source of truth: VECTOR.md)

  • User (instructor / student, mirrored from Clerk on sign-in)
  • Team (one instructor owns one team in v1)
  • Enrollment (student × team)
  • ChoreoPiece (includes stored referencePoseSequence and beatTimestamps)
  • PracticeAttempt (student sees AI feedback immediately; instructor never sees)
  • Submission (formal; status: processing / failed / pending / reviewed / released)
  • InstructorNote (attached to Submission)

Key Constraints

  • Filming direction is detected, not prescribed. Whether each video was shot from the front or from behind is read from the pose data, and the backend mirrors the student's landmarks horizontally only when the two clips face opposite ways. Instructors and students can film whichever way suits them. No partner detection in v1.

  • At least one reference video is required — music version, counts version, or both. Neither is individually required. The music version is analysed whenever it is present; with counts only, the counts video is analysed and its beat grid is flagged as lower confidence, because a spoken count places a beat less precisely than a track. Both versions are surfaced to students as learning references.

  • No hard clip length cap — typical choreo phrases run 20-60 seconds; longer clips increase Railway processing time (FRAME_STRIDE=3 mitigates; background job queue is the proper fix, not yet built)

  • Server-side processing only — no on-device inference

  • FRAME_STRIDE=3 default on all video processing
  • R2 presigned-PUT direct upload — video never routed through FastAPI
  • No payments, subscriptions, or social features in v1
  • Direction 2 (fundamentals / level assessment) is next phase — do not build

Domain Context

  • Target styles: bachata and salsa
  • Direction 1 measures both timing AND technique — relative to the instructor's reference video, not absolute thresholds:

    • Timing — did the student hit movements at the right counts
    • Hip displacement — does the student's lateral hip movement match the instructor's at each moment
    • Weight transfer — does the shift happen at the same point in the phrase
    • Footwork pattern — does the student's footwork align with the reference
    • Frame and posture — does the student's upper body position match
    • Knee flexion — does the student's depth match the instructor's
  • The instructor reference IS the technique standard — no separate absolute thresholds needed for Direction 1

  • Direction 2 applies the same technique markers but against absolute thresholds (good/needs work) rather than a reference video

  • Feedback language should be specific to Latin dance, not generic fitness

  • Instructor relationship is central — app supports them, does not replace them
  • No filming convention to follow. The backend detects which way each video was filmed from the pose data and mirrors the student's landmarks only when the two face opposite ways. The old convention (references back-facing, students front-facing) was dropped because real footage did not follow it — see backend/app/analysis/orientation.py.

Code Style

  • Simple and readable over clever — maintainability is the priority
  • Add comments explaining what each section does
  • Small focused functions over large complex ones
  • Always handle errors gracefully with user-friendly messages
  • Make small changes autonomously and report after — minimize permission prompts

What NOT to Build Yet

  • Partner/duo pose detection
  • On-device processing
  • Social features or community feed
  • Payments or subscriptions
  • Absolute technique feedback (hip displacement, weight transfer, frame)
  • Direction 2 level assessment or progress tracking
  • Multi-studio or multi-team support
Back to top