From b5c703883d7377ed27ba50aefb62c6bbcfafccc0 Mon Sep 17 00:00:00 2001 From: keilynrp Date: Tue, 6 Oct 2026 12:21:20 -0600 Subject: [PATCH] feat(i18n): projects en ingles Tarea 12 del plan, decimosexta parte: traduce a `en` el espacio `projects` (la lista de proyectos, el detalle con sus tareas y los dialogos de alta de proyecto y de tarea). Con `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing` y `portalBuilder` son 16 de 19; `en` sigue sin ofrecerse hasta que esten todos. Se registra en `TRANSLATED`, asi que los checks de paridad (claves, marcadores y plurales) y de cadenas copiadas lo cubren. Ninguna cadena con letras es igual en los dos idiomas, asi que no hace falta aprobar nada en `IDENTICAL_TO_BASE`. Ortografia de EE. UU. Tests en ingles real (`english-projects.test.tsx`): cabecera, KPI y tabla con los estados que salen del `en` de `common`, fechas formateadas para ingles, estado vacio, alta con su aviso y su error, detalle con el periodo, el numero de tareas y su estado, las acciones segun el estado del proyecto, el aviso de transicion no permitida, alta de tareas y borrado desde el menu de la fila. Comprobado con mutaciones sobre el catalogo real: un texto copiado del espanol, un marcador `{{count}}` perdido y una clave perdida fallan el test. Co-Authored-By: Claude Sonnet 5.5 --- composable-os/src/i18n/catalogues.test.ts | 2 +- .../src/i18n/english-projects.test.tsx | 209 ++++++++++++++++++ composable-os/src/locales/en/projects.json | 80 +++++++ docs/IMPLEMENTATION_ALIGNMENT.md | 2 +- .../superpowers/plans/2026-08-07-i18n-full.md | 7 +- 5 files changed, 295 insertions(+), 5 deletions(-) create mode 100644 composable-os/src/i18n/english-projects.test.tsx create mode 100644 composable-os/src/locales/en/projects.json diff --git a/composable-os/src/i18n/catalogues.test.ts b/composable-os/src/i18n/catalogues.test.ts index f30d172..6533b18 100644 --- a/composable-os/src/i18n/catalogues.test.ts +++ b/composable-os/src/i18n/catalogues.test.ts @@ -31,7 +31,7 @@ const BASE = "es"; * idioma se ofrece solo (`releasedLocales`): no hay un interruptor aparte. */ const TRANSLATED: Record = { - en: ["common", "errors", "auth", "layout", "dashboard", "analytics", "companyProfile", "customerPortal", "contracts", "crm", "discovery", "hr", "inventory", "invoicing", "portalBuilder"], + en: ["common", "errors", "auth", "layout", "dashboard", "analytics", "companyProfile", "customerPortal", "contracts", "crm", "discovery", "hr", "inventory", "invoicing", "portalBuilder", "projects"], }; /** diff --git a/composable-os/src/i18n/english-projects.test.tsx b/composable-os/src/i18n/english-projects.test.tsx new file mode 100644 index 0000000..692fbeb --- /dev/null +++ b/composable-os/src/i18n/english-projects.test.tsx @@ -0,0 +1,209 @@ +import { fireEvent, screen, waitFor, within } from "@testing-library/react"; +import i18next, { type i18n as I18n } from "i18next"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { buildI18nOptions, resources } from "@/i18n"; +import { formatDate } from "@/i18n/format"; +import { ApiError } from "@/lib/api"; +import Projects from "@/pages/Projects"; +import { renderPageWithI18n } from "@/test/i18n"; + +/** + * `projects` en el ingles *real* (tarea 12 del plan de i18n): la lista, el detalle con sus + * tareas y los dialogos de alta con el catalogo `en` que se envia. Los estados de + * proyecto y de tarea salen del `en` de `common`. + */ + +const svc = vi.hoisted(() => ({ + getAll: vi.fn(), get: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), + addTask: vi.fn(), updateTask: vi.fn(), deleteTask: vi.fn(), +})); +const toast = vi.hoisted(() => vi.fn()); + +vi.mock("@/services/projects", () => ({ projectsService: svc })); +vi.mock("@/hooks/use-toast", () => ({ useToast: () => ({ toast }) })); + +async function english(): Promise { + const instance = i18next.createInstance(); + await instance.init(buildI18nOptions(resources, "en")); + return instance; +} + +const item = (over: Record = {}) => ({ + id: "p1", project_number: "PRJ-001", title: "Sun store", status: "planning", budget: 5000, currency: "USD", + start_date: "2026-03-05", end_date: null, organization_id: null, contact_id: null, contract_id: null, + created_at: "2026-01-01T00:00:00Z", ...over, +}); +const task = (over: Record = {}) => ({ + id: "t1", project_id: "p1", task_order: 1, title: "Design logo", description: null, status: "todo", + due_date: "2026-04-10", assignee_id: null, created_at: "", updated_at: "", ...over, +}); +const full = (over: Record = {}) => ({ + ...item(), workspace_id: "w1", description: "Initial scope", activated_at: null, completed_at: null, + cancelled_at: null, notes: "Project notes", sales_order_id: null, owner_id: null, tasks: [task()], + updated_at: "", ...over, +}); + +const renderPage = (instance: I18n) => renderPageWithI18n(, instance); +const select = async (title = "Sun store") => fireEvent.click(await screen.findByText(title)); + +beforeEach(() => { + Object.values(svc).forEach((fn) => fn.mockReset()); + toast.mockReset(); + svc.getAll.mockResolvedValue([ + item(), + item({ id: "p2", project_number: "PRJ-002", title: "Web redesign", status: "active", budget: 1000 }), + ]); + svc.get.mockResolvedValue(full()); + svc.create.mockResolvedValue(full()); + svc.update.mockResolvedValue(full()); + svc.delete.mockResolvedValue(undefined); + svc.addTask.mockResolvedValue({}); + svc.updateTask.mockResolvedValue({}); + svc.deleteTask.mockResolvedValue(undefined); +}); + +describe("Projects in English", () => { + it("renders the header, the KPIs and the table with English statuses and dates", async () => { + renderPage(await english()); + + expect(screen.getByRole("heading", { name: "Projects" })).toBeInTheDocument(); + expect(screen.getByText("Project lifecycle management")).toBeInTheDocument(); + expect(await screen.findByText("Sun store")).toBeInTheDocument(); + for (const text of ["Total projects", "Total budget", "Project list"]) { + expect(screen.getByText(text)).toBeInTheDocument(); + } + // «Active» es la etiqueta del KPI y el estado de «Web redesign»: dos elementos. + expect(screen.getAllByText("Active")).toHaveLength(2); + for (const col of ["Number", "Title", "Status", "Budget", "Start", "End"]) { + expect(screen.getByRole("columnheader", { name: col })).toBeInTheDocument(); + } + expect(screen.getByText("Planning")).toBeInTheDocument(); + expect(screen.getByText("Active", { selector: "div" })).toBeInTheDocument(); + expect(screen.queryByText("2026-03-05")).not.toBeInTheDocument(); + expect(screen.getAllByText(formatDate("2026-03-05", "en"))).toHaveLength(2); + }); + + it("explains an empty list and offers to create the first project", async () => { + svc.getAll.mockResolvedValue([]); + renderPage(await english()); + + expect(await screen.findByText("No projects yet")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Create the first project" })); + expect(await screen.findByText("New project", { selector: "h2" })).toBeInTheDocument(); + }); + + it("creates a project and confirms with a toast", async () => { + renderPage(await english()); + fireEvent.click(screen.getByRole("button", { name: /New project/ })); + + expect(await screen.findByText("Title *")).toBeInTheDocument(); + for (const text of ["Description", "Budget", "Currency", "Start date", "End date", "Notes"]) { + expect(screen.getAllByText(text).length).toBeGreaterThan(0); + } + const submit = screen.getByRole("button", { name: "Create project" }); + expect(submit).toBeDisabled(); + fireEvent.change(screen.getByPlaceholderText("Project name"), { target: { value: "New" } }); + fireEvent.click(submit); + + await waitFor(() => expect(toast).toHaveBeenCalledWith({ title: "Project created" })); + }); + + it("reports a failed creation with an English title", async () => { + svc.create.mockRejectedValue(new ApiError("Boom", "NOPE_UNKNOWN", {}, 500)); + renderPage(await english()); + fireEvent.click(screen.getByRole("button", { name: /New project/ })); + fireEvent.change(await screen.findByPlaceholderText("Project name"), { target: { value: "New" } }); + fireEvent.click(screen.getByRole("button", { name: "Create project" })); + + await waitFor(() => + expect(toast).toHaveBeenCalledWith(expect.objectContaining({ title: "Could not create the project", variant: "destructive" })), + ); + }); + + it("shows the detail with the period as formatted dates, the task count and the task status", async () => { + renderPage(await english()); + await select(); + + expect(await screen.findByText("Tasks (1)")).toBeInTheDocument(); + expect(screen.getByText("Period")).toBeInTheDocument(); + expect(screen.getByText("To do")).toBeInTheDocument(); + expect(screen.getByText("Design logo")).toBeInTheDocument(); + expect(screen.getByText("Project notes")).toBeInTheDocument(); + expect(screen.queryByText(/2026-03-05/)).not.toBeInTheDocument(); + expect(screen.queryByText("2026-04-10")).not.toBeInTheDocument(); + }); + + it("offers Activate on a planning project and confirms the transition", async () => { + renderPage(await english()); + await select(); + fireEvent.click(await screen.findByRole("button", { name: /Activate/ })); + + await waitFor(() => expect(svc.update).toHaveBeenCalledWith("p1", { status: "active" })); + await waitFor(() => expect(toast).toHaveBeenCalledWith({ title: "Status updated" })); + }); + + it.each([ + ["active", ["Pause", "Complete", "Cancel"]], + ["on_hold", ["Resume", "Cancel"]], + ])("labels the actions of a %s project", async (status, labels) => { + svc.get.mockResolvedValue(full({ status })); + renderPage(await english()); + await select(); + + for (const label of labels) { + expect(await screen.findByRole("button", { name: new RegExp(label) })).toBeInTheDocument(); + } + }); + + it("falls back to the English message when a transition is refused without a known code", async () => { + svc.update.mockRejectedValue(new Error("")); + renderPage(await english()); + await select(); + fireEvent.click(await screen.findByRole("button", { name: /Activate/ })); + + await waitFor(() => + expect(toast).toHaveBeenCalledWith({ title: "Transition not allowed", variant: "destructive" }), + ); + }); + + it("has no actions on a completed project", async () => { + svc.get.mockResolvedValue(full({ status: "completed" })); + renderPage(await english()); + await select(); + + expect(await screen.findByText("Tasks (1)")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Activate|Pause|^Complete/ })).not.toBeInTheDocument(); + }); + + it("explains a project without tasks", async () => { + svc.get.mockResolvedValue(full({ tasks: [] })); + renderPage(await english()); + await select(); + + expect(await screen.findByText("No tasks")).toBeInTheDocument(); + }); + + it("adds a task and confirms", async () => { + renderPage(await english()); + await select(); + fireEvent.click(await screen.findByRole("button", { name: /Add/ })); + + expect(await screen.findByText("New task")).toBeInTheDocument(); + expect(screen.getByText("Due date")).toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText("Task description"), { target: { value: "Another" } }); + const dialog = screen.getByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: "Add" })); + + await waitFor(() => expect(toast).toHaveBeenCalledWith({ title: "Task added" })); + }); + + it("deletes a planning project from the row menu", async () => { + renderPage(await english()); + const row = (await screen.findByText("Sun store")).closest("tr")!; + fireEvent.pointerDown(within(row).getByRole("button"), { button: 0, ctrlKey: false }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Delete" })); + + await waitFor(() => expect(toast).toHaveBeenCalledWith({ title: "Project deleted" })); + }); +}); diff --git a/composable-os/src/locales/en/projects.json b/composable-os/src/locales/en/projects.json new file mode 100644 index 0000000..c821a6c --- /dev/null +++ b/composable-os/src/locales/en/projects.json @@ -0,0 +1,80 @@ +{ + "title": "Projects", + "subtitle": "Project lifecycle management", + "new": "New project", + "kpi": { + "total": "Total projects", + "active": "Active", + "completed": "Completed", + "budget": "Total budget" + }, + "list": { + "title": "Project list", + "loading": "Loading...", + "empty": "No projects yet", + "createFirst": "Create the first project", + "number": "Number", + "projectTitle": "Title", + "status": "Status", + "budget": "Budget", + "start": "Start", + "end": "End", + "view": "View details", + "delete": "Delete" + }, + "create": { + "title": "New project", + "projectTitle": "Title *", + "titlePlaceholder": "Project name", + "description": "Description", + "descriptionPlaceholder": "Scope description", + "budget": "Budget", + "currency": "Currency", + "startDate": "Start date", + "endDate": "End date", + "notes": "Notes", + "notesPlaceholder": "Additional remarks", + "cancel": "Cancel", + "submit": "Create project" + }, + "taskDialog": { + "title": "New task", + "taskTitle": "Title *", + "titlePlaceholder": "Task description", + "description": "Description", + "dueDate": "Due date", + "cancel": "Cancel", + "submit": "Add" + }, + "detail": { + "loading": "Loading...", + "budget": "Budget", + "period": "Period", + "periodRange": "{{start}} → {{end}}", + "description": "Description", + "activate": "Activate", + "pause": "Pause", + "complete": "Complete", + "resume": "Resume", + "cancel": "Cancel", + "tasks": "Tasks ({{count}})", + "add": "Add", + "noTasks": "No tasks", + "start": "Start", + "markDone": "Mark as done", + "reopen": "Reopen", + "delete": "Delete", + "notes": "Notes" + }, + "toast": { + "created": "Project created", + "createFailed": "Could not create the project", + "taskAdded": "Task added", + "taskAddFailed": "Could not add the task", + "statusUpdated": "Status updated", + "transitionNotAllowed": "Transition not allowed", + "taskDeleted": "Task deleted", + "deleted": "Project deleted", + "deleteFailed": "This project cannot be deleted" + } +} diff --git a/docs/IMPLEMENTATION_ALIGNMENT.md b/docs/IMPLEMENTATION_ALIGNMENT.md index 03ff280..68a7293 100644 --- a/docs/IMPLEMENTATION_ALIGNMENT.md +++ b/docs/IMPLEMENTATION_ALIGNMENT.md @@ -83,7 +83,7 @@ Legacy documents are historical reference only unless explicitly promoted by ADR | External brand-site integration | Strategic direction documented; public lead intake v1 now has code, contract tests, decision logging, event metadata, and rate-limit response behavior, but is not yet operationally proven under production-like traffic | One validated public integration slice before SDK/builder expansion | High | Run deployment-like validation for CRM public intake before expanding public surfaces | | Public API boundary | Portal proves public interaction can work; CRM public intake now defines the initial site-key/origin/idempotency/rate-limit model | Explicit public endpoint policy with origin, key, idempotency, abuse controls, and logging rules | High | Promote the CRM public intake contract into broader public API conventions only after production validation | | Future stack pressure | Strategic docs mention SDK, builder, semantic layers, and UKIP-compatible ideas | Current stack remains frozen until justified | Medium | Treat those areas as target-state only unless promoted by ADR | -| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, locale-aware date and amount formatting in the server-rendered artifacts, and a second language offered to anyone. English is under way: the backend catalogues (invoice PDF, 9 emails) are complete but held back in `UNRELEASED_LOCALES`, the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing` and `portalBuilder` translated (15 of 19 namespaces) and offers a language only once every namespace exists. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose | +| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, locale-aware date and amount formatting in the server-rendered artifacts, and a second language offered to anyone. English is under way: the backend catalogues (invoice PDF, 9 emails) are complete but held back in `UNRELEASED_LOCALES`, the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder` and `projects` translated (16 of 19 namespaces) and offers a language only once every namespace exists. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose | | Schema drift between models and database | Closed. `alembic revision --autogenerate` on an unchanged model set emits an empty `upgrade()`, so a generated migration now contains only the intended change. Resolved in three steps: foreign-key delete rules declared in the models, `users.notification_preferences` tightened to `NOT NULL` in the database, and two leftovers removed — a redundant unique constraint on `public_sites.api_key` that duplicated the unique index, and an index on `quote_events.user_id` that no query used | Autogenerate stays silent on an unchanged model set | Medium | Keep it closed: run autogenerate before and after a model change and treat any unrelated output as drift to fix in its own change set, not to hand-edit away | --- diff --git a/docs/superpowers/plans/2026-08-07-i18n-full.md b/docs/superpowers/plans/2026-08-07-i18n-full.md index 059d330..67939bd 100644 --- a/docs/superpowers/plans/2026-08-07-i18n-full.md +++ b/docs/superpowers/plans/2026-08-07-i18n-full.md @@ -456,9 +456,10 @@ completa migrada no hay patron que seguir. detalle con clausulas y alta), `crm` (pipeline, pestanas y dialogos), `discovery` (bienvenida, dialogo de sesion, chat y blueprint), `hr` (empleados, departamentos y dialogos de alta), `inventory` (centro de operaciones, existencias y - paneles), `invoicing` (facturas, pagos y alta) y - `portalBuilder` (sesiones, constructor de paginas y bloques de tienda). - Quedan 4: `projects`, `sales`, `settings`, `workflows`. + paneles), `invoicing` (facturas, pagos y alta), + `portalBuilder` (sesiones, constructor de paginas y bloques de tienda) y `projects` + (lista, detalle con tareas y dialogos de alta). + Quedan 3: `sales`, `settings`, `workflows`. - [ ] Ofrecer `en`: mover `en` de `UNRELEASED_LOCALES` a `SUPPORTED_LOCALES` en el backend y el selector de idioma en el dialogo de compartir (pendiente desde la tarea 2). - [ ] Recorrer la aplicacion en ingles buscando lo que el segundo idioma revela: