↑↓ to move · Enter to open · Esc to close

    All posts
    Article, · 5 min read

    RedCheck in two languages

    Dark mode, English and Spanish, GDPR pages and cookies: the unglamorous work that makes an app usable by people who aren't me.

    Until late August, RedCheck had exactly one kind of user in mind: a Spanish speaker, on a laptop, in light mode. That’s fine for a side project. It’s not fine for something you put on your LinkedIn and ask strangers to try.

    This post covers the week where I fixed that: dark mode, a full English/Spanish version (including the AI), the legal pages any app handling personal data in the EU needs, and the cookies that hold it all together. None of it is a headline feature. All of it is the difference between “a demo” and “a product someone else can use”.

    Dark mode first

    I started with dark mode because it touches every component, and I wanted to find out how consistent the styling really was before adding a second language on top.

    The approach is the usual one: a ThemeContext that toggles a dark class on the root element, and Tailwind’s dark: variants everywhere. The part that’s easy to get wrong is the first paint. If the theme is only read once React mounts, a dark-mode user sees a white flash on every load. The fix is a tiny inline script in index.html that reads the stored preference and sets the class before anything renders. It’s ten lines and it’s the most important ten lines of the feature.

    The honest finding: going through every component in dark mode exposed how much styling had been copy-pasted with slightly different greys, borders and hover states. Dark mode didn’t create those inconsistencies, it just made them visible.

    Two languages without an i18n library

    The obvious choice was i18next or react-intl. I didn’t use either.

    RedCheck has two languages, no pluralisation rules more complex than “1 task / 2 tasks”, and no translators other than me. So each component owns a small translations object with an es and an en key, and a LanguageContext decides which one is active. The language is resolved in this order:

    1. A shared cookie (more on that below).
    2. localStorage, for users who had chosen a language before the cookie existed.
    3. The browser’s navigator.language.
    4. Spanish, as the default.

    The trade-off is clear and I accepted it on purpose:

    • What I gained: zero dependencies, no loading of translation bundles, and the text lives right next to the component that uses it, so changing a screen means touching one file.
    • What I gave up: there’s no central catalogue. If I ever add a third language, or someone else wants to translate, I’ll have to extract everything into proper resource files. That day, a library will earn its place. Today it wouldn’t.

    One small React lesson from this: the dashboard loads its data once on mount. The linter wanted the translation function in that effect’s dependency list, which would have meant refetching every task and subject every time someone toggled the language. I left it out deliberately and documented why. Lint rules are a good default, not a law.

    RedCheck’s dashboard in English and dark mode: the month calendar coloured by workload, task balance per subject, and the task list.
    The English demo account.
    The same dashboard in Spanish and dark mode, with the Spanish demo account’s subjects and tasks.
    The Spanish demo account. Same app, different content.

    The AI has to speak English too

    Translating the UI is the easy half. RedCheck’s main feature is SmartCheck, which generates a prioritised plan for your day with a short explanation for each task. If the interface is in English and the plan comes back in Spanish, the translation is worthless.

    So the user’s language now travels with the request: frontend to backend, backend to the AI engine, and into the prompt. The model writes its reasoning in the user’s language.

    The decision that mattered here was what to let the model translate. Free text (the reasoning, the support message) is written in the user’s language. Machine-readable fields like the risk level are not: they are fixed English values (HIGH, MEDIUM, LOW) and the frontend translates them into labels. If the model were allowed to return “ALTO” sometimes and “HIGH” other times, every consumer of that response would need to handle both. Keeping structured output language-neutral keeps the contract stable.

    User picks English

    Frontend
    language: en

    Spring Boot backend

    SmartCheck AI engine

    Prompt: write prose in English

    Plan: English reasoning,
    risk = HIGH / MEDIUM / LOW

    User picks English

    Frontend
    language: en

    Spring Boot backend

    SmartCheck AI engine

    Prompt: write prose in English

    Plan: English reasoning,
    risk = HIGH / MEDIUM / LOW

    A demo account per language

    RedCheck has a “try the demo” button so nobody has to register just to look around. With two languages, a single demo account stopped making sense: an English visitor landing on a dashboard full of Spanish subjects and task names gets a terrible first impression.

    There are now two demo accounts, one seeded with Spanish content and one with English content, and the button picks one based on the current UI language. Both are reset to a known state every hour by a scheduled event in MySQL, and both are protected from deletion: they’re shared, public identities, so nobody should be able to delete the account everyone else is about to log into. (That protection turned out to be incomplete, but that’s a story for a later post in this series.)

    GDPR: terms, privacy and honest cookies

    RedCheck stores personal data: emails, tasks, and task history that the AI uses. In the EU that means real Terms of Service and a real Privacy Policy, not a placeholder link. Both pages exist now, in both languages.

    The cookie section is short, and I’m glad it is. RedCheck doesn’t use analytics or advertising cookies. The only cookies it sets hold two preferences: the theme and the language. Being able to write that sentence plainly in the privacy policy is a design decision in itself: every tracking script I didn’t add is a paragraph I don’t have to justify.

    The reason those two preferences live in a cookie is that RedCheck is now split across two sites: the landing page at redcheckapp.com and the app at my.redcheckapp.com (part 7 is about that split). localStorage is scoped to a single origin, so a visitor who picks English and dark mode on the landing page would arrive at the login screen in Spanish and light mode. A cookie scoped to the parent domain can be read from both.

    What I took away

    • Dark mode is a styling audit in disguise.
    • Choose your i18n tooling for the languages you have, not the ones you might have. Just know what it’ll cost to change your mind.
    • When an LLM’s output feeds code, let it translate prose, never enums.
    • Demo data deserves the same care as real data: it’s the first thing most visitors will see.
    • The cheapest privacy policy to write is the one for an app that doesn’t track people.

    You can switch languages and themes at redcheckapp.com, or try either demo account directly.

    Hasta finales de agosto, RedCheck tenía en mente un único tipo de usuario: alguien que habla español, con un portátil y en modo claro. Para un proyecto personal, vale. Para algo que pones en LinkedIn y le pides a desconocidos que prueben, no.

    Este post cuenta la semana en la que lo arreglé: modo oscuro, una versión completa en inglés y español (también la IA), las páginas legales que necesita cualquier app que trate datos personales en la UE y las cookies que lo mantienen todo unido. Nada de esto es una funcionalidad estrella. Todo ello es la diferencia entre “una demo” y “un producto que otra persona puede usar”.

    Primero, el modo oscuro

    Empecé por el modo oscuro porque toca todos los componentes, y quería saber hasta qué punto los estilos eran coherentes antes de meter un segundo idioma encima.

    El enfoque es el habitual: un ThemeContext que activa una clase dark en el elemento raíz y variantes dark: de Tailwind por todas partes. La parte fácil de hacer mal es el primer pintado. Si el tema solo se lee cuando React se monta, quien usa el modo oscuro ve un fogonazo blanco en cada carga. La solución es un pequeño script en línea en index.html que lee la preferencia guardada y pone la clase antes de que se renderice nada. Son diez líneas y son las diez líneas más importantes de la funcionalidad.

    Lo que descubrí, siendo sincero: repasar cada componente en modo oscuro dejó a la vista cuántos estilos se habían copiado y pegado con grises, bordes y estados hover ligeramente distintos. El modo oscuro no creó esas incoherencias; solo las hizo visibles.

    Dos idiomas sin librería de i18n

    Lo obvio era usar i18next o react-intl. No usé ninguna.

    RedCheck tiene dos idiomas, ninguna regla de plurales más compleja que “1 tarea / 2 tareas” y ningún traductor aparte de mí. Así que cada componente tiene su propio objeto translations con una clave es y otra en, y un LanguageContext decide cuál está activa. El idioma se resuelve en este orden:

    1. Una cookie compartida (más sobre esto abajo).
    2. localStorage, para quien ya había elegido idioma antes de que existiera la cookie.
    3. El navigator.language del navegador.
    4. Español, por defecto.

    El compromiso está claro y lo acepté a propósito:

    • Lo que gané: cero dependencias, nada de cargar paquetes de traducciones, y el texto vive junto al componente que lo usa, así que cambiar una pantalla significa tocar un solo fichero.
    • Lo que perdí: no hay un catálogo central. Si algún día añado un tercer idioma, o alguien más quiere traducir, tendré que extraerlo todo a ficheros de recursos. Ese día una librería se ganará su sitio. Hoy no.

    Una pequeña lección de React por el camino: el dashboard carga sus datos una vez al montarse. El linter quería la función de traducción en las dependencias de ese efecto, lo que habría supuesto volver a pedir todas las tareas y asignaturas cada vez que alguien cambiaba de idioma. La dejé fuera a propósito y documenté por qué. Las reglas del linter son un buen punto de partida, no una ley.

    El dashboard de RedCheck en inglés y en modo oscuro: el calendario mensual coloreado según la carga, el balance de tareas por asignatura y la lista de tareas.
    La cuenta demo en inglés.
    El mismo dashboard en español y en modo oscuro, con las asignaturas y tareas de la cuenta demo en español.
    La cuenta demo en español. La misma app, distinto contenido.

    La IA también tiene que hablar inglés

    Traducir la interfaz es la mitad fácil. La funcionalidad principal de RedCheck es SmartCheck, que genera un plan priorizado para tu día con una breve explicación para cada tarea. Si la interfaz está en inglés y el plan llega en español, la traducción no sirve de nada.

    Así que ahora el idioma del usuario viaja con la petición: del frontend al backend, del backend al motor de IA y de ahí al prompt. El modelo escribe su razonamiento en el idioma del usuario.

    La decisión importante fue qué dejar que tradujera el modelo. El texto libre (el razonamiento, el mensaje de apoyo) se escribe en el idioma del usuario. Los campos que lee una máquina, como el nivel de riesgo, no: son valores fijos en inglés (HIGH, MEDIUM, LOW) y es el frontend el que los traduce a etiquetas. Si el modelo pudiera devolver unas veces “ALTO” y otras “HIGH”, todo lo que consume esa respuesta tendría que manejar ambos. Mantener la salida estructurada independiente del idioma mantiene estable el contrato.

    El usuario elige inglés

    Frontend
    idioma: en

    Backend Spring Boot

    Motor SmartCheck AI

    Prompt: escribe la prosa en inglés

    Plan: razonamiento en inglés,
    riesgo = HIGH / MEDIUM / LOW

    El usuario elige inglés

    Frontend
    idioma: en

    Backend Spring Boot

    Motor SmartCheck AI

    Prompt: escribe la prosa en inglés

    Plan: razonamiento en inglés,
    riesgo = HIGH / MEDIUM / LOW

    Una cuenta demo por idioma

    RedCheck tiene un botón de “probar la demo” para que nadie tenga que registrarse solo para echar un vistazo. Con dos idiomas, una única cuenta demo dejó de tener sentido: alguien que llega en inglés a un dashboard lleno de asignaturas y tareas en español se lleva una primera impresión pésima.

    Ahora hay dos cuentas demo, una con contenido en español y otra en inglés, y el botón elige según el idioma de la interfaz. Las dos vuelven a un estado conocido cada hora gracias a un evento programado en MySQL, y las dos están protegidas contra el borrado: son identidades compartidas y públicas, así que nadie debería poder borrar la cuenta en la que el siguiente visitante está a punto de entrar. (Esa protección resultó estar incompleta, pero eso lo cuento en un post posterior de esta serie.)

    RGPD: términos, privacidad y cookies honestas

    RedCheck guarda datos personales: correos, tareas y el historial de tareas que usa la IA. En la UE eso implica unos Términos de servicio y una Política de privacidad de verdad, no un enlace de relleno. Las dos páginas ya existen, en ambos idiomas.

    La sección de cookies es corta, y me alegro de que lo sea. RedCheck no usa cookies de analítica ni de publicidad. Las únicas cookies que pone guardan dos preferencias: el tema y el idioma. Poder escribir esa frase sin rodeos en la política de privacidad es en sí una decisión de diseño: cada script de seguimiento que no añadí es un párrafo que no tengo que justificar.

    Esas dos preferencias viven en una cookie porque RedCheck ahora está repartido entre dos sitios: la landing en redcheckapp.com y la app en my.redcheckapp.com (la parte 7 trata sobre esa separación). localStorage está limitado a un único origen, así que quien eligiera inglés y modo oscuro en la landing llegaría a la pantalla de login en español y en modo claro. Una cookie asociada al dominio padre se puede leer desde los dos.

    Con qué me quedo

    • El modo oscuro es una auditoría de estilos disfrazada.
    • Elige tus herramientas de i18n para los idiomas que tienes, no para los que podrías tener. Pero ten claro lo que costará cambiar de opinión.
    • Cuando la salida de un LLM alimenta código, deja que traduzca prosa, nunca enums.
    • Los datos de la demo merecen el mismo cuidado que los reales: es lo primero que verán la mayoría de visitantes.
    • La política de privacidad más barata de escribir es la de una app que no rastrea a nadie.

    Puedes cambiar de idioma y de tema en redcheckapp.com, o probar directamente cualquiera de las dos cuentas demo.

    • Frontend
    • Backend
    • AI