Demo activity keeps a sales demo studio looking alive. An hourly job invents believable trainee behaviour for every studio flagged as a demo: app opens, workouts, cardio, weigh-ins, meals, water, steps, check-in form answers, coach feedback and open tasks. This module has no HTTP routes. Nothing in modules/index.ts mounts it. It is a library called by a worker and by two scripts. Source: backend/apps/core-api/src/modules/demo-activity/.

Which studios are demo studios

A studio is in demo mode when Studio.settings.demoMode is exactly true. loadDemoStudios queries with a JSON path filter on settings.demoMode and re-checks each row with isDemoStudio. There is no endpoint that sets the flag. The script apps/core-api/src/scripts/setup-demo-studio.ts writes it as the last step of preparing the demo studio.

Callers

The setup helpers under scripts/demo-setup/ (assign.ts, copy-source.ts) also import readTrainingPlan, demoId and DEMO_ID_PREFIX from this module. No route handler and no other domain module calls runDemoActivity. The module itself calls into two task modules:
  • createFormFilledTask from modules/tasks/form-filled.ts, after it fills a form.
  • AUTOMATION_DEFAULTS from modules/task-automations/task-automations.schema.ts and CONTENT from modules/tasks/task-content.ts, to build showcase tasks.
See Tasks and Automation.

The scheduler

startDemoActivityScheduler(ctx) is called from server.ts and closed on shutdown. It uses the BullMQ queue PerformQueue.DEMO_ACTIVITY (demo-activity).
  • A repeatable job named hourly with job id demo-activity-hourly runs every DEMO_ACTIVITY_EVERY_MS (60 minutes).
  • A one-off job named boot is added at every start, so a deploy does not wait an hour for fresh data.
  • The worker runs with concurrency 1, a 10 minute lock, a 5 minute stalled check and maxStalledCount: 1.
  • Completed jobs are removed. The last 50 failures are kept.
  • The run summary is logged only when at least one demo studio exists.
On an environment with no demo studio the job runs, finds nothing and returns { "studios": 0, "results": [] }.

What a run does

backfillDays defaults to 2 and has a minimum of 1. For each demo studio, runForStudio:
  1. Loads the eligible clients.
  2. Loads the names of the exercises in their training plans.
  3. Builds day contexts for today and the previous backfillDays days, in the studio’s timezone.
  4. Builds rows for every client and day with buildDemoDay.
  5. Writes the rows with writeDemoDays.
  6. Fills and handles check-in forms with runDemoForms.
  7. Tops up showcase tasks with topUpShowcaseTasks.
Studios are processed one after another.

Eligible clients

loadDemoClients takes clients of the studio that are ACTIVE, not deleted, and have at least one subscription with status ACTIVE. For each it reads:
  • stepsGoal, waterGoalMl, weightKg, lastCheckInAt.
  • The newest active TRAINING program and the newest active NUTRITION program. File plans (pdfUrl set) are skipped.
  • A weight baseline: the latest real WeightEntry (one whose id does not start with the demo prefix), or else Client.weightKg, or else 75 kg.
  • The latest real body measurements from DailyMetric.measurements, limited to waist, chest, arm, hips and thigh.
Both baseline queries exclude rows whose id starts with the demo prefix, so generated weights are never used as the baseline.

Deterministic output

Nothing uses Math.random. Every decision comes from hash(seed) in demo-random.ts, an FNV-style hash of a string such as <clientId>:<date>:train. The same client and day always produce the same rows, which is what makes a rerun safe.

Personas

personaOf(clientId, dayIndex) gives each trainee a persona for a 28 day block. The block start is offset per client, and the persona is re-rolled each block. platformOf(clientId) fixes each trainee to ios (78%) or android.

Today is generated up to the current time

Past days are generated in full. For today, clockMinutes holds the current minute of the day in the studio’s timezone, and hasPassed drops anything scheduled later. A workout that would finish at 18:40 appears only in a run after 18:40. This is why the job runs hourly.

Rows written per trainee and day

buildDemoDay first decides whether the trainee opened the app that day, at a time between 06:30 and 09:30. If not, or if that time has not passed yet, nothing is written for that trainee and day. Details worth knowing:
  • Training days. trainingWeekdays spreads the plan’s workoutsPerWeek over fixed weekdays. Plan days rotate week by week.
  • Weights. Each exercise gets a base weight from 10 kg in 2.5 kg steps, plus one step every four weeks in a 16 week cycle. From the third set on, a set has a 30% chance of one missed rep.
  • Weight trend. weightOn drifts from the baseline at a per-trainee rate between -0.45 and +0.15 kg a week, flattening over a 12 week horizon, with up to 0.3 kg of daily noise either way.
  • Steps. Between 55% and 130% of the goal (plan stepsDaily, then Client.stepsGoal, then 8000), scaled by how much of the day has passed.
  • Water. 500 ml cups at up to six fixed times, sized against the goal (Client.waterGoalMl, then the plan’s water target, then 2500 ml).
  • Nutrition plan. Only the first day of the nutrition plan is read.
Client.lastCheckInAt and Client.lastCheckInPlatform are moved forward to the latest generated activity, and Client.weightKg is set to the latest generated weigh-in.

Forms and coach feedback

runDemoForms works on check-in forms only. Filling. fillPending looks at FormAssignment rows with status PENDING, a CHECK_IN form template, and a creation time at least 2 hours ago. For each:
  • The persona’s fillsForm rate decides whether the trainee fills it.
  • If not, and the assignment is older than 6 days, it is set to CANCELLED.
  • If yes, a submit time 2 to 22 hours after the assignment is chosen. Once that time has passed, the job creates a FormResponse with generated answers and sets the assignment to COMPLETED.
  • It then calls createFormFilledTask, the same hook a real submission triggers, so the studio’s form-filled task automations create their tasks. The created task’s createdAt is moved back to the submit time.
Answers come from buildAnswers, by field type: range and rating follow the persona’s mood, number is 5 to 9, weight is the trainee’s current weight, text picks a Hebrew note that matches the mood, dropdown picks an option, and checkbox_confirmation is true. Other field types are left unanswered. Handling. handleOlder takes up to 200 fabricated responses, submitted between 30 hours and 14 days ago, that are not handled yet. For each it picks an active coach of the studio and a handling time: 4 to 28 hours after the submit for 75% of responses, 60 to 110 hours for the rest. Once that time has passed, in one transaction it:
  • Creates a CheckinReview with a note, a message and channels: ["push"].
  • Stamps handledById, handledByName, handledAt and handledNote on the FormResponse.
  • Marks the matching open form-filled tasks DONE.
  • Creates a ClientActivity of type CHECKIN_FEEDBACK_SENT.
No push is sent. The review only records that one was.

Showcase tasks

topUpShowcaseTasks keeps the tasks board populated. For every task automation type in AUTOMATION_DEFAULTS, plus MANUAL, it counts the studio’s open InboxItem rows of that type and creates enough to reach two. New rows use the type’s title, detail and priority from CONTENT, have source: "manual", an empty coachIds, and are attached to a trainee. Form related types are linked to one of the studio’s latest form responses or assignments through metadata.

How fabricated rows are marked

Rows created by the day generator and the form filler have a deterministic id with the prefix dm_ (DEMO_ID_PREFIX), built by demoId(kind, ...parts). isDemoId(id) tests for the prefix. The fixed ids are also what makes reruns idempotent: inserts use createMany with skipDuplicates: true. Some writes are not marked with the prefix:
  • CheckinReview, ClientActivity and showcase InboxItem rows get ordinary generated ids.
  • Tasks created through createFormFilledTask are ordinary tasks.
  • Updates to existing rows (Client, FormAssignment, FormResponse handled fields, InboxItem status) carry no marker.
Removing demo data by id prefix alone therefore leaves these behind.

Live days and settled days

The writer treats the last two generated days as live. For those, DailyMetric and NutritionDayLog are upserted on clientId and date, so steps, water and eaten meals grow through the day as the hourly job reruns. Older days are settled and only inserted when missing.
The live-day upsert matches on clientId and date, not on the demo id. If a demo trainee has a real DailyMetric or NutritionDayLog row for today or yesterday, the job overwrites its steps, water and snapshot data. Keep real usage out of a studio that has demo mode on.

Run summary

runDemoActivity returns one result per demo studio.
appDays, workouts, cardio, weighIns and meals count rows actually inserted. metrics and nutritionDays add the settled inserts to the number of live-day upserts, so they are never zero on an active studio.

Running it by hand

scripts/run-demo-activity.ts runs one pass against the configured database and prints the summary as JSON. Pass --days=N to backfill more history.
The script writes to whatever database DATABASE_URL points at. It only touches studios with settings.demoMode set to true.