# Grupo D · El trabajo encolado — V8 Sitemaps, V9 Lotes, V10 Ficha de lote

Las tres vistas son la parte del producto que justifica que exista: encolar trabajo contra Google en
vez de cargarlo de a cinco a mano. Ninguna está mal pensada —el razonamiento del dominio está
escrito en los comentarios y es correcto— y las tres están mal repartidas: lo que hay que leer
primero está debajo de lo que se usa una vez, y la misma cifra se escribe dos y tres veces en la
misma pantalla con tipografías distintas.

---

## V8 · Sitemaps — `/domains/{id}/sitemaps` — [`frontend/pages/Sitemaps/Index.tsx`](frontend/pages/Sitemaps/Index.tsx)

**Veredicto**: reestructurar — **severidad** 3/5
**El problema en una línea**: la pregunta de la vista —«¿Google tiene lo último que publiqué?»— se
contesta en una tabla que arranca a los 760 px, debajo de dos tarjetas y doce controles de filtro
que están ahí para una lista que en la captura tiene **una** fila.

### Qué pasa hoy

- La tabla, que es la respuesta, empieza en el píxel 760 de 1052. Arriba hay: la bajada, la tarjeta
  «Última sincronización» (255 px), la tarjeta «Registrar un sitemap» (190 px) y el bloque de
  filtros (115 px). El contenido ocupa el último 28 % de la página.
- El bloque de filtros rinde **doce controles** —búsqueda + botón, 3 chips de tipo, 3 de origen, 4
  de último envío— sobre una tabla de 1 fila. La condición que lo muestra es `total > 0`
  (`Index.tsx:305`), o sea que aparece completo desde el primer sitemap registrado.
- El alta es una tarjeta permanente en el segundo lugar de la página. Es la acción **más rara** de
  la pantalla —se registra un sitemap y después se lo mira durante meses— y ocupa la segunda banda
  más valiosa. Además el `EmptyState` la alcanza con `<a href="#location">` (`Index.tsx:343`): un
  ancla que salta hacia **arriba**, a un formulario que ya estaba a la vista.
- La celda «Resultado del último envío» puede rendir **cuatro líneas**: el resultado con su icono,
  el error del envío, «El archivo cambió después de ese envío…» y «Último envío correcto: …»
  (`Index.tsx:963-1016`). Tres de esas cuatro son prosa fija alrededor de un dato —una fecha, un
  booleano— que merecería columna propia (RT-02, contrato §3.5).
- `Stat` escribe el valor en `text-lg` (`Index.tsx:558`), tamaño que el contrato §1 elimina; y las
  dos `CardTitle` llevan `className="text-base"` (`474`, `609`), que es exactamente lo que la
  primitiva ya da. Dos síntomas del mismo problema: la vista no confía en el componente.
- La lista «Qué quedó sin procesar» (`Index.tsx:537-548`) rinde `summary['errors']` como viñetas de
  cadenas crudas «ubicación: motivo». Es **el mismo array** que V10 rinde como tabla con
  encabezados, y acá se rinde peor. Y cada uno de esos errores ya está, además, en su propia fila de
  la tabla de abajo, en `last_error` + `last_error_code`.
- La frase «No consumió cupo del día: no hubo nada nuevo que enviarle a Google» (`Index.tsx:527`)
  dice lo mismo que el pie fijo «Leer los sitemaps es tráfico hacia tu sitio, no hacia Google: no
  consume cupo» (`Index.tsx:362`). Dos veces la misma aclaración, a 700 px de distancia.

### En qué orden debería mirarse

1. **La tabla**: qué sitemaps conocemos y qué le mandamos a Google. Es la respuesta.
2. **El estado de la última corrida**, en una línea: terminó / está leyendo, cuándo, y el enlace al
   lote. Sólo se abre a detalle si sigue corriendo o si dejó algo sin procesar.
3. **Los filtros**, y sólo cuando hay lista suficiente como para que filtrar signifique algo.
4. **El alta**, que es un botón hasta que alguien lo pide.
5. Las dos aclaraciones fijas y el sello de zona horaria.

### Reestructuración propuesta

| Bloque de hoy | Adónde va | Patrón | Por qué |
|---|---|---|---|
| «Ver la cobertura» en `actions` | `DomainTabs` (Cobertura · Sitemaps · Lotes · Configuración) | `Tabs` como enlaces | El contrato §4 ya asigna esas cuatro caras al mismo objeto; deja el `actions` para la acción, no para la navegación |
| «Sincronizar ahora» | `actions`, único control primario | `Button` + `Spinner` | Es la única acción de la pantalla; queda sola y en una línea (regla 21) |
| Card «Última sincronización»: badge, fecha, enlace al lote | `PageIntro` | `StatusBadge` + `DataTimestamp` + `<Link>` + `PollingIndicator` | Es la línea de estado de la página, exactamente lo que `PageIntro` define. Una línea en vez de 255 px |
| Barra + 4 cifras + frase de cupo | `Collapsible` «Detalle de la última corrida», dentro de la misma `Section` | `Collapsible` (§4, ítem 6) | Abierto sólo si el lote no es terminal o dejó notas. Cuando terminó limpio, la línea del `PageIntro` es toda la verdad que hace falta |
| Las 4 cifras en `text-lg` | `DescriptionList columns={4}` — `dt` T6 / `dd` T5 | `LabelValue` | Elimina `text-lg` y el `Stat` local; el cupo consumido pasa a ser una cifra más y deja de necesitar su párrafo |
| `ul` «Qué quedó sin procesar» | Una línea con el conteo + enlace a `?result=FAILED`; el detalle, en V10 | `<Link>` al filtro | El detalle ya existe mejor rendido en V10 (`BatchIssues`) y cada error está además en su fila |
| Frase «No consumió cupo del día…» | Se funde con el pie fijo, que ya lo dice | — | Deja de haber dos redacciones de la misma aclaración |
| Card «Registrar un sitemap» | `FormDialog` con `?new=1` en la dirección | `Dialog` (§4, ítem 3: 1 campo) | Un campo no merece una banda permanente. El estado en la URL es lo que permite reabrir el diálogo **con el error adentro** después del redirect |
| `<a href="#location">` del `EmptyState` | Disparador del mismo `FormDialog` | `Button` | Sin sitemaps, el alta es lo único que hay para hacer: el vacío la ofrece de verdad |
| Bloque de filtros (12 controles) | `Section` con `h2 sr-only`; 3 `ToggleGroup` + 1 `InputGroup` | `toggle-group`, `input-group` | Los tres grupos tienen ≤5 opciones: es el patrón que Cobertura ya usa bien. Se rinde con `total > 10` **o** con algún filtro activo, para que un enlace compartido muestre sus propios controles |
| Columna «Tipo y origen» | Igual, dos líneas fijas (badge + origen T6) | — | Ya está bien; sólo deja de competir con celdas de cuatro líneas |
| Columna «URLs declaradas» con sub-línea «sitemaps declarados» | Columna «Declara»: cifra a la derecha + unidad T6 en la misma línea | `columnHelper.numeric` | El encabezado dice «URLs» y para un índice el número cuenta sitemaps: hoy el encabezado y el valor no hablan de lo mismo |
| Columna «Resultado del último envío» (hasta 4 líneas) | **Dos** columnas: «Último envío» (`StatusBadge`) y «Fecha del envío» (`DataTimestamp`, vacío = «Nunca») | §3.7: un badge de estado va siempre seguido de su columna de fecha | «Último envío correcto: …» y «Nunca se envió con éxito» dejan de ser prosa y pasan a ser el valor de la columna que ya les correspondía |
| Línea «El archivo cambió después de ese envío» (`needs_submit`) | Sexto valor del badge: `attention` «Pendiente de envío» | `StatusBadge` + `Tooltip` | Es un estado, no una nota al pie. La palabra se lee sin el tooltip; el tooltip sólo dice **cuándo** se envía |
| Error de lectura + aviso de URLs ajenas en la celda de ubicación | Clasificación en **una** línea T6 en la celda (`ERROR_REASONS`, `aria-describedby`); el mensaje del servidor, en `DetailSheet` con `?sitemap=<id>` | `Sheet` (§4, ítem 4) | La fila conserva su error asociado a la fila, como pide V8 del checklist; lo largo —el 404, la etiqueta que faltaba, las 12 URLs ajenas— deja de estirar todas las filas |
| Botón «Detalle» de la fila | Última celda, **sólo** en filas con problema | `Button variant="ghost"` | Una sola acción no es un menú (§4, ítem 10). En una tabla sana la columna queda vacía |
| Los dos párrafos fijos + `TimezoneFootnote` | Quedan donde están | — | RT-05 y RT-01; el texto vuelve porque la confusión vuelve |

### Componentes compartidos que necesita

- `DomainTabs` — la barra de las cuatro caras de un dominio, con los `TabsTrigger` como `<Link>` a
  las rutas que ya existen. `{ domainId: string; current: 'coverage' | 'sitemaps' | 'batches' |
  'settings' }`. Reemplaza los ocho botones «Ver la …» repartidos hoy entre V6, V7, V8, V9 y V10.
- `FormDialog` — el diálogo de formulario corto con su anatomía congelada: se abre con un parámetro
  de la dirección (`?new=1`), **no se cierra al enviar**, el error del servidor vuelve junto al
  campo con `field` / `FieldError`, el foco va al primer campo inválido y el botón muestra
  `Spinner` + gerundio. `{ param, title, description?, submitLabel, pendingLabel, children }`.
  Reemplaza la tarjeta de alta de V8 y sirve para las otras dos usos que el contrato ya nombra.
- `DetailSheet` — el panel de detalle de una fila, con el id en la dirección y un
  `DescriptionList` adentro. `{ param, title, items, action?, open, onOpenChange }`. Existe medio
  escrito en Cobertura; si V8 arma el suyo, el producto termina con dos.
- `PollingIndicator` — «Se está leyendo · se actualiza sola», `Spinner` **dentro de un badge**
  (§3.9). `{ active, label, href? }`. Lo necesitan V8, V9 y V10, y hoy cada una lo insinúa distinto.
- `columnHelper.numeric()` en `DataTable` — fija `text-right tabular-nums` en la celda **y en el
  encabezado** a la vez. Es la única forma de que la regla 14 no se rompa fila por fila.

### Qué no tocar

- El razonamiento de `SUBMIT_RESULTS` (`Index.tsx:96-114`): `SKIPPED_UNCHANGED` con icono de
  igualdad y color de texto normal, nunca gris de falla. Ese comentario es la razón por la que el
  producto no empuja a forzar envíos inútiles.
- `check_sitemap()` (`apps/sitemaps/services.py:101-135`) y su llamada **antes** de guardar
  (`views.py:156-162`). Es la decisión de producto de rechazar al pegar; el diseño existe para
  sostenerla, no para relajarla.
- La distinción entre «declara algunas URLs ajenas» (aviso, el archivo sirve) y «ninguna pertenece»
  (rechazo). Está en `check_sitemap` y en `foreign_url_count`, y es correcta.
- `preserveState` en el envío del alta (`Index.tsx:596`) y el foco al campo con error
  (`Index.tsx:584-586`). Sin eso, rechazar al pegar sería peor que no rechazar.
- La sangría por `depth` **más** el «Declarado por …» en palabras: la sangría no sobrevive a
  ordenar por otra columna ni la lee un lector de pantalla.

### Reglas en juego

- **RT-02 / §3.7**: el resultado del envío es un badge; su fecha va en columna propia, no adentro
  de la misma celda ni en un `title`.
- **RT-04**: los cuatro resultados de envío (Enviado · Sin cambios · Pendiente de envío · Falló) se
  distinguen por icono y palabra; en escala de grises siguen siendo cuatro.
- **RT-05**: el pie fijo se queda. «Enviar le avisa a Google que existe; no decide si lo rastrea ni
  cuándo».
- **RT-08**: el error del alta viaja junto al campo, dentro del `FormDialog`, nunca en un toast; el
  error de sincronización sigue en `ServerErrorNotice`.
- **RT-09**: la tabla ya pagina en el servidor; lo que cambia es el alto de la fila.
- **RT-12**: el botón del alta dice «Comprobando la dirección…», no «Registrando…»: lo que tarda es
  bajar el archivo del sitio del usuario, y decirlo hace legible una espera de hasta 10 s
  (`REGISTER_TIMEOUT_SECONDS`).

---

## V9 · Lotes — `/domains/{id}/batches` — [`frontend/pages/Batches/Index.tsx`](frontend/pages/Batches/Index.tsx)

**Veredicto**: reestructurar — **severidad** 4/5
**El problema en una línea**: la tabla cumple todo lo que hay que cumplir —paginación de servidor,
icono + palabra, sondeo que se corta, región viva que sólo anuncia cambios— y aun así el fold
muestra 5 lotes, porque **la fila es un párrafo**: la misma cifra se dice en prosa en una columna y
en números en la de al lado.

### Qué pasa hoy

- 3540 px, 1043 nodos, 444 bloques de texto para **31 lotes**. Son ~100 px por fila: cada una tiene
  hasta 3 líneas en «Tipo y origen», hasta 3 más un botón en «Estado», 3 en «Avance» y 2 en cada
  columna de fecha.
- **La celda «Estado» y la celda «Avance» dicen lo mismo.** Estado: «Se procesaron 317 de 2.000 URLs
  hasta ahora.» Avance: barra + «317 de 2.000 procesadas». Es el mismo par de números, una vez en
  prosa y otra en cifras, uno al lado del otro (`Index.tsx:295-319` y `BatchProgress.tsx:225-236`).
- `RowStateCell` fija `min-w-52` y `BatchProgress` `min-w-40` (`Index.tsx:301`, `97`): entre las dos
  se llevan 350 px de los ~1100 disponibles y aplastan las columnas de fecha, que es por lo que en
  la captura se lee «hace 7 / horas» y «En cola / desde hace / 23 horas» en tres renglones.
- «Fallidos», «Cupo consumido» y «Duración» son numéricas y están alineadas a la izquierda, con el
  encabezado también a la izquierda (`Index.tsx:106-155`). Contradice la regla 14 del contrato y es
  la razón por la que no se pueden comparar dos filas de un vistazo.
- La columna «Inicio» rinde una **oración** cuando el lote no arrancó: «En cola desde <fecha>»
  (`Index.tsx:126-132`). El servidor, en cambio, ya ordena esa columna por
  `Coalesce('started_at', 'created_at')` bajo una sola clave `started` (`apps/jobs/views.py:75`):
  la base trata las dos fechas como un solo eje y la pantalla las trata como dos.
- La columna «Fin» rinde «Todavía en curso» o «—» (`Index.tsx:136-148`): prosa y guiones en una
  columna de fechas, para un dato que es `Inicio + Duración`.
- Hay un botón «Ver los ítems que fallaron» / «Ver el detalle del lote» **dentro de la celda de
  estado** (`Index.tsx:308-316`) que apunta al mismo destino que el enlace del tipo, en la primera
  celda de la misma fila. Dos entradas al mismo lugar, una de ellas engordando la fila 40 px.
- El sondeo no hace latir la tabla —`useBatchPolling` recarga props y `DataTable` sólo muestra
  esqueleto en **su** navegación, no en la recarga— pero tampoco se ve. Con el lote en curso en la
  página 2, la vista se recarga cada 5 s y no cambia un píxel: 12 peticiones por minuto sin ninguna
  señal en pantalla de que algo está pasando.

### En qué orden debería mirarse

1. **La primera fila**: qué corrió último y cómo terminó. Es literalmente la pregunta de la vista.
2. **La columna de estado**, de arriba abajo: dónde están los `PARTIAL` y los `FAILED`.
3. **Lo que quedó sin hacer**: la columna «Sin procesar», que es el subtítulo de la página
   («qué quedó sin hacer») convertido en una cifra comparable.
4. **Los filtros**, cuando la lista pasa de una pantalla.
5. Las dos aclaraciones fijas y el sello de zona horaria.

### Reestructuración propuesta

| Bloque de hoy | Adónde va | Patrón | Por qué |
|---|---|---|---|
| «Ver el dominio» + «Ver la cobertura» en `actions` | `DomainTabs` | `Tabs` como enlaces | Es navegación entre caras del mismo objeto, no acción sobre esta pantalla |
| Tres grupos de chips `Button` | 3 `ToggleGroup` dentro de una `Section` con `h2 sr-only` | `toggle-group` | Estado, tipo y origen tienen ≤5 valores cada uno; es el patrón que Cobertura ya usa bien |
| (no existe) Corte por período | `Select` «Período» → `?days=` | `select` | Con 500 lotes acumulados el corte útil es un rango, no un agrupador visual. `?days=` ya es vocabulario del producto (`apps/web/views.py:156`) y `Filter` acepta `cast`, así que son dos líneas de servidor |
| Celda «Estado»: badge + oración + botón | Badge + **motivo corto** (≤4 palabras) en T6 | `StatusBadge` + `batchReasonShort()` | «Cupo agotado», «Límite de Google», «Sin acceso», «Ítems fallidos». Conserva la distinción que la fila necesita —cortado por cupo ≠ falló— y manda la oración completa a V10, que ya la escribe palabra por palabra con `BatchStateHeadline` |
| Botón dentro de la celda de estado | Se va: el enlace de la primera celda ya es la entrada a la ficha | `<Link>` (regla 19) | Dos caminos al mismo destino en la misma fila; el de la primera celda además funciona con Cmd+clic y con teclado |
| Celda «Avance»: barra + conteo + «Quedaron N sin procesar» | Barra + conteo. El pendiente sale a columna | `BatchProgress` | El pendiente es un número comparable entre filas: en una columna se suma con la vista, en una frase no |
| (nueva ubicación) «Sin procesar» | Columna numérica a la derecha | `columnHelper.numeric` | Es el dato que la bajada de la página promete. Hoy existe, escondido en prosa |
| «Fallidos» y «Cupo consumido» a la izquierda | A la derecha, encabezado incluido | `columnHelper.numeric` | Regla 14 |
| «Inicio» + «Fin» | Una columna **«Cuándo»** sobre `Coalesce(started_at, created_at)`; «Fin» vive en V10 | `DataTimestamp` | Es la clave por la que el servidor ya ordena. El badge de la columna anterior dice si ese momento es un encolado o un arranque, así que la celda vuelve a ser una fecha de una línea |
| «Duración» | Queda, numérica a la derecha | — | Es la respuesta a «¿tardó un rato o media tarde?» y ya está bien resuelta en `batchDuration` |
| Sondeo invisible | `PollingIndicator` en `PageIntro`: «1 lote en curso», enlace a `?state=RUNNING` | `StatusBadge` + `Spinner` | Una sola cosa se mueve en la pantalla, y si lo que se mueve no está en esta página, se puede ir a verlo. Paginar, ordenar y filtrar siguen sin animarse (§3.12) |
| `EmptyState` + `FirstBatchAction` | Quedan tal cual | — | La acción que se **reemplaza** según lo que falte es de lo mejor que hay en el repositorio |
| Dos párrafos fijos + `TimezoneFootnote` | Quedan | — | RT-05, RT-01 |

**Los cinco estados, de un vistazo.** El mapa de `BatchStateBadge` ya cumple RT-04: cinco siluetas
distintas, no la misma tilde con otro color. Lo que cambia es lo que las rodea.

| Estado | Icono | Palabra | Tono | Barra | Columna «Cuándo» | Motivo corto |
|---|---|---|---|---|---|---|
| `QUEUED` | `Hourglass` | En cola | `neutral` | sin barra y **sin animación** (RT-14) | encolado hace 23 h | — |
| `RUNNING` | `LoaderCircle` girando | En curso | `neutral` | crece; es el único movimiento de la fila | empezó hace 23 h | — |
| `COMPLETED` | `CircleCheck` | Completado | `positive` | llena y sólida | terminó ayer | — |
| `PARTIAL` | `CircleDotDashed` | Parcial | `attention` | **nunca llena en sólido** | terminó anteayer | «Cupo agotado» |
| `FAILED` | `CircleX` | Falló | `critical` | vacía o rayada | terminó ayer | «Sin acceso» |

Con la fila a dos líneas —el nombre del lote y su origen— la altura baja de ~100 px a ~44 px: en el
mismo alto de contenedor (`max-h-[70vh]`) el fold pasa de 5 lotes a **13 o 14**.

### Componentes compartidos que necesita

- `DomainTabs`, `PollingIndicator`, `columnHelper.numeric()` — los mismos de V8.
- `batchReasonShort(batch): string | null` en `frontend/pages/Batches/batch.ts` — mapa cerrado de
  los seis códigos de `BatchReason` a **≤4 palabras**. Vive al lado de `batchReasonText`, que se
  queda intacto para V10, por la misma razón que el archivo ya declara: la fila y la ficha tienen
  que contar la misma historia del mismo corte.
- Un `Filter(param='days', lookup='created_at__gte', cast=…)` en `BATCHES` — no es un componente,
  pero es la pieza de servidor que hace falta para el corte por período.

### Qué no tocar

- `useBatchPolling` entero, y sobre todo que sea el único `setInterval` del producto. El comentario
  del archivo explica por qué, y es verificable con una búsqueda.
- `_active_states` mirando el dominio y no la página (`apps/jobs/views.py:189-201`). Es correcto: lo
  que hay que arreglar no es el criterio del sondeo, es que su resultado no se ve.
- `useStateChangeAnnouncement` (`Index.tsx:386-413`): anuncia sólo lo que cambió. Sin esa
  comparación, la pantalla es ruido continuo cada 5 s para quien usa lector.
- El comentario de `BATCHES` que explica por qué **no** se ofrece ordenar por estado
  (`apps/jobs/views.py:77-83`). Es una decisión correcta y no obvia.
- `FirstBatchAction`: la acción se reemplaza, no se deshabilita (RT-07, RT-18).

### Reglas en juego

- **R-C**: `COMPLETED` y `PARTIAL` con siluetas distintas, ya cumplido; el motivo corto es lo que
  evita que «Parcial» obligue a abrir la ficha para saber si se cortó por cupo o porque algo falló.
- **RT-01 / RT-02**: cada estado con su fecha; la fecha en columna propia, nunca en un `title`.
- **RT-04**: icono + palabra, verificable en escala de grises.
- **RT-09**: ya cumplido —50 por página, total a la vista, todo en la querystring—; lo que faltaba
  para que sirviera con 500 lotes es que entren más de cinco en una pantalla.
- **RT-13 / RT-14**: el estado inicial es «En cola», nunca «Listo»; ninguna barra indeterminada en
  un lote que no arrancó.
- **Regla 14 y 19** del contrato: numéricas a la derecha; la fila no es clickeable entera.

---

## V10 · Ficha de lote — `/batches/{id}` — [`frontend/pages/Batches/Show.tsx`](frontend/pages/Batches/Show.tsx)

**Veredicto**: reestructurar — **severidad** 4/5, con dos defectos de corrección que hay que
arreglar antes que cualquier cosa de diseño
**El problema en una línea**: la ficha dice tres veces los mismos tres números y, en dos caminos
concretos del código, se contradice a sí misma: un lote `PARTIAL` puede dibujar la barra llena y un
lote `FAILED` puede anunciar «No falló ningún ítem».

### Qué pasa hoy

- **La barra llena en un `PARTIAL` (R-C).** `_close` de la sincronización marca `PARTIAL` cuando
  `summary.errors` no está vacío (`apps/sitemaps/services.py:435-440`). `_submit_if_changed` agrega
  «No quedaba cupo para enviar «…». Se retoma en el próximo ciclo.» **sin tocar `failed_items` ni
  `processed_items`** (`services.py:385-388`), y ese sitemap ya sumó `processed_items` al leerse
  (`services.py:265`). Resultado real: `processed == total`, `failed == 0`, `pending == 0`, estado
  `PARTIAL`. `BatchProgress` calcula desde las cifras, así que la barra queda **llena y sólida, sin
  rayas y sin hueco**. Lo único que la distingue de un `COMPLETED` es la palabra del badge, que es
  precisamente lo que R-C prohíbe.
- **Y la frase que la acompaña dice lo contrario.** En ese mismo caso `_reason` cae en `ITEM_ERRORS`
  (`apps/jobs/views.py:282`, porque `errors` está lleno aunque `failed_items` sea 0) y
  `batchReasonText` imprime **«Fallaron 0 de 7 sitemaps.»** (`batch.ts:153-155`). Un lote parcial,
  con la barra al 100 %, explicado con una oración que dice que no falló nada.
- **`FAILED` que dice que no falló nada.** `_fail` cierra el lote en `FAILED` con
  `summary = {'errors': [exc.message]}` y `failed_items` en 0 (`apps/sitemaps/tasks.py:100-103`).
  `FailureList` decide su título con `batch.failed_items > 0` (`Show.tsx:583`), así que la captura
  `v10b` muestra, en la misma pantalla: badge **«Falló»**, la frase «Fallaron todos los intentos» y,
  30 cm más abajo, **«No falló ningún ítem»**.
- **La deuda de `summary['errors']` ya está resuelta en los datos y la pantalla no la usa.** El
  servidor parte cada entrada en `{item, reason}` y deja `item: null` en todo lo que no tenga forma
  de URL (`apps/jobs/views.py:302-322`). Los dos sitios que incrementan `failed_items` son
  exactamente los dos que escriben `f'{location}: {motivo}'` (`services.py:238` y `:404`); los tres
  que escriben notas —ciclo de índices, sin cupo, excepción global— no llevan URL adelante. O sea:
  **`item !== null` ⇔ intento fallido** es cierto hoy, fila por fila, y es un invariante que un test
  puede afirmar contra `failed_items`. La pantalla, en cambio, decide con `failed_items` a nivel
  lote, que es la única forma de equivocarse: un lote con 5 ítems fallidos **y** una nota de cupo
  lista la nota bajo el título «Los ítems que fallaron», convirtiendo un corte por cupo en un error.
- **Los mismos números, tres veces.** En la captura `v10-partial`: **1.588** aparece en el motivo,
  en el pie de la barra y en la cifra «Sin procesar»; **412** y **2.000**, dos veces cada uno. Las
  tarjetas «Cómo terminó» y «Cifras» son el mismo contenido con dos tipografías.
- **`unchanged` no se rinde nunca cuando importa.** `InspectionSummary` sólo muestra «Otras N URLs
  se consultaron y seguían con el mismo estado» dentro de la rama `hasBreakdown`
  (`Show.tsx:381-386`). Un lote que consultó 412 URLs y ninguna cambió cae en la otra rama y dice
  «Este lote no dejó ningún estado nuevo anotado» —una tarjeta entera para una negación— sin decir
  jamás que **se consultaron 412 y seguían igual**, que es el resultado.
- **Cajas dentro de cajas.** `QuotaMeter` dibuja dos `rounded-lg border p-3` (`QuotaMeter.tsx:136`)
  dentro de una `Card`; `FailureList` envuelve su tabla en otro `rounded-lg border`
  (`Show.tsx:613`); `NeighbourLink` es un tercer `rounded-lg border` a mano (`Show.tsx:682`). Tres
  radios y tres bordes distintos del de la primitiva, en una sola pantalla.
- **La navegación entre vecinos flota.** Con un solo vecino, `NeighbourNav` rinde un `<span />`
  vacío como espaciador (`Show.tsx:663`) y la tarjeta queda pegada al borde derecho, a 60 px del
  pie, como si se hubiera caído ahí.
- **El `actions` lleva tres controles** de igual peso —«Ver todos los lotes», «Ver la cobertura»,
  «Ver el dominio»— y ninguno conserva el filtro con el que se llegó: `batchesPath(domain.id)`
  devuelve la lista sin querystring (`batch.ts:13-15`).

### En qué orden debería mirarse

1. **Cómo terminó y por qué**, en un bloque: la palabra del estado, el par de cifras y la frase del
   motivo. Con eso ya se decidió si hay algo que hacer.
2. **Qué quedó sin hacer y si eso es un problema**: los ítems fallidos, separados de las notas.
3. **Qué produjo el lote**, según su tipo: estados obtenidos, sitemaps enviados, el archivo.
4. **Cuánto cupo gastó**, para ubicar lo anterior.
5. Los lotes vecinos y el sello de zona horaria.

### Reestructuración propuesta

| Bloque de hoy | Adónde va | Patrón | Por qué |
|---|---|---|---|
| `actions` con 3 enlaces | `Button «Volver a los lotes»` (con el filtro) + `DropdownMenu «Ir a…»` | `button-group` / `dropdown-menu` (§4, ítem 10) | Dos controles, una línea (regla 21). El regreso a la lista es la navegación más usada y deja de estar al mismo nivel que dos destinos laterales |
| Volver perdiendo el filtro | El `<Link>` de la fila en V9 agrega `?from=<query de la lista>`; V10 lo lee y arma su href de vuelta; sin `from`, vuelve a la lista sin filtrar | Estado en la URL (§4) | Es la misma regla que ya hace enlazable a `DataTable`. Sirve además cuando se llega desde una notificación o desde el enlace del lote en V8, donde no hay filtro que conservar. El valor se valida contra los parámetros declarados en `BATCHES`, así que no puede volverse un redirect abierto |
| `description` «Programado · ejemplo.com» | `PageIntro`: `StatusBadge` + fin + origen + dominio | `PageIntro` | Una línea de estado donde hoy hay una bajada que no explica nada |
| Card «Cómo terminó» | `Section` «Cómo terminó» con **una** `Card`: T2 «412 de 2.000» + `BatchProgress` + la frase del motivo + la acción en `CardFooter` | `Card` + T2 | El par de cifras es el único T2 de la pantalla. El contrato lo permite explícitamente («92 de 120») y prohíbe que T2 sea una palabra de estado: acá la palabra la lleva el badge y la cifra lleva la verdad |
| Card «Cifras» (8 pares, 3 repetidos) | `DescriptionList columns={2}` de **5** pares: Fallidas · Cupo consumido · Inicio · Fin · Duración | `LabelValue` | Total, procesadas y pendientes ya están arriba en T2, en la barra y en su pie. Ningún número se escribe dos veces en la pantalla |
| «Este lote no dejó ningún estado nuevo anotado» | Misma `Card`, pero `unchanged` sale del `if` | — | Un lote que consultó 412 URLs sin cambios hizo algo, y hoy la pantalla dice que no |
| Card «Qué frenó a este lote» / «Los ítems que fallaron» | `BatchIssues`: **dos bloques**, decididos por `item !== null`, no por `failed_items` | `Card` ×2 + `Accordion` por motivo cuando pasa de 8 filas | Es la deuda del brief, y los datos ya vienen partidos. El bloque de notas dice con todas las letras «Ninguna de estas es una falla: lo que quedó sigue en el ciclo siguiente» |
| `rounded-lg border` alrededor de la tabla de fallos | Se va | — | La `Card` ya tiene su anillo; el borde propio le da dos filos |
| Card «Cupo del día del lote» + los 2 recuadros de `QuotaMeter` | `Section` «Cupo del día» **sin** `Card`, con los dos medidores como `Card size="sm"` de igual ancho | `Section` + `Card` | Corta la caja dentro de la caja y aplica §2: dos mitades del mismo total, dos anchos iguales. `QuotaMeter` conserva su barra propia, que es una de las dos excepciones declaradas en §5 |
| Título repetido «Cupo del día del lote · 18 de ago de 2026» | Se funde con el `CardTitle` de la `Section` | — | Hoy el mismo texto está dos veces con 40 px de diferencia |
| `NeighbourNav` con `<span />` de relleno | `Section` «Lotes vecinos» con `grid md:grid-cols-2 gap-4` de dos `Item` | `item` | Deja de flotar; con un solo vecino la grilla simplemente tiene una celda |
| `TimezoneFootnote` | Queda | — | RT-01 |

**Cómo se presenta un `PARTIAL` sin mentir.** Cuatro cosas, en este orden y en el mismo bloque:

1. `StatusBadge tone="attention"` con `CircleDotDashed` y la palabra **«Parcial»**. Silueta propia,
   ni la tilde ni la cruz.
2. **«412 de 2.000»** en T2, con la etiqueta «URLs procesadas» arriba. Un par de cifras, nunca un
   porcentaje, nunca la palabra del estado a 24 px.
3. `BatchProgress` debajo, con la regla endurecida: **el tramo sólido no llega al borde bajo ningún
   estado cuya palabra no sea «Completado»**. Cuando las cifras no dejan hueco —el caso del sync que
   leyó los 7 y no pudo enviar 1— la incompletitud se dice **con palabras** en el pie: «7 de 7
   leídos · 1 quedó sin enviar por cupo», nunca sólo con el color del badge. Y el caso
   `total_items === 0` deja de imprimir «El lote no tenía ítems para procesar» cuando el estado no
   es `COMPLETED`: esa frase se lee como éxito.
4. La frase de motivo, tal como la escribe `batchReasonText`, con un arreglo: la rama `ITEM_ERRORS`
   sólo se usa si `failed_items > 0`; si no, cae en «Se interrumpió antes de terminar» más la nota
   de pendientes. Así desaparece «Fallaron 0 de 7 sitemaps».

La acción que ofrece la ficha se mantiene como está pensada en `batchAction`: **no hay botón de
reintentar** cuando el corte fue por cupo, porque se resuelve solo mañana y un botón ahí invita a
insistir sobre algo que no depende de la persona. Lo que sí ofrece: «Comprobar el acceso» cuando el
motivo es `PROVIDER_DENIED`, «Ver las URLs que quedaron» hacia la cobertura filtrada, y —en un lote
de exportación— «Descargar el archivo». Un «Reintentar lo que faltó» genérico sería, además, RT-05
disfrazado de botón: promete apurar algo que el cupo diario no deja apurar.

### Componentes compartidos que necesita

- `BatchIssues` — los dos bloques del lote, separados por origen del dato.
  `{ failures: {item: string | null; reason: string}[]; total: number; failedItems: number }`.
  Rinde «Ítems que fallaron» (`item !== null`) y «Por qué se cortó» (`item === null`), agrupa por
  motivo con `Accordion` cuando pasa de ocho filas —que es el caso que el contrato ya nombra— y
  anuncia el recorte de `MAX_FAILURES` en el sitio. Reemplaza a `FailureList` y a la lista de
  viñetas de V8.
- `DomainTabs` **no** va en V10: un lote no es una cara de un dominio, es un objeto con página
  propia. Es la línea que mantiene coherente la decisión de rutas.
- `PollingIndicator` y `columnHelper.numeric()` — los mismos de V8 y V9.

### Qué no tocar

- `_daily_quota` y su negativa a inventar el presupuesto que falta (`apps/jobs/views.py:325-342`).
  Un lote de hace un mes explicado con el cupo de hoy es exactamente el historial falso que el
  modelo evita.
- El docstring de `_inspection` y el texto que dice **de qué** es el conteo: el reparto no es el
  estado de todas las URLs consultadas, y presentarlo así convertiría una foto parcial en una
  afirmación sobre el sitio entero.
- La fecha de obtención pegada al conteo por estado (R-A / RT-02).
- `batchAction(…, 'detail')` devolviendo nulo: la ficha no se manda a sí misma y no ofrece
  reintentos que no dependen de la persona.
- La aclaración de que el cupo consumido es cupo del dueño del sitio, no un recurso nuestro.
- `BatchStateHeadline`: el estado y su motivo en el mismo bloque, con la raya oculta al lector de
  pantalla.

### Reglas en juego

- **R-C**, en sus dos formas: `PARTIAL` nunca se dibuja como éxito completo, y una nota de corte por
  cupo nunca se lista como falla.
- **RT-01 / RT-02**: inicio, fin y fecha de obtención, siempre con `DataTimestamp`.
- **RT-04**: badge con icono y palabra; la barra refuerza, no porta.
- **RT-05**: ningún «reintentar para acelerar»; ninguna promesa sobre cuándo Google va a rastrear.
- **RT-08**: los motivos vienen del servidor con su código; la pantalla no les inventa causa.
- **RT-12 / RT-13**: región viva sólo sobre el bloque de cifras y sólo mientras se sondea.
- **Reglas 6, 7, 8 y 12** del contrato: ninguna página define su borde de tarjeta, ninguna `Card`
  con `p-*` propio, ninguna `Card` dentro de otra, dos piezas del mismo tipo con el mismo ancho.

---

## Lo que aprendí que sirve para las otras vistas

**1. La duplicación acá no es de componentes: es de datos.** El patrón se repite en las tres vistas
y sospecho que en las otras once: el mismo número se escribe una vez en prosa y otra en cifras, a
veinte píxeles de distancia, con dos tipografías. V9 lo hace entre «Estado» y «Avance»; V10 lo hace
entre «Cómo terminó» y «Cifras»; V8 lo hace entre la tarjeta de sincronización y el pie fijo.
Propongo subir al contrato una regla verificable: **ningún dato se escribe dos veces en la misma
pantalla; si hace falta decirlo en palabras y en cifras, la versión en cifras es la que se queda y
la prosa se queda con lo que las cifras no dicen.** Es la mitad de los 444 bloques de texto de V9.

**2. La prosa dentro de una celda es lo que hace largas a las tablas, no la cantidad de columnas.**
V9 tiene 8 columnas y 100 px de fila; con 8 columnas y celdas de una o dos líneas, 44 px. La regla
que sirve para Cobertura, Sesiones, Dominios y Notificaciones: **una celda es un valor, no una
explicación.** La explicación es fija y va una vez, al pie de la tabla, donde V8 y V9 ya la ponen
bien.

**3. `StatusBadge` necesita una sexta situación además de los cinco tonos: el estado que espera una
acción del sistema, no de la persona.** Aparece como «Pendiente de envío» en V8, como «En cola» en
V9 y como «Todavía en curso» en V10. Hoy cada vista lo redacta distinto. Sugiero fijar en §3.7 que
`neutral` es exactamente eso —el sistema tiene algo pendiente— y que `attention` queda reservado
para «falta una acción tuya». Sin esa línea, las seis propuestas van a repartir «En cola» y
«Pendiente» entre `neutral` y `attention` de seis maneras.

**4. El contrato asigna las cuatro caras de un dominio a `Tabs` y hoy son cuatro páginas con ocho
botones «Ver la …» repartidos entre sus encabezados.** Propongo cerrarlo así, porque toca a los
grupos B, C y D a la vez: **las rutas se conservan** (`/domains/{id}/coverage`, `/sitemaps`,
`/batches`, y la ficha) y los `TabsTrigger` son `<Link>` a esas rutas, con `aria-current`. La URL
sigue siendo el estado, los enlaces profundos siguen funcionando, y el `actions` del encabezado
—que es de una sola línea— se libera para la acción real de cada pantalla. La contracara, y es la
línea que hay que respetar: **un objeto con página propia no lleva la barra de pestañas de otro
objeto.** La ficha de lote no es una cara del dominio; es un objeto con URL propia al que se llega
desde una notificación, desde V8 y desde la API. Por eso `/batches/{id}` fuera del dominio está
bien, y `/domains/{id}/batches` colgado del dominio también: no es una incoherencia, es la
distinción entre una cara y un objeto.

**5. El regreso a una lista filtrada es un problema de todo el producto, no de V10.** Cobertura,
Sesiones y Dominios lo van a tener igual. La solución que propongo —el `<Link>` de la fila lleva
`?from=<query>`, el detalle lo devuelve, y el valor se valida contra los parámetros que el
`TableSpec` ya declara— es la única que respeta «todo estado de revelación va a la URL» sin inventar
un breadcrumb, que el contrato descartó por escrito. Vale la pena que salga de `DataTable` y no de
cada vista.

**6. Cuando un dato tiene dos significados, el arreglo es una migración, no un `if` en la
pantalla.** `summary['errors']` mezcla intentos fallidos con notas de un lote que no falló nada, y
la pantalla lo desambigua mirando `failed_items`, que es una cifra del lote y no de la fila. La
buena noticia es que la separación **ya existe en los datos** (`item` presente ⇔ intento fallido, y
coincide exactamente con los dos sitios que incrementan `failed_items`), así que el arreglo
inmediato es usarla. El arreglo durable es el que el proyecto ya sabe hacer: `SyncSummary` e
`InspectionSummary` guardan `{item, reason, kind}` y una migración reescribe lo que está en la base,
igual que `0002_summary_keys_in_english`. Que esté guardado en la base explica por qué cuesta más,
no por qué se deja.

**7. Un bloque que sólo importa mientras algo está pasando no tiene por qué ocupar lugar cuando no
pasa nada.** «Última sincronización» en V8 es imprescindible mientras el lote corre e irrelevante
cuando terminó limpio hace tres días. La palanca del brief —«dónde vive» y **«cuándo aparece»**— es
la más barata de las dos y la menos usada: `Collapsible` abierto por condición, filtros que aparecen
recién cuando hay algo que filtrar, botón de detalle sólo en las filas que tienen algo que explicar.
Ninguna de las tres quita información.

**8. La animación de un producto que se mira muchas veces por día tiene un solo lugar legítimo acá:
la barra que crece.** Todo lo demás —paginar, ordenar, filtrar, cambiar de pestaña— no se anima, y
el único indicador de que el sondeo está vivo es un badge con `Spinner`, no la pantalla entera
repintándose. La regla que propongo subir: **si algo se repinta cada 5 segundos, sólo se puede mover
lo que efectivamente cambió de valor.**
