# GFIBER-671 — Botones de scroll izquierda/derecha en el Attendance Grid

## Context

**Ticket:** GFIBER-671 (High, To Do, asignado a Alejandro Lembke) — *"Add buttons to move the attendance calendar to the right or to the left (like scrolling)"*. Surgió en el daily del 2026-07-02: *"The scroll bar doesn't get displayed for some users, so we need something else to enable them to navigate the attendance calendar."*

El mismo día se cerró **GFIBER-663** (PR #89 → `dev`), que atacó el síntoma raíz por CSS: en Windows con "Automatically hide scroll bars" (activado por defecto en muchas configuraciones), el scrollbar horizontal del `AttendanceGrid` nunca se renderizaba como barra persistente/arrastrable para usuarios de mouse en desktop. El fix fue `scrollbar-width`/`scrollbar-color` + `::-webkit-scrollbar*` sobre el contenedor `.scroll`, forzando un scrollbar clásico siempre visible.

GFIBER-671 es el fast-follow: incluso con un scrollbar visible, seguir dependiendo *exclusivamente* de arrastrar una barra de 10px es una interacción frágil (precisión de mouse, descubribilidad, accesibilidad motora). El pedido es un control explícito e independiente del navegador — flechas ‹ › que desplazan la grilla — como refuerzo, no como reemplazo del scrollbar ya corregido.

**Resultado buscado:** dos botones flecha (izquierda/derecha) superpuestos sobre el borde del área scrolleable del `AttendanceGrid`, que aparecen solo cuando hay contenido para desplazar, se deshabilitan en los extremos, y desplazan la grilla con un click — sin tocar persistencia, Server Actions, ni el modelo de datos (es una mejora puramente de interacción de UI).

## Investigación previa

Confirmado leyendo el código real (no solo la documentación de Docmost):

- **Componente objetivo:** `packages/design-system/src/components/AttendanceGrid/index.tsx` — component plano `"use client"`, sin refs ni estado hoy (100% driven by props). El wrapper de la app, `components/dashboard/AttendanceGrid.tsx`, **no necesita cambios**: no usa el prop `monthLabel`/`onPrevMonth`/`onNextMonth` del DS (tiene su propio header de navegación de mes fuera de este componente) y solo envuelve el render del DS en un `<div>` para capturar `onPointerDown` del status-picker — no posee ref al contenedor de scroll. Todo el trabajo va **dentro del DS**, consistente con la convención DS-first del proyecto (ningún control nuevo va inline en la app).
- **Contenedor de scroll:** `<div className={styles.scroll} data-scroll>` (index.tsx:125) — ya tiene `position: relative` (style.module.scss:58), heredado del fix de GFIBER-663. Esto es clave: **no hace falta reestructurar CSS** para tener un contexto de posicionamiento — los botones pueden ser `position: absolute` hijos directos de `.scroll`, igual que ya hace `.scrollHint` (style.module.scss:232-240, right-edge fade decorativo). Un elemento `position: absolute` cuyo *containing block* es el propio elemento con `overflow-x: auto` **no se desplaza con el scroll** (se posiciona respecto al padding-box estático del contenedor, no respecto al contenido que se desplaza) — es el mismo mecanismo que ya usa `.scrollHint` para quedar fijo en el borde derecho.
- **Columnas sticky a esquivar:** `.agentCell`/`.agentHead` (ancho 200px, sticky `left:0`) y, si `showSummary` está activo (default `true`), `.summaryCell`/`.summaryHead` (ancho 88px adicional, sticky `left:200px`). El botón **izquierdo no debe ir en `left:0`** (taparía el nombre del agente) — debe ir en el borde donde terminan las columnas congeladas: `left: 200px` (sin summary) o `left: 288px` (con summary). El botón derecho va en `right: 0`, en el mismo slot que hoy ocupa `.scrollHint`.
- **Estilo a reutilizar (decisión confirmada con el usuario):** `.navBtn` (style.module.scss:30-47) — círculo 28px, `‹`/`›` unicode, hover `--fiber-gray-100`, focus-visible `--fiber-blue`. Hoy este estilo solo lo usa el header de mes opcional del propio componente (index.tsx:107-123), que la app no consume — es decir, **existe y está validado visualmente pero no tiene ningún consumidor real todavía**. Reutilizarlo mantiene consistencia visual con la identidad ya definida en Penpot para este componente y cumple el mínimo de WCAG 2.2 SC 2.5.8 (24×24px). Se le suma `box-shadow` + fondo sólido (`--fiber-surface`) para que se lea flotando sobre celdas de cualquier color (weekend, today, status tonos).
- **Sin precedente de teclado Left/Right:** el único roving-focus del repo usa `ArrowUp`/`ArrowDown` (Picker en `components/dashboard/AttendanceGrid.tsx:229-247`, para el menú de status). No hay ningún patrón `ArrowLeft`/`ArrowRight` existente — no es necesario inventarlo para este ticket (los botones son suficientes; el foco natural por Tab ya los hace accesibles por teclado).
- **DataTable/UserTable comparten el mismo `overflow-x:auto` sin botones** — confirmado, fuera de alcance de este ticket (ya está anotado como deuda fast-follow desde GFIBER-663).

## Approach

Todo el cambio vive en `packages/design-system/src/components/AttendanceGrid/`. Component sigue siendo controlado por props para todo lo existente; se le agrega estado *interno* puramente de presentación (no se expone a la app):

1. `useRef<HTMLDivElement>` sobre el `<div className={styles.scroll}>`.
2. `useState` con `{ canLeft: boolean, canRight: boolean }`. Un `useEffect` calcula el estado inicial al montar/cuando cambian `agents`/`days` (el ancho de la tabla puede cambiar de mes a mes) comparando `scrollWidth` vs `clientWidth` del ref; si no hay overflow (`scrollWidth <= clientWidth`), ambos botones se **ocultan por completo** (no solo deshabilitan) — no tiene sentido mostrar controles de scroll cuando no hay nada que desplazar.
3. Listener de `scroll` sobre el propio contenedor (recalcula `canLeft`/`canRight` en cada evento) + `ResizeObserver` sobre el contenedor (recalcula si el usuario resizea la ventana o cambia el layout del dashboard). Ambos se limpian en el cleanup del `useEffect`.
4. Dos `<button>` con la clase `.navBtn` existente (mismo trato visual, con `box-shadow`/fondo agregado para el overlay), `type="button"`, `aria-label="Scroll attendance grid left"` / `"...right"`, `disabled` cuando `!canLeft` / `!canRight`, `data-scroll-left` / `data-scroll-right` para hooks de test (mismo patrón que el `data-scroll`/`data-scroll-hint` ya existentes).
5. `onClick`: `scrollRef.current?.scrollBy({ left: ±Math.round(scrollRef.current.clientWidth * 0.8), behavior })`, con `behavior` = `'auto'` si `window.matchMedia('(prefers-reduced-motion: reduce)').matches`, si no `'smooth'`.
6. El `.scrollHint` decorativo (fade derecho) se **elimina** — el botón funcional en el mismo slot lo vuelve redundante y visualmente competiría con él en el borde derecho. Si en la revisión visual se prefiere mantenerlo detrás del botón, es un ajuste de una línea, pero la recomendación es no duplicar la señal.

## Archivos a modificar

- **`packages/design-system/src/components/AttendanceGrid/index.tsx`** — agregar `useRef`/`useState`/`useEffect` + los dos botones dentro de `.scroll`, después del `<table>` (reemplazando el `<span className={styles.scrollHint}>` actual).
- **`packages/design-system/src/components/AttendanceGrid/style.module.scss`** — nuevas clases `.scrollBtn` (extiende `.navBtn`: `position: absolute`, `top: 50%`, `transform: translateY(-50%)`, `z-index: 4`, `background-color: var(--fiber-surface)`, `border: 1px solid var(--fiber-gray-200)`, `box-shadow: var(--shadow-sm)`), `.scrollBtnLeft { left: 200px }` / `.scrollBtnLeftWithSummary { left: 288px }` (o resolver el offset inline con una custom property, a decidir en implementación) y `.scrollBtnRight { right: 8px }`. Eliminar `.scrollHint`.
- **`packages/design-system/src/components/AttendanceGrid/AttendanceGrid.test.tsx`** (Jest/RTL) — nuevos tests: botones ausentes cuando no hay overflow (mockear `scrollWidth`/`clientWidth` vía `Object.defineProperty` en el elemento, patrón estándar de RTL para jsdom), presentes y con `aria-label` correctos cuando sí hay overflow, click llama a `scrollBy` (mock `Element.prototype.scrollBy`) con el signo correcto, `disabled` refleja `scrollLeft` en los extremos tras disparar el evento `scroll`.
- **`packages/design-system/src/components/AttendanceGrid/AttendanceGrid.test.cy.tsx`** — extender el bloque `describe('AttendanceGrid (component) — scroll & sticky', ...)` (líneas 66-176) ya existente: click en el botón derecho aumenta `scrollLeft` real (layout de navegador real, no mock), click izquierdo lo disminuye, botón derecho se oculta/deshabilita al llegar al final, botón izquierdo al llegar al inicio, los botones permanecen visualmente anclados al hacer scroll (no se desplazan con el contenido), y no tapan el nombre del agente (posición izquierda después de las columnas sticky).
- **`packages/design-system/src/components/AttendanceGrid/AttendanceGrid.stories.tsx`** — story adicional con `agents`/`days` suficientes para forzar overflow en un viewport angosto, para revisión visual en Storybook antes de mergear.

No se toca `components/dashboard/AttendanceGrid.tsx` (wrapper de la app), ni Server Actions, ni el esquema de Supabase — cambio 100% contenido en el design system, sin persistencia involucrada.

## Ejecución

Este trabajo se despacha con la skill global **`background-orchestrator`** (no se implementa en esta sesión interactiva). Siguiendo el mismo patrón ya usado para el ticket hermano GFIBER-663 (`agente-gfiber-663-attendance-scrollbar`, registrado en la página Docmost *Agentes_Activos*):

1. **Agente autónomo en tmux**, nombre sugerido `agente-gfiber-671-attendance-scroll-buttons`, corriendo en su propio git worktree bajo `.worktrees/` sobre una rama dedicada (`feat/gfiber-671-attendance-scroll-buttons`, branch desde `dev` — mismo target que GFIBER-663).
2. **TDD estricto** (rojo→verde): el agente escribe primero los tests de Jest/RTL y Cypress descritos en la sección de Verificación antes de tocar `index.tsx`/`style.module.scss`.
3. **Nunca mergea** — al terminar, abre PR hacia `dev` vía la skill `aleleba-pr` (output en inglés, sin `Co-Authored-By` ni trailers de atribución, por convención del repo).
4. **Se documenta en Docmost**: entrada nueva en *Agentes_Activos* (historial) y actualización de la subpágina *Grid_de_Asistencia_(Attendance)* con el resultado, igual que se hizo para GFIBER-663.
5. El orquestador (esta sesión) monitorea el progreso del agente, responde preguntas si el agente se bloquea, y valida/archiva el resultado al finalizar (`/aprobar agente-gfiber-671-attendance-scroll-buttons`) — sin mergear directamente.

Este plan (el archivo completo hasta aquí) es el brief que se le entrega al agente en background como contexto de arranque.

## Verificación

1. `npm run test` (Vitest, app) — debe seguir en verde, sin tocar comportamiento del wrapper de la app.
2. Jest del design-system (`npm run test --workspace=@gfiber/design-system` o el script equivalente) — nuevos tests de botones en verde.
3. Cypress component tests del DS — nuevas aserciones de scroll real (`scrollLeft`, `disabled`, posición fija) en verde.
4. Revisión visual en Storybook: confirmar que el botón izquierdo no se superpone al nombre del agente ni al header sticky, y que el botón derecho no queda apretado contra el borde de la tarjeta.
5. Prueba manual en DEV con un roster que exceda el ancho del viewport (mes completo, varios agentes): click en ambas flechas desplaza la grilla, se deshabilitan/ocultan en los extremos, funcionan con teclado (Tab + Enter/Space) y son anunciados correctamente por lector de pantalla (`aria-label`).
6. `npm run lint` + `tsc` limpios.
