Exercises from the physiotherapist to the patient’s phone
A platform where a physiotherapist composes an exercise list, schedules it per weekday, sends it to a patient’s phone, and gets pain, ease and discipline back as charts. Built twice: Drupal in 2013, Laravel in 2020.
A physiotherapist sees a patient for twenty minutes and then sends them home with a sheet of exercises. MijnOefening is the sheet, done properly: the therapist picks exercises from a library of filmed ones, sets how many sets and repetitions and on which days, writes a note, and presses send. The patient gets it on their phone, does the exercises, and says how much it hurt. The therapist sees that as a chart before the next appointment, and can call the patient on video from the same page.
It was built twice. The first version was a Drupal 7 site from 2013, made at Itsavirus, the agency I was freelancing for. The second is a Laravel application, written from August 2018 by the clinic’s own developer, which I took over in the spring of 2020 to get it launched. The live site today is the second one.
The Drupal version: a list you could e-mail
In 2013 an exercise was a node with a photograph, a body text, and four vocabularies: where the complaint is, how long it has lasted, its pain class, and the movement category. The front page was a search block built from those four filters, and a therapist collected exercises into an exercise list. Sending was a form on the list: type an address, and a module mailed an HTML page with every exercise’s picture and a link, signed with the practice’s name and address from the therapist’s profile, and logged a mail_log node so the practice could see what had gone out.
Who could see which exercise was a per-node field, anonymous, authenticated or chiropractor, turned into Drupal access grants, so the public got a taste and the paying therapists got the rest. Everything was in Dutch and English through the i18n stack, which needed two small modules of its own because Views would not translate taxonomy terms in exposed filters or term listings on its own.
What that version could not do is the whole point of the second one: the patient did not exist. An e-mail went out and nothing came back.
A message is the unit
The Laravel rebuild has patients as users, and the thing a therapist sends is a message. A message carries the note, an exercise list, a start and an end date, a schedule per exercise with seven booleans for the weekdays, any advice documents, and the patient’s own PSK items, the patient-specific complaints a physiotherapist scores, such as climbing stairs or getting out of a car. Saving the message to an address the system does not know creates the patient and sends a registration link; to a known patient it links them to the therapist and, if they asked for it, e-mails that something new is waiting.
The patient’s day is a query over that shape. Take every message that has started and not ended, join the schedule on today’s weekday, join the exercise list for the order, and drop what has already been reported today or given a reason. Each exercise reports back separately: pain from one to ten, how many sets and repetitions were done, capped at what was asked, and a reason if it could not be done. At the end of the day there is one more form, the ease of the list and of each PSK item, on the same scale.
THERAPIST PATIENT
┌────────────────────────────────────┐ ┌──────────────────────────────┐
│ message #41 │ │ each morning, from the │
│ from therapist ─► to patient │ │ schedule for that weekday: │
│ text "Beste Jan, een nieuwe..." │ │ │
│ start 2020-03-10 end 2020-04-10 │ │ exercises still to do │
│ list "Acute lumbago - mild" │ ───► │ minus those reported today │
│ ├─ exercise 12 mo tu we th fr │ │ minus those given a reason │
│ ├─ exercise 87 mo .. we .. fr │ │ │
│ └─ exercise 203 .. tu .. th .. │ │ per exercise ─► report │
│ advice "Wat zijn eiwitten?" pdf │ │ pain 1..10 │
│ psk "traplopen" │ │ sets x reps completed │
│ "uit auto stappen" │ │ reason, if it did not go │
└────────────────────────────────────┘ │ │
│ end of day ─► day form │
┌────────────────────────────────────┐ │ ease of the list 1..10 │
│ charts, per message, per day │ ◄─── │ ease per psk 1..10 │
│ pain per exercise │ └──────────────────────────────┘
│ ease of the list, ease per psk │
│ discipline = done / expected │ ───► leaderboard (today's done,
│ discipline over 7 days, all days │ expected) and achievements
└────────────────────────────────────┘
One message, both ends. The therapist writes the left box once; the right box is what the patient sees every morning, computed from it. What comes back is averaged per day per message and drawn as the charts.
Everything the therapist sees afterwards is arithmetic on those reports: pain per exercise per day, ease of the list, and discipline, which is sets done divided by sets expected. The same numbers feed the patient’s side of the app, where they become a monthly leaderboard against anonymous others in the clinic and twelve achievements with experience points, from “100% discipline for a day” at 25 points to “a month of exercises” at 800. The discipline number underneath them is the one a therapist actually wants.
Six hundred exercises from three film days
The library came from three days of filming, and the therapists’ notes for each shot came back as one long text: a code, a Dutch title, an English title, a YouTube link, starting position, execution, and the attributes. A Python script cuts that text into 604 exercises, maps every attribute to its English name, writes the database seeder, and then, for each YouTube id, downloads the clip and cuts two GIFs from it, a small and a large one, at a default moment or at a timestamp the therapist can set later per exercise. A GIMP script clears the white studio floor from the photographs. Adding an exercise in the admin runs the same GIF cutter when the video or the timestamp changes, if the server has it switched on.
The number in the seeder is also the honest size of the product: 604 exercises, in two languages, each with a video, is what a practice gets for its ten euros per therapist per month, or a hundred a year, which Mollie collects as a recurring payment per clinic manager.
What I did in 2020
I arrived at a readme that begins with how to install the project on a new machine, which is what a handover looks like. The clinic behind it wanted to go live earlier than planned because of COVID, free until the first of June 2020, and the code was a working application with no front door: no public page that explained it, no way to try it, and a dashboard that had grown a view per feature. Rewriting a hundred and fifty Blade views was not on the table in seven weeks. Three things were.
A landing page, with the galleries fed from a config file listing the screenshots, so that the marketing renders and the product could be updated separately. A demo: one click creates a throwaway clinic, manager, therapist and patient, logs the visitor in as the therapist, and a bar at the foot lets them switch to the patient or the manager, ask a question or register. And a dashboard shell around the existing views: the therapist’s menu is a tree in a config file, each node naming a view and whether it can load lazily. The server walks the tree, turns every path into a pattern, and renders all panels into one page; the browser matches the URL against the patterns, shows the right panel, pushes the state, and marks the breadcrumb. The old views did not change; they got a frame that behaves like one application.
THERAPIST'S BROWSER PATIENT'S PHONE
┌──────────────────────────────────────┐ ┌───────────────────────────────┐
│ Blade views in one dashboard shell │ │ iOS / Android app │
│ menu tree ─► route patterns │ │ OAuth (Passport) bearer token │
│ panels swapped in place, pushState │ │ 2FA code by e-mail, expiring │
└──────────────────┬───────────────────┘ └───────────────┬───────────────┘
│ session, CSRF │ /api/...
▼ ▼
┌──────────────────────────────────────────────────────────────────────────┐
│ LARAVEL 5.8 · MySQL │
│ users (patient · doctor · therapist · manager · admin) clinics │
│ exercises (604 seeded) lists messages schedules reports psk │
│ achievements leaderboard key_store ip_ban not_found │
└───┬──────────────┬──────────────┬──────────────┬──────────────┬──────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌──────────┐ ┌───────────┐ ┌───────────┐ ┌─────────────┐
│ YouTube │ │ Voxeet │ │ Mollie │ │ Telegram │ │ EPD systems │
│ videos, │ │ video │ │ recurring │ │ bot: down │ │ Fysiomanager│
│ gifs cut│ │ call │ │ per │ │ sites, │ │ Crossuite │
│ locally │ │ rooms │ │ therapist │ │ errors │ │ token link │
└─────────┘ └──────────┘ └───────────┘ └───────────┘ └─────────────┘
The pieces. The browser and the app are two doors into one Laravel application; the phone talks OAuth with a two-factor code that expires. The videos live on YouTube and are cut into GIFs locally.
Around the edges
A practice already has an electronic patient record, Fysiomanager or Crossuite, and does not want to type a patient twice. Each therapist holds a token per system; the record system posts the patient’s name, e-mail and that token, and the request lands either on the send page for an existing patient, linked to this therapist if they were not, or on a pre-filled form for a new one. Video calls are Voxeet rooms: the therapist starts one from the patient page and the phone fetches its own token from the API.
Operations run through a Telegram bot. A scheduled job checks a list of the clinic’s domains and reports the ones that are down, a daily job pulls the abuse database’s blacklist into a table that a middleware checks on every request, and any path containing wp is looked up on the spot. Payment failures go to the same chat with the error attached; the error log in the archive is Mollie answering 429 to a scheduled run that tried a card, then Bancontact, then direct debit for the same amount in one go.
The live site still answers with a Laravel session cookie, the front page still lists the screenshots from that config file, and the demo now lives at its own set of routes with a patient called Peter Parker, which is not in my copy of the code. It kept being built after I left.