RedCheck en dos idiomas
Modo oscuro, inglés y español, páginas del RGPD y cookies: el trabajo poco vistoso que hace que una app la pueda usar alguien que no soy yo.
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:
- A shared cookie (more on that below).
localStorage, for users who had chosen a language before the cookie existed.- The browser’s
navigator.language. - 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.


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.
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.
Why a cookie and not just localStorage
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:
- Una cookie compartida (más sobre esto abajo).
localStorage, para quien ya había elegido idioma antes de que existiera la cookie.- El
navigator.languagedel navegador. - 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.


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.
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.
Por qué una cookie y no solo localStorage
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
- IA