Ayyoub Amjahed

Turna

Fotografías el cuadrante de turnos que te dan en el trabajo y aparece en tu calendario, repartido día a día, en menos de diez segundos.

Precisión
313/313 días
Falsos seguros
0
Casos de prueba
15 (8 sintéticos, 7 reales)
Decisiones cerradas
15
Plataforma
Android
Stack
Kotlin, Jetpack Compose, Python, Visión por IA

Mucha gente con turnos rotativos recibe su mes de trabajo en un papel: una tabla con nombres en las filas, días en las columnas y códigos de una letra en las celdas. Después lo copian a mano al calendario del móvil, o no lo copian y viven mirando la foto.

Turna elimina ese paso. Fotografías el papel y el mes queda repartido en el calendario del sistema, no en un calendario propio de la app, para que se sincronice con lo que ya usas.

De dónde sale

Nació en una sesión de ideación de diez apps móviles con potencial, filtradas por cuatro criterios: problema real, MVP en menos de ocho semanas, demostrable en un vídeo vertical y monetización clara. De las diez se eligió esta por la relación entre el dolor del usuario y la dificultad de construirla.

La razón de fondo es que el problema ya existe como objeto fotografiable. El papel del cuadrante está ahí, en la taquilla o en el grupo de WhatsApp. Eso elimina el mayor riesgo de casi todas las demás ideas: convencer a alguien de que cambie un hábito.

Para quién es

El usuario primario es enfermería y auxiliares de hospital con turnos rotativos en España. Secundarios: hostelería, seguridad privada, tripulación de vuelo y gran superficie.

El perfil manda sobre todo lo demás: no es técnico, no quiere otra app y solo va a dar una oportunidad al producto. Si el primer cuadrante sale mal, no hay segundo intento. Toda la semana 0 (más abajo) existe por esta frase.

El modelo es freemium: un cuadrante gratis, después suscripción (3,99 €/mes o 24,99 €/año) o pago único de 39,99 € de por vida. El bucle de crecimiento orgánico es compartir el calendario con la pareja o los compañeros por un enlace .ics.

Decisiones de producto

Hay quince decisiones registradas con su porqué. Las que más condicionan lo que se construye:

#Decisión
D-001Sin registro para la primera extracción. El valor se demuestra antes de pedir cuenta.
D-002Se escribe en el calendario nativo del sistema, no en uno propio de la app.
D-004Pantalla de revisión obligatoria. Nunca se puede saltar.
D-005Compartir por enlace de calendario (.ics/webcal://), no por invitación a la app.
D-007Solo imagen como entrada en el MVP. Nada de PDF ni Excel.
D-008Sin intercambios de turnos entre compañeros: evita convertirse en un marketplace de dos lados.
D-010Plataforma única y nativa. Decidido en la semana 1: Android.
D-012Escala ×2 de imagen y consenso de dos lecturas, fijos en producción, no configurables.
D-013Sentinela "CIERRE" para horas de fin abiertas («hasta que cierre el negocio»).
D-014Cierre de la semana 0 con 313/313 y 0 falsos seguros, aceptando un hueco de validación (ver abajo).

Semana 0: comprobar que el modelo puede leer un cuadrante

Antes de escribir una línea de la app se construyó un banco de pruebas. Sin eso, «va mejor» es una impresión; con eso es un número.

El banco de pruebas

Tres piezas:

  • Fixtures: imágenes de cuadrantes, sintéticas (generadas) y reales (conseguidas en grupos de Facebook y por búsqueda directa).
  • Expected: la verdad de referencia de cada fixture, anotada a mano, en JSON, día a día.
  • Scripts: run_extraction.py llama al modelo con la imagen y guarda el resultado; run_eval.py compara contra la verdad y genera un informe.

Las quince fixtures

Ocho sintéticas, generadas con HTML/CSS y capturadas como imagen, cubren los casos que había que forzar: turno base, noches con saliente, vacaciones parciales, grupos rotativos sin nombre, horas numéricas, formato calendario con vocabulario propio (UCI), una captura de WhatsApp con ruido de chat, y una fila ambigua con dos personas de nombre parecido donde el modelo debe rendirse en vez de adivinar.

Siete reales, y cada una trajo un problema distinto:

FixtureOrigenLo que enseña
r1Cafetería españolaSin leyenda impresa; el código D es ambiguo (Domingo o Descanso).
r2Hostelería española, semanalTurno partido: dos bloques de trabajo con hueco.
r3Restaurante español, semanalHora de cierre abierta (CIERRE).
r4Hostelería españolaTres semanas en una hoja, turnos partidos y CIERRE.
r5Hospital de PerúFormato quincenal, turno M/T combinado sin partir.
r6Hospital de ColombiaCódigo combinado turno+tarea (C2, N1…); D (Descanso) y L (Libre) como códigos distintos.
r7Hospital, país sin confirmarTurno D de 12 h frente a M de 6 h, con verificación cruzada de horas impresas.

El hueco aceptado

No hay ningún cuadrante confirmado de un hospital español, que es justamente el usuario primario, pese a buscarlo activamente en grupos de Facebook durante cuatro días sin respuesta. Se cerró la semana 0 igualmente (D-014), con una mitigación explícita: el primer cuadrante real de hospital español que llegue se incorpora al banco antes de volver a tocar el prompt.

Iteración de modelo y prompt

CorridaModeloPromptEscala y consensoPrecisiónFalsos seguros
gpt-4ogpt-4ov1—30,7 %148
gpt-4o-v2gpt-4ov2—35,8 %138
gpt-5.1-finalgpt-5.1v3×2 + consenso 268,8 %48
gpt-5.4-finalgpt-5.4v3×2 + consenso 2100 %0
gpt-5.5-v2/v3gpt-5.5v2/v3—96,3 %7-8
gpt-5.5-finalgpt-5.5v3×2 + consenso 2100 %0
v6-completa (15 fixtures)gpt-5.5v6×2 + consenso 295,2 % → 98,7 %*15 → 4*
v7-completa (15 fixtures)gpt-5.5v7×2 + consenso 2100 %0

* Tras corregir un bug del evaluador: usaba un glosario global fijo en vez de leer la leyenda de cada documento.

Seis hallazgos

  1. El modelo importa mucho más que el prompt. Pasar de gpt-4o a gpt-5.5 fue un salto de más de sesenta puntos. Iterar el prompt movía como mucho cinco.
  2. La resolución de la imagen es la palanca más infravalorada. Ampliar ×2 antes de enviar resolvió los últimos falsos seguros: en tablas densas el modelo perdía la alineación de columna, no la lectura del carácter.
  3. El consenso de dos lecturas es la red de seguridad real. El modelo nunca ha demostrado saber decir «no estoy seguro» por sí solo: devuelve confianza 1.0 incluso en los días que falla. Si las dos lecturas no coinciden, el día se marca y se pregunta.
  4. Un día tiene dos bloques solo si el documento imprime dos horarios separados. Nunca por analogía con el nombre del código. Esta regla salió de una regresión real del prompt v6: enseñarle que M/T eran dos franjas hizo que generalizara MN (guardia de 24 h) como dos bloques y le abriera un hueco falso.
  5. La leyenda del documento manda siempre sobre un glosario global. La misma letra (D, C, M) significa cosas distintas en centros distintos; un glosario fijo de español de España rompía la evaluación de las fixtures latinoamericanas.
  6. Ningún cambio de prompt entra sin correr las quince fixtures y confirmar que ninguna baja respecto a la versión anterior. Es una regla no negociable, no una buena intención.

El esquema cambió por el camino

La estructura de salida de la extracción evolucionó varias veces durante la semana 0, siempre empujada por una fixture concreta:

  • De inicio/fin planos a un array de segmentos por día, para soportar turnos partidos (r2, r4).
  • Sentinela "CIERRE" para horas de fin no fijas (r3).
  • El color de celda no es fiable como señal. Se confirmó en r5: era decorativo, no codificaba el turno.
  • Código y tarea (r6) se fusionan en un solo campo codigo, con la tarea como metadato opcional. La justificación no es «eso es de otro mercado», sino que la tarea no cambia cuándo trabaja la persona.

La competencia, probada de primera mano

Existe un competidor directo, Shift2Cal, de un estudio italiano generalista: foto, IA, calendario. Tracción modesta en Android (unas mil descargas, 4,3 estrellas) y casi nula en iOS. Probada: lenta, interpreta mal el cuadrante y la pantalla de resultado no se entiende.

El aprendizaje no fue «se puede hacer mejor», sino algo más incómodo: el mercado no premia «más IA», premia que se vea claro y no tarde nada. Una app rival sin ninguna IA, Shifter, genera cariño solo por tener un calendario simple y con colores. La pantalla de revisión de Turna tiene que competir en simplicidad, no solo en precisión.

Semana 1: la app y el backend

Decisiones de arranque

  • Android nativo, Kotlin y Jetpack Compose (D-010, D-015).
  • Nombre de paquete com.turna.app, corregido desde com.cuadrante.app al cerrarse la marca. «Turna» es la marca; «cuadrante» sigue siendo el sustantivo del dominio (la clase de datos, el documento que se fotografía), y no se renombró indiscriminadamente.
  • Un solo repositorio: app/ para el cliente Android, core/ para el núcleo de extracción compartido y services/extraccion/ para el backend HTTP.
  • minSdk 26, que da java.time nativo. Cero permisos declarados hasta que hagan falta; los del calendario llegan en la semana 4.

Tres pantallas

El primer commit es un esqueleto que navega, sin lógica real todavía:

  1. Captura, con el escáner de documentos de ML Kit (detección de bordes y corrección de perspectiva) y un botón para importar desde la galería.
  2. Revisión, una rejilla del mes. Los datos de ejemplo incluyen a propósito un día dudoso y un turno partido, para que el diseño los tenga en cuenta desde el principio.
  3. Confirmación, un mensaje simple de éxito.

Verificado en Android Studio con Clean and Assemble Project with Tests: BUILD SUCCESSFUL, compilación limpia desde cero.

El backend reutiliza literalmente el código validado

De tres arquitecturas consideradas se eligió extraer el núcleo de evals/run_extraction.py a un paquete compartido core/, para que el banco de pruebas y el servicio de producción importen el mismo código. Nunca copias que puedan divergir.

A core/ se movieron el esquema de extracción, el prompt v7, la preparación de imagen (escala ×2), la llamada al modelo, el consenso de dos lecturas, el modelo semántico del día y el cálculo de coste.

La verificación del traslado no fue visual:

  • preparar_imagen: bytes idénticos, cero diferencias.
  • Prompt y esquema: hashes idénticos.
  • Las quince fixtures corridas contra el servicio HTTP real, no contra resultados guardados: 313/313, 0 falsos seguros.

Y aun así aparecieron tres bugs reales, todos corregidos antes de dar el resultado por bueno:

  1. Una variable local turnos tapaba el módulo importado dentro de consensuar().
  2. _clave_semantica no comparaba el código de turno, solo tipo y franja. En las cuatro fixtures sin horas impresas, dos lecturas con códigos distintos (M frente a T) pasaban como «de acuerdo» porque caían en la misma franja «desconocida». Corregido: el código solo cuenta cuando no hay segmentos; si los hay, el horario es la verdad.
  3. Referencias sueltas a ESQUEMA_EXTRACCION y al cálculo de coste que no se habían movido con el resto.

El resultado es un endpoint POST /extraer (imagen, usuario y sector → JSON de turnos con el detalle del consenso día a día) y un GET de estado. Escala ×2 y consenso son fijos (D-012). Sin autenticación ni despliegue todavía: solo local.

Lo que sigue, en orden

  1. Conectar app y backend con datos reales, sustituyendo CuadranteDeMentira por turnos de verdad en la pantalla de revisión, incluidos los días dudosos.
  2. Diseño final de la pantalla de revisión, compitiendo explícitamente en simplicidad contra Shift2Cal y Shifter.
  3. Escritura real en el calendario del sistema, con permisos.
  4. Enlace de compartir .ics y muro de pago.
  5. Incorporar al banco el primer cuadrante real de hospital español en cuanto aparezca.

Lo que deliberadamente no se toca

La versión iOS queda aplazada sin fecha. No se aborda hasta tener usuarios de pago reales en Android y una señal concreta de fricción, como parejas con iPhone que no puedan recibir el calendario compartido. La lógica de extracción en core/ es independiente de plataforma y se reutilizaría sin cambios; lo que habría que rehacer es toda la capa nativa (SwiftUI, VisionKit, EventKit, WidgetKit).

Notas de este proyecto