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-001 | No sign-up for the first extraction. The value is shown before asking for an account. |
| D-002 | Writes into the system’s native calendar, not one of the app’s own. |
| D-004 | Mandatory review screen. It can never be skipped. |
| D-005 | Sharing by calendar link (.ics/webcal://), not by inviting people to the app. |
| D-007 | Image only as input for the MVP. No PDF, no Excel. |
| D-008 | No shift swapping between colleagues: avoids becoming a two-sided marketplace. |
| D-010 | Single native platform. Decided in week 1: Android. |
| D-012 | 2× 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-014 | Week 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.pycalls the model with the image and stores the result;run_eval.pycompares 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:
| Fixture | Source | What it teaches |
|---|---|---|
| r1 | Spanish café | No printed legend; the D code is ambiguous (Sunday or day off). |
| r2 | Spanish hospitality, weekly | Split shift: two working blocks with a gap. |
| r3 | Spanish restaurant, weekly | Open-ended finishing time (CIERRE). |
| r4 | Spanish hospitality | Three weeks on one sheet, split shifts and CIERRE. |
| r5 | Hospital in Peru | Fortnightly format, combined M/T shift without a split. |
| r6 | Hospital in Colombia | Combined shift+task code (C2, N1…); D (rest) and L (free) as distinct codes. |
| r7 | Hospital, country unconfirmed | A 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
| Run | Model | Prompt | Upscale and consensus | Accuracy | Confident errors |
|---|---|---|---|---|---|
| gpt-4o | gpt-4o | v1 | — | 30.7 % | 148 |
| gpt-4o-v2 | gpt-4o | v2 | — | 35.8 % | 138 |
| gpt-5.1-final | gpt-5.1 | v3 | 2× + consensus 2 | 68.8 % | 48 |
| gpt-5.4-final | gpt-5.4 | v3 | 2× + consensus 2 | 100 % | 0 |
| gpt-5.5-v2/v3 | gpt-5.5 | v2/v3 | — | 96.3 % | 7-8 |
| gpt-5.5-final | gpt-5.5 | v3 | 2× + consensus 2 | 100 % | 0 |
| v6-full (15 fixtures) | gpt-5.5 | v6 | 2× + consensus 2 | 95.2 % → 98.7 %* | 15 → 4* |
| v7-full (15 fixtures) | gpt-5.5 | v7 | 2× + consensus 2 | 100 % | 0 |
* After fixing a bug in the evaluator: it used a fixed global glossary instead of reading each document’s legend.
Six findings
- 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.
- 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.
- 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.
- 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/Tmeant two ranges made it generaliseMN(a 24-hour on-call) into two blocks with a false gap. - 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. - 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/finto an array ofsegmentosper 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
codigofield, 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 fromcom.cuadrante.apponce 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 andservices/extraccion/for the HTTP backend. minSdk26, which gives nativejava.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:
- Capture, with ML Kit’s document scanner (edge detection and perspective correction) and a button to import from the gallery.
- 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.
- 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:
- A local variable
turnosshadowed the imported module insideconsensuar(). _clave_semanticadidn’t compare the shift code, only type and time range. In the four fixtures without printed hours, two readings with different codes (MversusT) 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.- Loose references to
ESQUEMA_EXTRACCIONand 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
- Wire app and backend with real data, replacing
CuadranteDeMentirawith actual shifts on the review screen, doubtful days included. - Final design of the review screen, explicitly competing on simplicity against Shift2Cal and Shifter.
- Real writes into the system calendar, with permissions.
.icssharing link and paywall.- 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
- I spent days tuning the prompt, and what actually moved accuracy was changing the model
I went from 30.7 % to 100 % of days correct reading shift rosters. Almost none of that gain came from the prompt, and the metric that mattered wasn't accuracy.