Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composable-os/src/i18n/catalogues.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const BASE = "es";
* idioma se ofrece solo (`releasedLocales`): no hay un interruptor aparte.
*/
const TRANSLATED: Record<string, string[]> = {
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"],
};

/**
Expand Down
209 changes: 209 additions & 0 deletions composable-os/src/i18n/english-projects.test.tsx
Original file line number Diff line number Diff line change
@@ -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<I18n> {
const instance = i18next.createInstance();
await instance.init(buildI18nOptions(resources, "en"));
return instance;
}

const item = (over: Record<string, unknown> = {}) => ({
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<string, unknown> = {}) => ({
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<string, unknown> = {}) => ({
...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(<Projects />, 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" }));
});
});
80 changes: 80 additions & 0 deletions composable-os/src/locales/en/projects.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION_ALIGNMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<language>/<domain>.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/<language>/<domain>.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 |

---
Expand Down
7 changes: 4 additions & 3 deletions docs/superpowers/plans/2026-08-07-i18n-full.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading