Ayyoub Amjahed

Turna

Take a photo of the shift roster you get at work and it lands in your calendar, split day by day, in under ten seconds.

Accuracy
313/313 days
Confident errors
0
Test cases
15 (8 synthetic, 7 real)
Decisions closed
15
Platform
Android
Stack
Kotlin, Jetpack Compose, Python, AI vision

A lot of people on rotating shifts get their month of work on a sheet of paper: a table with names down the rows, days across the columns and one-letter codes in the cells. Then they copy it by hand into the calendar on their phone, or they don’t, and live by looking at the photo.

Turna removes that step. You photograph the sheet and the month ends up spread across the system calendar, not a calendar of the app’s own, so it syncs with whatever you already use.

Where it comes from

It came out of an ideation session of ten mobile apps with potential, filtered by four criteria: a real problem, an MVP in under eight weeks, demonstrable in a vertical video, and clear monetisation. Of the ten, this one was chosen for the ratio between the user’s pain and the difficulty of building it.

The underlying reason is that the problem already exists as a photographable object. The roster sheet is there, in the locker or in the WhatsApp group. That removes the biggest risk of almost every other idea: convincing someone to change a habit.

Who it’s for

The primary user is nursing and hospital care staff on rotating shifts in Spain. Secondary: hospitality, private security, flight crews and big retail.

The profile rules over everything else: not technical, doesn’t want another app, and will give the product exactly one chance. If the first roster comes out wrong, there is no second attempt. All of week 0 (below) exists because of that sentence.

The model is freemium: one roster free, then a subscription (€3.99/month or €24.99/year) or a one-off €39.99 for life. The organic growth loop is sharing the calendar with a partner or colleagues through an .ics link.

Product decisions

Fifteen decisions are on record with their reasoning. The ones that shape what gets built the most:

#Decision
D-001No sign-up for the first extraction. The value is shown before asking for an account.
D-002Writes into the system’s native calendar, not one of the app’s own.
D-004Mandatory review screen. It can never be skipped.
D-005Sharing by calendar link (.ics/webcal://), not by inviting people to the app.
D-007Image only as input for the MVP. No PDF, no Excel.
D-008No shift swapping between colleagues: avoids becoming a two-sided marketplace.
D-010Single native platform. Decided in week 1: Android.
D-0122× image upscale and two-reading consensus, fixed in production, not configurable.
D-013"CIERRE" sentinel for open-ended finishing times (“until the place closes”).
D-014Week 0 closed at 313/313 and 0 confident errors, accepting one validation gap (see below).

Week 0: checking that the model can read a roster

Before writing a single line of the app, a test bench was built. Without it, “it’s better now” is an impression; with it, it’s a number.

The test bench

Three parts:

  • Fixtures: roster images, synthetic (generated) and real (found in Facebook groups and by direct search).
  • Expected: the ground truth for each fixture, annotated by hand, in JSON, day by day.
  • Scripts: run_extraction.py calls the model with the image and stores the result; run_eval.py compares against the truth and produces a report.

The fifteen fixtures

Eight synthetic ones, generated with HTML/CSS and captured as images, cover the cases that had to be forced: a base shift, nights with a hand-over day, partial holidays, unnamed rotating groups, numeric hours, a calendar format with its own vocabulary (ICU), a WhatsApp screenshot with chat noise, and an ambiguous row with two people of similar names where the model must give up rather than guess.

Seven real ones, each bringing a different problem:

FixtureSourceWhat it teaches
r1Spanish caféNo printed legend; the D code is ambiguous (Sunday or day off).
r2Spanish hospitality, weeklySplit shift: two working blocks with a gap.
r3Spanish restaurant, weeklyOpen-ended finishing time (CIERRE).
r4Spanish hospitalityThree weeks on one sheet, split shifts and CIERRE.
r5Hospital in PeruFortnightly format, combined M/T shift without a split.
r6Hospital in ColombiaCombined shift+task code (C2, N1…); D (rest) and L (free) as distinct codes.
r7Hospital, country unconfirmedA 12-hour D shift versus a 6-hour M, cross-checked against printed hours.

The accepted gap

There is no confirmed roster from a Spanish hospital, which is precisely the primary user, despite actively searching in Facebook groups for four days with no reply. Week 0 was closed anyway (D-014), with an explicit mitigation: the first real Spanish hospital roster that turns up goes into the bench before the prompt is touched again.

Model and prompt iteration

RunModelPromptUpscale and consensusAccuracyConfident errors
gpt-4ogpt-4ov1—30.7 %148
gpt-4o-v2gpt-4ov2—35.8 %138
gpt-5.1-finalgpt-5.1v32× + consensus 268.8 %48
gpt-5.4-finalgpt-5.4v32× + consensus 2100 %0
gpt-5.5-v2/v3gpt-5.5v2/v3—96.3 %7-8
gpt-5.5-finalgpt-5.5v32× + consensus 2100 %0
v6-full (15 fixtures)gpt-5.5v62× + consensus 295.2 % → 98.7 %*15 → 4*
v7-full (15 fixtures)gpt-5.5v72× + consensus 2100 %0

* After fixing a bug in the evaluator: it used a fixed global glossary instead of reading each document’s legend.

Six findings

  1. The model matters far more than the prompt. Going from gpt-4o to gpt-5.5 was a jump of more than sixty points. Iterating the prompt moved five at most.
  2. Image resolution is the most underrated lever. Upscaling 2× before sending fixed the last confident errors: in dense tables the model was losing column alignment, not misreading the character.
  3. Two-reading consensus is the real safety net. The model has never shown it can say “I’m not sure” on its own: it returns confidence 1.0 even on the days it gets wrong. If the two readings disagree, the day is flagged and the user is asked.
  4. A day has two blocks only if the document prints two separate time ranges. Never by analogy with the code’s name. This rule came out of a real regression in prompt v6: teaching it that M/T meant two ranges made it generalise MN (a 24-hour on-call) into two blocks with a false gap.
  5. The document’s legend always wins over a global glossary. The same letter (D, C, M) means different things at different workplaces; a fixed Spain-Spanish glossary broke the evaluation of the Latin American fixtures.
  6. No prompt change goes in without running all fifteen fixtures and confirming none of them drops against the previous version. It’s a non-negotiable rule, not a good intention.

The schema changed along the way

The extraction’s output structure evolved several times during week 0, always pushed by a specific fixture:

  • From flat inicio/fin to an array of segmentos per day, to support split shifts (r2, r4).
  • The "CIERRE" sentinel for non-fixed finishing times (r3).
  • Cell colour is not a reliable signal. Confirmed in r5: it was decorative, it didn’t encode the shift.
  • Code and task (r6) merged into a single codigo field, with the task as optional metadata. The justification isn’t “that’s another market”: it’s that the task doesn’t change when the person works.

The competition, tried first-hand

There is a direct competitor, Shift2Cal, from a generalist Italian studio: photo, AI, calendar. Modest traction on Android (about a thousand downloads, 4.3 stars) and almost none on iOS. Tried it: slow, misreads the roster, and the results screen is hard to follow.

The lesson wasn’t “this can be done better” but something less comfortable: the market doesn’t reward “more AI”, it rewards being clear and taking no time at all. A rival app with no AI whatsoever, Shifter, earns affection just by having a simple, colourful calendar. Turna’s review screen has to compete on simplicity, not only on accuracy.

Week 1: the app and the backend

Starting decisions

  • Native Android, Kotlin and Jetpack Compose (D-010, D-015).
  • Package name com.turna.app, corrected from com.cuadrante.app once the brand was settled. “Turna” is the brand; “cuadrante” (roster) is still the domain noun (the data class, the document you photograph), and it wasn’t renamed indiscriminately.
  • One repository: app/ for the Android client, core/ for the shared extraction core and services/extraccion/ for the HTTP backend.
  • minSdk 26, which gives native java.time. Zero declared permissions until they’re needed; the calendar ones arrive in week 4.

Three screens

The first commit is a skeleton that navigates, with no real logic yet:

  1. Capture, with ML Kit’s document scanner (edge detection and perspective correction) and a button to import from the gallery.
  2. Review, a grid of the month. The sample data deliberately includes a doubtful day and a split shift, so the design accounts for them from the start.
  3. Confirmation, a simple success message.

Verified in Android Studio with Clean and Assemble Project with Tests: BUILD SUCCESSFUL, a clean build from scratch.

The backend literally reuses the validated code

Of three architectures considered, the choice was to extract the core of evals/run_extraction.py into a shared core/ package, so that the test bench and the production service import the same code. Never copies that can drift.

Into core/ went the extraction schema, prompt v7, image preparation (2× upscale), the model call, the two-reading consensus, the day’s semantic model and cost calculation.

Verification of the move was not visual:

  • preparar_imagen: identical bytes, zero differences.
  • Prompt and schema: identical hashes.
  • All fifteen fixtures run against the real HTTP service, not against stored results: 313/313, 0 confident errors.

And still three real bugs turned up, all fixed before the result was accepted:

  1. A local variable turnos shadowed the imported module inside consensuar().
  2. _clave_semantica didn’t compare the shift code, only type and time range. In the four fixtures without printed hours, two readings with different codes (M versus T) passed as “in agreement” because they fell into the same “unknown” range. Fixed: the code only counts when there are no segments; if there are, the schedule is the truth.
  3. Loose references to ESQUEMA_EXTRACCION and to the cost calculation that hadn’t moved with the rest.

The result is a POST /extraer endpoint (image, user and sector → JSON of shifts with the day-by-day consensus detail) and a GET status endpoint. Upscale and consensus are fixed (D-012). No authentication and no deployment yet: local only.

What’s next, in order

  1. Wire app and backend with real data, replacing CuadranteDeMentira with actual shifts on the review screen, doubtful days included.
  2. Final design of the review screen, explicitly competing on simplicity against Shift2Cal and Shifter.
  3. Real writes into the system calendar, with permissions.
  4. .ics sharing link and paywall.
  5. Add the first real Spanish hospital roster to the bench as soon as it turns up.

What deliberately isn’t touched

The iOS version is postponed with no date. It isn’t tackled until there are real paying users on Android and a concrete friction signal, such as partners with iPhones who can’t receive the shared calendar. The extraction logic in core/ is platform-independent and would be reused unchanged; what would have to be redone is the entire native layer (SwiftUI, VisionKit, EventKit, WidgetKit).

Notes from this project