jungle-admin-data-patterns

/home/avalon/.hermes/skills/.archive/devops/jungle-admin-data-patterns/SKILL.md · raw

Jungle Studio Admin — Data Patterns & Pitfalls

Mariana Tek Data Quality Issues

1. Duplicate Reservations Per Customer

MT data has multiple reservation records per customer per class (same person, same class, different reservation IDs). Always use COUNT(DISTINCT customer_name) not COUNT(*) when counting reservations. customer_id is mostly NULL — use customer_name for dedup.

2. Reservation Statuses

9 statuses in data (verified counts): 'check in' (17703), 'standard cancel' (10035), 'penalty cancel' (1469), 'removed' (1316), 'penalty no show' (1173), 'pending' (789), 'graced no show' (228), 'graced cancel' (202), 'class cancelled' (7). Only count 'check in' and 'pending' as active reservations. Filter with: WHERE status IN ('check in', 'pending')

3. Date vs Time Columns

Reservations store class_start_date (DATE) and class_start_time (TIME) as separate columns. When displaying in frontend, MUST combine them: new Date(\\${date}T\${time}`). Using date-only string innew Date()` interprets as UTC midnight → shows as 5PM PDT.

ISO String Gotcha: Postgres DATE columns come through the API as full ISO strings (2026-04-06T00:00:00.000Z), NOT bare dates (2026-04-06). If you need to append a time component (e.g., + 'T12:00:00'), first strip the ISO suffix: dateStr.split('T')[0] + 'T12:00:00'. Otherwise you get Invalid Date.

4. Class Schedule Data

class_sessions table: class_date (DATE), class_time (TIME — renamed from start_time). Join with reservations on class_session_id.

Build System Pitfall — CRITICAL

The dashboard package.json build script was tsc -b && vite build. With noUnusedLocals: true in tsconfig, any unused variable in ANY file makes tsc exit with code 1, and && means vite build never runs. The dist/ can be silently stale for hours/days while npm run build appears to "succeed" (shows TS errors but npm exits 0).

Fix: Changed to tsc -b || true && vite build so TS errors don't block builds. Added build:strict for when strict checking is wanted. Always verify dist/ timestamp after build: ls -la dist/assets/index-*.js. If building with npx vite build directly works but npm run build doesn't update dist/, this is the cause.

Admin Check-In System

Status Update API

PATCH /api/reservations/:id/status — updates reservation status in local DB. Valid statuses (all 9 MT statuses): 'check in', 'pending', 'standard cancel', 'penalty cancel', 'graced cancel', 'penalty no show', 'graced no show', 'removed', 'class cancelled'. Sets manual_override = true, status_updated_at = NOW(), status_updated_by = 'admin'.

Manual Override Pattern (Local Supersedes Sync)

DB columns on reservations: manual_override BOOLEAN DEFAULT false, status_updated_at TIMESTAMPTZ, status_updated_by TEXT. Sync guard in sync-reservations.ts line 214: status = CASE WHEN reservations.manual_override = true THEN reservations.status ELSE EXCLUDED.status END. When admin changes a status, manual_override = true and MT sync will NOT overwrite it.

Frontend StatusButton Component

Tappable status badges with dropdown menu showing all 9 MT statuses. Uses optimistic UI — statusOverrides state map applied via useMemo over the reservations array. No page reload needed. Works on both desktop table and mobile card views. STATUS_STYLES map must cover all 9 statuses with appropriate colors (emerald for check-in, blue for pending, amber for cancels, red for no-shows, gray for removed/class cancelled).

Reformer Class Data

Capacity Per Location

Data Sources

API Patterns (server.ts)

Instructor / Employee Photo Upload Flow

Data model

Admin-style upload path

To behave like the admin form, use the same two-step backend flow the UI uses: 1. POST /api/uploads/instructors with multipart form field name file 2. Take the returned { url } and PUT /api/employees/:id with JSON body { "image_url": "/uploads/instructors/...jpg" }

Relevant server routes: - POST /api/uploads/:kind where kind = instructors - PUT /api/employees/:id with image_url in the allowed fields

Relevant frontend references: - src/components/ImageUploader.tsx posts FormData with field file - src/pages/EmployeeForm.tsx stores the returned URL in imageUrl and submits it as image_url

Bulk assignment pattern

For bulk instructor photos: - Query employees from Postgres: SELECT id, full_name FROM employees WHERE 'Instructor' = ANY(roles) ORDER BY id - For each instructor, upload one image through /api/uploads/instructors even if the source image repeats - Then update that employee via PUT /api/employees/:id - This creates unique uploaded files per employee, which matches the real admin upload workflow better than directly writing a shared URL into the DB

Manual filesystem seeding pattern (when the user only wants images added to the instructor bucket)

Sometimes the request is NOT to assign photos to employee records yet — just to make a large batch of reference/headshot images available in the same place the admin uploader uses.

In that case, you can mirror the admin outcome by copying the files directly into: - /home/avalon/apps/jungle-studio-dashboard/public/uploads/instructors

Recommended pattern: - Preserve the source files - Copy (do not move) each image into the instructors folder - Give each copied file a unique UUID filename with the original extension - The resulting public path is /uploads/instructors/<uuid>.<ext>

Why this works: - server.ts serves public/uploads statically at /uploads - No rebuild/restart is needed for newly copied files - public/uploads/*/* is gitignored, so these runtime-upload assets usually produce no git diff and nothing to commit/push

Use this direct-copy method only when the task is explicitly “add these images to instructors like uploaded assets.” If the goal is to attach photos to specific employees, still do the full upload + PUT /api/employees/:id flow above.

Verification checklist

Jungle Mobile Instructor Avatar Flow

Root cause pattern

If new instructor headshot files exist on disk but avatars still do not appear in the mobile app, the usual cause is: - files were added to /public/uploads/instructors, BUT - no employee records were updated to point employees.image_url at those new file URLs

The mobile app does NOT discover images by scanning the uploads folder.

What the mobile app actually uses

Fast mapping workflow for live mobile avatars

When the user says to "just map them to instructors": 1. Confirm the new files are reachable under /uploads/instructors/... 2. Query the target instructors from Postgres 3. Update employees.image_url directly for the chosen instructor IDs 4. Verify the mobile API returns the new URLs from /api/schedule/classes?...

Useful checks: - Count whether any employee is using the new uploaded URLs: SELECT COUNT(*) FROM employees WHERE image_url IN (...) - Inspect live upcoming instructors: SELECT e.id, e.full_name, MIN(cs.class_date + cs.class_time) AS next_class_at ... GROUP BY e.id, e.full_name ORDER BY next_class_at - Verify live mobile payload: curl http://127.0.0.1:4011/api/schedule/classes?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD

Practical assignment strategy

If the user does not provide an exact mapping and wants visible results quickly, assign the new photos to instructors with the nearest upcoming classes first so schedule-card changes appear immediately.

Deployment note

For DB-only image_url remaps: - no frontend rebuild is needed - no PM2 restart is usually needed - the mobile API reads the new employees.image_url values live from Postgres

Jungle Mobile Instructor View

A lightweight instructor-facing dashboard exists on the mobile PWA. It is NOT a login/auth system — it's a public view by employee ID with no password, PIN, or session.

Route: /instructor/:id
Screen: src/screens/InstructorHomeScreen.tsx
API: GET /api/instructors/:id/mobile (in server.ts)

What it shows

How it works

Security note

Anyone who knows an instructor's ID can view their schedule. No auth required.

Frontend Patterns

Responsive Layout Pitfalls (ClassDetail lesson)

Nested Fixed Sidebar in Flex Layout

ClassDetail has its own sidebar inside AppShell's <main>. Making it fixed on ALL breakpoints breaks desktop because fixed positions relative to viewport (overlaps AppShell's nav sidebar). Solution: use max-lg:fixed (Tailwind v4) to apply fixed only below lg. Desktop keeps the sidebar as a normal flex child.

Null customer_id in Reservations

Many reservations (e.g., ClassPass bookings) have customer_id = NULL. Customer name links in ClassDetail MUST check r.customer_id before rendering a <Link> — otherwise the link goes to /customers/null which 404s. Use conditional: r.customer_id ? <Link to={...}> : <span>{name}</span>.

When clicking a customer from ClassDetail, pass router state: <Link to={/customers/${id}} state={{ from: 'class', classId: id }}>. In CustomerDetail, read useLocation().state?.from === 'class' to show "Back to Class" instead of "Back to Customers" with the correct return URL.

Table-to-Card Pattern

Desktop tables (hidden md:block) paired with mobile card lists (md:hidden). Each card shows: primary info (name + status badge) on first row, secondary metadata (spot, count, source) on second row, tags on third. Keeps all data accessible without horizontal scroll.