chore: auto-commit (57 archivos)

- frontend/functions/core/format_datetime_short.md
- frontend/functions/core/format_datetime_short.test.ts
- frontend/functions/core/format_datetime_short.ts
- frontend/functions/core/format_duration.md
- frontend/functions/core/format_duration.test.ts
- frontend/functions/core/format_duration.ts
- frontend/functions/core/month_grid.md
- frontend/functions/core/month_grid.test.ts
- frontend/functions/core/month_grid.ts
- frontend/functions/core/string_hash_palette.md
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 03:41:58 +02:00
parent cd50e790ca
commit 03568c88e3
58 changed files with 2923 additions and 0 deletions
@@ -0,0 +1,44 @@
---
name: format_datetime_short
kind: function
lang: ts
domain: core
version: "1.0.0"
purity: pure
signature: "formatDateTimeShort(iso: string): string"
description: "Formatea un string ISO datetime a formato corto \"dd/mm/yy hh:mm\". ISO invalido retorna string vacio."
tags: [format, datetime, date, display, utility]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: iso
desc: "String ISO 8601 con fecha y hora (ej: \"2026-05-09T14:30:00.000Z\" o \"2026-05-09T14:30:00\"). Cualquier string que `new Date()` no pueda parsear retorna \"\"."
output: "String con formato \"dd/mm/yy hh:mm\" usando la hora local del entorno. String vacio si el ISO es invalido."
tested: true
tests:
- "ISO valido retorna dd/mm/yy hh:mm"
- "string vacio retorna vacio"
- "ISO invalido retorna vacio"
- "padding de dia y mes con cero"
test_file_path: "frontend/functions/core/format_datetime_short.test.ts"
file_path: "frontend/functions/core/format_datetime_short.ts"
---
## Ejemplo
```typescript
formatDateTimeShort("2026-05-09T14:30:00") // "09/05/26 14:30"
formatDateTimeShort("2026-01-01T00:00:00") // "01/01/26 00:00"
formatDateTimeShort("not-a-date") // ""
formatDateTimeShort("") // ""
```
## Notas
Extraida de `apps/kanban/frontend/src/components/format.ts` lineas 32-41.
Usa `Date` nativo del entorno. La hora mostrada es **hora local** del navegador/runtime (getHours, getMinutes).
El ano se trunca a 2 digitos (slice(-2)), correcto para fechas 2000-2099.
@@ -0,0 +1,28 @@
import { describe, it, expect } from "vitest";
import { formatDateTimeShort } from "./format_datetime_short";
// Nota: la funcion usa hora local del entorno (getHours/getMinutes).
// Los tests usan fechas con offset conocido o verifican el patron de formato.
describe("formatDateTimeShort", () => {
it("string vacio retorna vacio", () => {
expect(formatDateTimeShort("")).toBe("");
});
it("ISO invalido retorna vacio", () => {
expect(formatDateTimeShort("not-a-date")).toBe("");
expect(formatDateTimeShort("2026-13-01")).toBe("");
});
it("ISO valido retorna dd/mm/yy hh:mm", () => {
// Verificamos el patron sin asumir timezone: resultado debe tener forma xx/xx/xx xx:xx
const result = formatDateTimeShort("2026-05-09T14:30:00");
expect(result).toMatch(/^\d{2}\/\d{2}\/\d{2} \d{2}:\d{2}$/);
});
it("padding de dia y mes con cero", () => {
// Dia 1, mes enero: debe ser "01/01/..."
const result = formatDateTimeShort("2026-01-01T00:00:00");
expect(result).toMatch(/^01\/01\//);
});
});
@@ -0,0 +1,14 @@
/**
* Formatea un string ISO a "dd/mm/yy hh:mm".
* Retorna "" si el ISO es invalido o no parseable.
*/
export function formatDateTimeShort(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yy = String(d.getFullYear()).slice(-2);
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}/${mm}/${yy} ${hh}:${mi}`;
}
@@ -0,0 +1,59 @@
---
name: format_duration
kind: function
lang: ts
domain: core
version: "1.0.0"
purity: pure
signature: "formatDuration(ms: number): string"
description: "Formatea una duracion en milisegundos a string humano escalado. Escala: m | h Xm | D Xh | S XD | M XS. NaN, Infinity o negativo retornan \"0m\"."
tags: [format, duration, time, display, utility]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: ms
desc: "Duracion en milisegundos. Debe ser un numero finito no negativo. NaN, Infinity o negativos producen \"0m\"."
output: "String humano escalado segun magnitud: minutos (Xm), horas+minutos (Xh Ym), dias+horas (XD Yh), semanas+dias (XS YD), meses+semanas (XM YS). Menor de un minuto devuelve \"0m\"."
tested: true
tests:
- "menos de un minuto retorna 0m"
- "exactamente 1 hora retorna 1h"
- "1h 30m retorna 1h 30m"
- "1 dia sin horas retorna 1D"
- "2 dias 3 horas retorna 2D 3h"
- "1 semana sin dias retorna 1S"
- "2 semanas 3 dias retorna 2S 3D"
- "1 mes sin semanas retorna 1M"
- "2 meses 1 semana retorna 2M 1S"
- "NaN retorna 0m"
- "negativo retorna 0m"
- "Infinity retorna 0m"
test_file_path: "frontend/functions/core/format_duration.test.ts"
file_path: "frontend/functions/core/format_duration.ts"
---
## Ejemplo
```typescript
formatDuration(0) // "0m"
formatDuration(30_000) // "0m" (30s < 1min)
formatDuration(90_000) // "1m"
formatDuration(3_600_000) // "1h"
formatDuration(5_400_000) // "1h 30m"
formatDuration(86_400_000) // "1D"
formatDuration(97_200_000) // "1D 3h"
formatDuration(604_800_000) // "1S"
formatDuration(2_592_000_000) // "1M"
formatDuration(NaN) // "0m"
formatDuration(-1) // "0m"
```
## Notas
Extraida de `apps/kanban/frontend/src/components/format.ts` lineas 9-30.
Funcion pura sin dependencias externas. Usa constantes locales privadas (MIN, HOUR, DAY, WEEK, MONTH).
Escala en ingles/abreviada: m=minutos, h=horas, D=dias, S=semanas, M=meses.
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { formatDuration } from "./format_duration";
const MIN = 60_000;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
const MONTH = 30 * DAY;
describe("formatDuration", () => {
it("menos de un minuto retorna 0m", () => {
expect(formatDuration(0)).toBe("0m");
expect(formatDuration(30_000)).toBe("0m");
expect(formatDuration(59_999)).toBe("0m");
});
it("exactamente 1 hora retorna 1h", () => {
expect(formatDuration(HOUR)).toBe("1h");
});
it("1h 30m retorna 1h 30m", () => {
expect(formatDuration(HOUR + 30 * MIN)).toBe("1h 30m");
});
it("1 dia sin horas retorna 1D", () => {
expect(formatDuration(DAY)).toBe("1D");
});
it("2 dias 3 horas retorna 2D 3h", () => {
expect(formatDuration(2 * DAY + 3 * HOUR)).toBe("2D 3h");
});
it("1 semana sin dias retorna 1S", () => {
expect(formatDuration(WEEK)).toBe("1S");
});
it("2 semanas 3 dias retorna 2S 3D", () => {
expect(formatDuration(2 * WEEK + 3 * DAY)).toBe("2S 3D");
});
it("1 mes sin semanas retorna 1M", () => {
expect(formatDuration(MONTH)).toBe("1M");
});
it("2 meses 1 semana retorna 2M 1S", () => {
expect(formatDuration(2 * MONTH + WEEK)).toBe("2M 1S");
});
it("NaN retorna 0m", () => {
expect(formatDuration(NaN)).toBe("0m");
});
it("negativo retorna 0m", () => {
expect(formatDuration(-1)).toBe("0m");
expect(formatDuration(-999)).toBe("0m");
});
it("Infinity retorna 0m", () => {
expect(formatDuration(Infinity)).toBe("0m");
});
});
@@ -0,0 +1,36 @@
// Escala unidades segun magnitud: m | h Xm | D Xh | S XD | M XS.
// <1 minuto cae como "0m" para mantener la unidad mas pequena coherente.
const MIN = 60_000;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
const MONTH = 30 * DAY;
/**
* Formatea una duracion en milisegundos a string humano escalado.
*
* Escala: m | h Xm | D Xh | S XD | M XS.
* NaN, Infinity o negativo retornan "0m".
*/
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return "0m";
if (ms < HOUR) return `${Math.floor(ms / MIN)}m`;
if (ms < DAY) {
const h = Math.floor(ms / HOUR);
const m = Math.floor((ms % HOUR) / MIN);
return m === 0 ? `${h}h` : `${h}h ${m}m`;
}
if (ms < WEEK) {
const d = Math.floor(ms / DAY);
const h = Math.floor((ms % DAY) / HOUR);
return h === 0 ? `${d}D` : `${d}D ${h}h`;
}
if (ms < MONTH) {
const w = Math.floor(ms / WEEK);
const d = Math.floor((ms % WEEK) / DAY);
return d === 0 ? `${w}S` : `${w}S ${d}D`;
}
const m = Math.floor(ms / MONTH);
const w = Math.floor((ms % MONTH) / WEEK);
return w === 0 ? `${m}M` : `${m}M ${w}S`;
}
+54
View File
@@ -0,0 +1,54 @@
---
name: month_grid
kind: function
lang: ts
domain: core
version: "1.0.0"
purity: pure
signature: "monthGrid(year: number, month: number): { date: string | null; inMonth: boolean }[]"
description: "Genera la cuadricula mensual con inicio en lunes (Mon-first ISO). Retorna array de celdas multiplo de 7 con padding nulo al inicio y final. Util para calendarios y vistas de mes."
tags: [calendar, date, grid, month, utility]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: year
desc: "Ano completo del mes a generar (ej: 2026). Rango valido: cualquier ano soportado por Date nativo."
- name: month
desc: "Mes 1-12 donde 1=Enero y 12=Diciembre."
output: "Array de MonthGridCell ({ date: string|null; inMonth: boolean }) con longitud siempre multiplo de 7. Celdas de padding tienen date=null e inMonth=false. Celdas del mes tienen date en formato YYYY-MM-DD e inMonth=true."
tested: true
tests:
- "enero 2026 empieza en jueves (dow=3)"
- "febrero 2024 bisiesto tiene 29 dias"
- "longitud siempre multiplo de 7"
- "primer dia inMonth es el primero del mes"
- "ultimo dia inMonth es el ultimo del mes"
- "celdas de padding tienen date null"
- "mes que empieza en lunes no tiene padding inicial"
test_file_path: "frontend/functions/core/month_grid.test.ts"
file_path: "frontend/functions/core/month_grid.ts"
---
## Ejemplo
```typescript
const cells = monthGrid(2026, 5); // Mayo 2026 (1 mayo = viernes => firstDow=4)
// cells.length es multiplo de 7 (ej: 35)
// cells[0..3] => { date: null, inMonth: false } (padding lun-jue)
// cells[4] => { date: "2026-05-01", inMonth: true }
// cells[34] => { date: "2026-05-31", inMonth: true }
monthGrid(2026, 1); // Enero 2026: jueves=dow 3, 3 celdas padding inicial
```
## Notas
Extraida de `apps/kanban/frontend/src/components/CalendarView.tsx` lineas 72-84.
Reemplaza la dependencia de `dayjs` por `Date` nativo para mantener la funcion sin dependencias externas.
Algoritmo: `firstDow = (new Date(year, month-1, 1).getDay() + 6) % 7` convierte domingo=0 de JS a lunes=0 ISO.
`new Date(year, month, 0).getDate()` obtiene el ultimo dia del mes (dia 0 del mes siguiente = ultimo del actual).
Exporta tambien el tipo `MonthGridCell` para uso con TypeScript estricto.
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { monthGrid } from "./month_grid";
describe("monthGrid", () => {
it("longitud siempre multiplo de 7", () => {
for (let m = 1; m <= 12; m++) {
expect(monthGrid(2026, m).length % 7).toBe(0);
}
});
it("enero 2026 empieza en jueves (dow=3)", () => {
// 1 enero 2026 es jueves. Mon-first => dow=3 (0=lun,1=mar,2=mie,3=jue)
const cells = monthGrid(2026, 1);
expect(cells[0].date).toBeNull();
expect(cells[1].date).toBeNull();
expect(cells[2].date).toBeNull();
expect(cells[3].date).toBe("2026-01-01");
expect(cells[3].inMonth).toBe(true);
});
it("febrero 2024 bisiesto tiene 29 dias", () => {
const cells = monthGrid(2024, 2);
const inMonth = cells.filter((c) => c.inMonth);
expect(inMonth).toHaveLength(29);
expect(inMonth[28].date).toBe("2024-02-29");
});
it("primer dia inMonth es el primero del mes", () => {
const cells = monthGrid(2026, 5);
const first = cells.find((c) => c.inMonth);
expect(first?.date).toBe("2026-05-01");
});
it("ultimo dia inMonth es el ultimo del mes", () => {
const cells = monthGrid(2026, 5); // mayo tiene 31 dias
const last = [...cells].reverse().find((c) => c.inMonth);
expect(last?.date).toBe("2026-05-31");
});
it("celdas de padding tienen date null", () => {
const cells = monthGrid(2026, 1);
const padding = cells.filter((c) => !c.inMonth);
for (const p of padding) {
expect(p.date).toBeNull();
}
});
it("mes que empieza en lunes no tiene padding inicial", () => {
// Buscamos un mes que empiece en lunes. Enero 2024: lunes.
// 1 enero 2024 es lunes => firstDow=0 => no padding inicial
const cells = monthGrid(2024, 1);
expect(cells[0].date).toBe("2024-01-01");
expect(cells[0].inMonth).toBe(true);
});
});
+40
View File
@@ -0,0 +1,40 @@
export interface MonthGridCell {
date: string | null;
inMonth: boolean;
}
/**
* Genera la cuadricula mensual con inicio en lunes (ISO week).
*
* @param year Año completo (ej: 2026).
* @param month Mes 1-12 (Enero = 1).
* @returns Array de celdas con longitud multiplo de 7.
* Celdas de padding tienen date=null e inMonth=false.
* Celdas del mes tienen date="YYYY-MM-DD" e inMonth=true.
*/
export function monthGrid(year: number, month: number): MonthGridCell[] {
// getDay() devuelve 0=Dom..6=Sab. Convertir a 0=Lun..6=Dom.
const firstDow = (new Date(year, month - 1, 1).getDay() + 6) % 7;
const lastDay = new Date(year, month, 0).getDate();
const cells: MonthGridCell[] = [];
// Padding inicial
for (let i = 0; i < firstDow; i++) {
cells.push({ date: null, inMonth: false });
}
// Dias del mes
for (let d = 1; d <= lastDay; d++) {
const mm = String(month).padStart(2, "0");
const dd = String(d).padStart(2, "0");
cells.push({ date: `${year}-${mm}-${dd}`, inMonth: true });
}
// Padding final hasta multiplo de 7
while (cells.length % 7 !== 0) {
cells.push({ date: null, inMonth: false });
}
return cells;
}
@@ -0,0 +1,51 @@
---
name: string_hash_palette
kind: function
lang: ts
domain: core
version: "1.0.0"
purity: pure
signature: "stringHashPalette(s: string, palette: string[]): string"
description: "Mapea un string a un elemento de la paleta usando hash DJB-31 deterministico (h = (h*31 + charCodeAt) >>> 0). Util para asignar colores consistentes a tags, usuarios u otros identificadores de texto."
tags: [hash, color, palette, string, deterministic, utility]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: s
desc: "String de entrada a hashear. Cualquier string Unicode. String vacio produce h=0, retorna palette[0]."
- name: palette
desc: "Array de strings (colores u otros valores) a usar como paleta. No debe estar vacio (lanza Error si lo esta)."
output: "Un elemento de palette seleccionado deterministicamente por h % palette.length. El mismo string siempre retorna el mismo elemento para la misma paleta."
tested: true
tests:
- "string vacio retorna palette[0]"
- "mismo string siempre retorna mismo color"
- "strings distintos pueden dar colores distintos"
- "paleta vacia lanza error"
- "paleta de un elemento siempre retorna ese elemento"
test_file_path: "frontend/functions/core/string_hash_palette.test.ts"
file_path: "frontend/functions/core/string_hash_palette.ts"
---
## Ejemplo
```typescript
const TAG_PALETTE = ["blue", "cyan", "teal", "green", "lime", "yellow", "orange", "red", "pink", "grape", "violet", "indigo"];
stringHashPalette("frontend", TAG_PALETTE) // deterministico, ej: "teal"
stringHashPalette("backend", TAG_PALETTE) // deterministico, diferente
stringHashPalette("frontend", TAG_PALETTE) // mismo resultado siempre
stringHashPalette("x", []) // throws Error("palette must not be empty")
```
## Notas
Extraida de `apps/kanban/frontend/src/components/colors.ts` lineas 70-76 (funcion `tagColor`).
Generalizada para aceptar cualquier paleta en vez de la constante `TAG_PALETTE` hardcodeada.
El hash DJB-31 (variante de DJB2 con multiplicador 31) produce distribucion uniforme suficiente para asignacion de colores. No es criptografico.
El operador `>>> 0` garantiza entero de 32 bits sin signo, evitando overflow en strings largos.
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { stringHashPalette } from "./string_hash_palette";
const PALETTE = ["blue", "cyan", "teal", "green", "lime", "yellow", "orange", "red", "pink", "grape", "violet", "indigo"];
describe("stringHashPalette", () => {
it("string vacio retorna palette[0]", () => {
// h empieza en 0, loop no itera, h%n = 0 => palette[0]
expect(stringHashPalette("", PALETTE)).toBe(PALETTE[0]);
});
it("mismo string siempre retorna mismo color", () => {
const a = stringHashPalette("frontend", PALETTE);
const b = stringHashPalette("frontend", PALETTE);
expect(a).toBe(b);
});
it("strings distintos pueden dar colores distintos", () => {
const colors = new Set(["alpha", "beta", "gamma", "delta", "epsilon"].map((s) => stringHashPalette(s, PALETTE)));
// Con 5 strings distintos y paleta de 12 es muy probable obtener al menos 2 distintos
expect(colors.size).toBeGreaterThan(1);
});
it("paleta vacia lanza error", () => {
expect(() => stringHashPalette("hello", [])).toThrow("palette must not be empty");
});
it("paleta de un elemento siempre retorna ese elemento", () => {
expect(stringHashPalette("anything", ["only"])).toBe("only");
expect(stringHashPalette("", ["only"])).toBe("only");
expect(stringHashPalette("xyz", ["only"])).toBe("only");
});
});
@@ -0,0 +1,16 @@
/**
* Mapea un string a un color de la paleta usando hash DJB-31 deterministico.
*
* Hash: h = (h * 31 + charCodeAt(i)) >>> 0 (unsigned 32-bit).
* El indice es h % palette.length.
*
* @throws Error si palette esta vacio.
*/
export function stringHashPalette(s: string, palette: string[]): string {
if (palette.length === 0) throw new Error("palette must not be empty");
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) >>> 0;
}
return palette[h % palette.length];
}
+69
View File
@@ -0,0 +1,69 @@
---
name: fetch_json
kind: function
lang: ts
domain: infra
version: "1.0.0"
purity: impure
signature: "async function fetchJSON<T>(path: string, init?: RequestInit, baseUrl?: string): Promise<T>"
description: "Wrapper de fetch que parsea JSON, lanza HTTPError en errores HTTP y retorna undefined en 204. Exporta la clase HTTPError con el status code de la respuesta fallida."
tags: [http, fetch, json, rest, api, error, httperror, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: []
params:
- name: path
desc: "Path relativo a baseUrl (ej: /api/users). Se concatena directamente: ${baseUrl ?? ''}${path}"
- name: init
desc: "RequestInit de fetch (method, body, headers, etc.). headers se mergea con el default Content-Type: application/json. credentials se puede sobreescribir (default: include)"
- name: baseUrl
desc: "URL base sin slash final (ej: https://api.example.com). Opcional, default string vacio"
output: "Promise<T> con el JSON parseado. Lanza HTTPError si !res.ok. Retorna undefined as T si status 204."
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/infra/fetch_json.ts"
---
## Ejemplo
```typescript
import { fetchJSON, HTTPError } from "@fn_library/../infra/fetch_json";
// GET con base URL
const users = await fetchJSON<User[]>("/users", undefined, "https://api.example.com");
// POST con body
const card = await fetchJSON<Card>(
"/cards",
{ method: "POST", body: JSON.stringify({ title: "Nueva tarea" }) },
"/api",
);
// Capturar HTTPError
try {
await fetchJSON("/protected");
} catch (err) {
if (err instanceof HTTPError) {
console.error(`HTTP ${err.status}: ${err.message}`);
}
}
```
## Comportamiento detallado
- **URL**: `${baseUrl ?? ""}${path}` — si baseUrl es undefined o vacío, path se usa tal cual.
- **Headers**: `{ "Content-Type": "application/json" }` como base, mergeados con `init?.headers`. Los headers de init tienen precedencia.
- **credentials**: `"include"` por defecto (envía cookies). Se puede sobreescribir pasando `credentials: "omit"` en init.
- **Error HTTP** (`!res.ok`): intenta `res.json()` para leer el cuerpo del error. Si el parse falla (body vacío, no-JSON), usa `{ Message: res.statusText }`. Construye `HTTPError(status, err.Message ?? err.message ?? res.statusText)`.
- **204 No Content**: retorna `undefined as T` sin intentar parsear el body (que está vacío).
- **Errores de red**: no se capturan — el rechazo de la Promise nativa de `fetch` se propaga tal cual.
## Notas
Extraído de `apps/kanban/frontend/src/api.ts` (líneas 1432) y generalizado añadiendo el parámetro `baseUrl` opcional para reutilización fuera del contexto kanban.
`HTTPError` se exporta para que el consumidor pueda hacer `instanceof HTTPError` en sus catch.
+44
View File
@@ -0,0 +1,44 @@
/**
* HTTPError — error HTTP con status code.
* Lanzado por fetchJSON cuando la respuesta no es ok.
*/
export class HTTPError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = "HTTPError";
}
}
/**
* fetchJSON — wrapper de fetch que parsea JSON, lanza HTTPError en errores HTTP
* y retorna undefined en 204 No Content.
*
* URL final: `${baseUrl ?? ""}${path}`.
* Headers default: `{ "Content-Type": "application/json" }`, mergeables via init.headers.
* credentials: "include" por defecto, sobreescribible via init.
*/
export async function fetchJSON<T>(
path: string,
init?: RequestInit,
baseUrl?: string,
): Promise<T> {
const res = await fetch(`${baseUrl ?? ""}${path}`, {
credentials: "include",
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ Message: res.statusText }));
throw new HTTPError(
res.status,
err.Message ?? err.message ?? res.statusText,
);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
@@ -0,0 +1,15 @@
/**
* Helpers privados compartidos por color_bg, color_border y color_swatch.
* No exportar desde index.ts ni @fn_library directamente.
*/
/** Tokens de color nombrados de Mantine que tienen escala de tonos (ej: --mantine-color-blue-9). */
export const MANTINE_TOKENS = new Set([
"blue", "cyan", "teal", "green", "lime", "yellow", "orange",
"red", "pink", "grape", "violet", "indigo", "gray", "dark",
]);
/** Retorna true si el string es un hex color valido (#RGB o #RRGGBB). */
export function isHex(v: string): boolean {
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v);
}
+46
View File
@@ -0,0 +1,46 @@
---
name: color_bg
kind: function
lang: ts
domain: ui
version: "1.0.0"
purity: pure
signature: "colorBg(color: string): string"
description: "Genera el valor CSS color-mix para el fondo de un elemento con color Mantine. Sin color retorna dark-6. Hex: mezcla 18% con dark-6. Token Mantine (blue, red, ...): mezcla tono -9 al 18% con dark-6."
tags: [color, mantine, css, background, ui, theme]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: color
desc: "Color de entrada. Puede ser: string vacio (sin color), hex valido (#RGB o #RRGGBB), o token de color Mantine (\"blue\", \"red\", \"teal\", etc.). Valores no reconocidos retornan el fondo base."
output: "String CSS listo para usar en style.background o style.backgroundColor. Produce un valor color-mix() o una CSS variable Mantine."
tested: true
tests:
- "string vacio retorna dark-6"
- "hex valido produce color-mix con 18 porciento"
- "token mantine produce color-mix con tono -9"
- "valor desconocido retorna dark-6"
- "hex de 3 digitos es valido"
test_file_path: "frontend/functions/ui/color_bg.test.ts"
file_path: "frontend/functions/ui/color_bg.ts"
---
## Ejemplo
```typescript
colorBg("") // "var(--mantine-color-dark-6)"
colorBg("#0ea5e9") // "color-mix(in srgb, #0ea5e9 18%, var(--mantine-color-dark-6))"
colorBg("blue") // "color-mix(in srgb, var(--mantine-color-blue-9) 18%, var(--mantine-color-dark-6))"
colorBg("unknown") // "var(--mantine-color-dark-6)"
```
## Notas
Extraida de `apps/kanban/frontend/src/components/colors.ts` lineas 46-51.
Junto con `color_border` y `color_swatch` forma el trio de helpers de color para tarjetas Mantine.
**Decision de helpers compartidos:** `MANTINE_TOKENS` e `isHex` se comparten via `_mantine_color_helpers.ts` en el mismo directorio `ui/`. Alternativa descartada: duplicar en cada archivo — genera drift si el set de tokens cambia (ya ocurrio en kanban al agregar "dark"). El prefijo underscore indica que el archivo es privado del directorio y no debe reexportarse desde `index.ts`.
+34
View File
@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { colorBg } from "./color_bg";
describe("colorBg", () => {
it("string vacio retorna dark-6", () => {
expect(colorBg("")).toBe("var(--mantine-color-dark-6)");
});
it("hex valido produce color-mix con 18 porciento", () => {
expect(colorBg("#0ea5e9")).toBe(
"color-mix(in srgb, #0ea5e9 18%, var(--mantine-color-dark-6))"
);
});
it("hex de 3 digitos es valido", () => {
expect(colorBg("#0ea")).toBe(
"color-mix(in srgb, #0ea 18%, var(--mantine-color-dark-6))"
);
});
it("token mantine produce color-mix con tono -9", () => {
expect(colorBg("blue")).toBe(
"color-mix(in srgb, var(--mantine-color-blue-9) 18%, var(--mantine-color-dark-6))"
);
expect(colorBg("red")).toBe(
"color-mix(in srgb, var(--mantine-color-red-9) 18%, var(--mantine-color-dark-6))"
);
});
it("valor desconocido retorna dark-6", () => {
expect(colorBg("unknown")).toBe("var(--mantine-color-dark-6)");
expect(colorBg("sky-blue")).toBe("var(--mantine-color-dark-6)");
});
});
+16
View File
@@ -0,0 +1,16 @@
import { MANTINE_TOKENS, isHex } from "./_mantine_color_helpers";
/**
* Genera el valor CSS para el fondo de un elemento con color de tarjeta Mantine.
*
* - Sin color (""): fondo base dark-6.
* - Hex (#rgb/#rrggbb): mezcla 18% del hex con dark-6.
* - Token Mantine (blue, red, ...): mezcla 18% del tono -9 con dark-6.
* - Valor desconocido: fondo base dark-6.
*/
export function colorBg(color: string): string {
if (!color) return "var(--mantine-color-dark-6)";
if (isHex(color)) return `color-mix(in srgb, ${color} 18%, var(--mantine-color-dark-6))`;
if (MANTINE_TOKENS.has(color)) return `color-mix(in srgb, var(--mantine-color-${color}-9) 18%, var(--mantine-color-dark-6))`;
return "var(--mantine-color-dark-6)";
}
+45
View File
@@ -0,0 +1,45 @@
---
name: color_border
kind: function
lang: ts
domain: ui
version: "1.0.0"
purity: pure
signature: "colorBorder(color: string): string"
description: "Genera el valor CSS color-mix para el borde de un elemento con color Mantine. Sin color retorna dark-4. Hex: mezcla 30% con dark-4. Token Mantine: mezcla tono -7 al 30% con dark-4."
tags: [color, mantine, css, border, ui, theme]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: color
desc: "Color de entrada. Puede ser: string vacio (sin color), hex valido (#RGB o #RRGGBB), o token de color Mantine (\"blue\", \"red\", \"teal\", etc.). Valores no reconocidos retornan el borde base."
output: "String CSS listo para usar en style.borderColor o style.border. Produce un valor color-mix() o una CSS variable Mantine."
tested: true
tests:
- "string vacio retorna dark-4"
- "hex valido produce color-mix con 30 porciento"
- "token mantine produce color-mix con tono -7"
- "valor desconocido retorna dark-4"
test_file_path: "frontend/functions/ui/color_border.test.ts"
file_path: "frontend/functions/ui/color_border.ts"
---
## Ejemplo
```typescript
colorBorder("") // "var(--mantine-color-dark-4)"
colorBorder("#0ea5e9") // "color-mix(in srgb, #0ea5e9 30%, var(--mantine-color-dark-4))"
colorBorder("blue") // "color-mix(in srgb, var(--mantine-color-blue-7) 30%, var(--mantine-color-dark-4))"
colorBorder("unknown") // "var(--mantine-color-dark-4)"
```
## Notas
Extraida de `apps/kanban/frontend/src/components/colors.ts` lineas 53-58.
Diferencia clave respecto a `color_bg`: usa tono `-7` (no `-9`) y mezcla al 30% (no 18%) para conseguir un borde mas visible que el fondo.
**Decision de helpers compartidos:** ver `color_bg.md`. Misma logica: `_mantine_color_helpers.ts` compartido.
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { colorBorder } from "./color_border";
describe("colorBorder", () => {
it("string vacio retorna dark-4", () => {
expect(colorBorder("")).toBe("var(--mantine-color-dark-4)");
});
it("hex valido produce color-mix con 30 porciento", () => {
expect(colorBorder("#0ea5e9")).toBe(
"color-mix(in srgb, #0ea5e9 30%, var(--mantine-color-dark-4))"
);
});
it("token mantine produce color-mix con tono -7", () => {
expect(colorBorder("blue")).toBe(
"color-mix(in srgb, var(--mantine-color-blue-7) 30%, var(--mantine-color-dark-4))"
);
expect(colorBorder("teal")).toBe(
"color-mix(in srgb, var(--mantine-color-teal-7) 30%, var(--mantine-color-dark-4))"
);
});
it("valor desconocido retorna dark-4", () => {
expect(colorBorder("unknown")).toBe("var(--mantine-color-dark-4)");
});
});
+16
View File
@@ -0,0 +1,16 @@
import { MANTINE_TOKENS, isHex } from "./_mantine_color_helpers";
/**
* Genera el valor CSS para el borde de un elemento con color de tarjeta Mantine.
*
* - Sin color (""): borde base dark-4.
* - Hex (#rgb/#rrggbb): mezcla 30% del hex con dark-4.
* - Token Mantine (blue, red, ...): mezcla 30% del tono -7 con dark-4.
* - Valor desconocido: borde base dark-4.
*/
export function colorBorder(color: string): string {
if (!color) return "var(--mantine-color-dark-4)";
if (isHex(color)) return `color-mix(in srgb, ${color} 30%, var(--mantine-color-dark-4))`;
if (MANTINE_TOKENS.has(color)) return `color-mix(in srgb, var(--mantine-color-${color}-7) 30%, var(--mantine-color-dark-4))`;
return "var(--mantine-color-dark-4)";
}
+110
View File
@@ -0,0 +1,110 @@
---
name: color_picker_grid
kind: component
lang: ts
domain: ui
version: "1.0.0"
purity: impure
framework: react
signature: "ColorPickerGrid(props: ColorPickerGridProps): JSX.Element"
description: "Grid de swatches de color con boton extra que abre un modal con ColorPicker de Mantine para seleccionar un hexadecimal libre. Soporta tokens Mantine y valores hex. El swatch activo recibe borde blanco + box-shadow azul; el custom-active aplica el mismo feedback visual."
tags: [color, picker, swatch, grid, modal, mantine, ui]
uses_functions:
- color_swatch_ts_ui
- color_border_ts_ui
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports:
- "@mantine/core"
- "@tabler/icons-react"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/color_picker_grid.tsx"
has_state: true
props:
- name: value
type: "string"
required: true
description: "Color seleccionado actualmente. Token Mantine (blue, red, ...) o hex (#rrggbb)."
- name: onChange
type: "(color: string) => void"
required: true
description: "Callback invocado al seleccionar cualquier swatch o cambiar el color en el picker libre."
- name: options
type: "{ value: string; label: string }[]"
required: false
description: "Lista de opciones a mostrar como swatches circulares. Default: DEFAULT_COLOR_OPTIONS (27 entradas)."
- name: swatchSize
type: "number"
required: false
description: "Diametro de cada swatch en px. Default: 26."
- name: customSwatches
type: "string[]"
required: false
description: "Array de hex para los swatches rapidos dentro del ColorPicker de Mantine. Default: 14 colores."
- name: modalTitle
type: "string"
required: false
description: "Titulo del modal que contiene el ColorPicker libre. Default: 'Color personalizado'."
emits:
- onChange
params:
- name: value
desc: "Color activo. Token Mantine o hex."
- name: onChange
desc: "Recibe el nuevo color cada vez que el usuario selecciona un swatch o mueve el ColorPicker."
- name: options
desc: "Paleta de swatches a renderizar. Omitir para usar DEFAULT_COLOR_OPTIONS."
- name: swatchSize
desc: "Tamaño del circulo en pixeles."
- name: customSwatches
desc: "Hexadecimales de acceso rapido dentro del picker modal."
- name: modalTitle
desc: "Titulo que aparece en la cabecera del modal del picker."
output: "Grupo horizontal de swatches circulares mas boton '+' (IconPalette). Click en swatch llama onChange con el token/hex. Click '+' abre Modal interno con ColorPicker de Mantine; onChange se dispara en cada cambio del picker. Exporta DEFAULT_COLOR_OPTIONS con 27 entradas (tokens Mantine + hexadecimales de acento)."
---
## Ejemplo
```tsx
import { ColorPickerGrid, DEFAULT_COLOR_OPTIONS } from "@fn_library/color_picker_grid";
import { useState } from "react";
function Demo() {
const [color, setColor] = useState("blue");
return (
<ColorPickerGrid
value={color}
onChange={setColor}
/>
);
}
```
Con opciones personalizadas y swatch reducido:
```tsx
<ColorPickerGrid
value={color}
onChange={setColor}
options={[
{ value: "red", label: "Rojo" },
{ value: "blue", label: "Azul" },
{ value: "#ff6600", label: "Naranja custom" },
]}
swatchSize={32}
modalTitle="Elige un color"
/>
```
## Notas
- `DEFAULT_COLOR_OPTIONS` se exporta desde el mismo archivo para que los consumidores puedan extenderla o filtrarla.
- El estado del color custom (hex libre) es local al componente. Si el valor inicial es hex, se inicializa con el; si no, arranca en `#888888`.
- La deteccion de "custom activo" usa `value.startsWith("#") && !options.some(o => o.value === value)` — un hex que coincide con una opcion de la paleta se trata como swatch fijo, no como custom.
- `colorSwatch` y `colorBorder` del registry resuelven tokens Mantine y hex a valores CSS listos para `background` y `border`.
- El Modal usa `withinPortal`, `zIndex: 2000`, `closeOnClickOutside: false`, `closeOnEscape: false` y `trapFocus: false` para evitar conflictos con Popovers / Menus padres.
+169
View File
@@ -0,0 +1,169 @@
import { Box, ColorPicker, Group, Modal, Text, Tooltip } from "@mantine/core";
import { IconPalette } from "@tabler/icons-react";
import { useState, type FC } from "react";
import { colorBorder } from "./color_border";
import { colorSwatch } from "./color_swatch";
/** Paleta de 27 opciones por defecto: tokens Mantine + hexadecimales de acento. */
export const DEFAULT_COLOR_OPTIONS: { value: string; label: string }[] = [
{ value: "", label: "Default" },
{ value: "blue", label: "Azul" },
{ value: "cyan", label: "Cian" },
{ value: "teal", label: "Teal" },
{ value: "green", label: "Verde" },
{ value: "lime", label: "Lima" },
{ value: "yellow", label: "Amarillo" },
{ value: "orange", label: "Naranja" },
{ value: "red", label: "Rojo" },
{ value: "pink", label: "Rosa" },
{ value: "grape", label: "Uva" },
{ value: "violet", label: "Violeta" },
{ value: "indigo", label: "Indigo" },
{ value: "gray", label: "Gris" },
{ value: "#0ea5e9", label: "Sky" },
{ value: "#14b8a6", label: "Esmeralda" },
{ value: "#84cc16", label: "Lima fluor" },
{ value: "#ec4899", label: "Magenta" },
{ value: "#a855f7", label: "Lavanda" },
{ value: "#f97316", label: "Mandarina" },
{ value: "#dc2626", label: "Rubi" },
{ value: "#0891b2", label: "Petroleo" },
{ value: "#fde047", label: "Limon" },
{ value: "#10b981", label: "Menta" },
{ value: "#fb7185", label: "Coral" },
{ value: "#6366f1", label: "Iris" },
{ value: "#94a3b8", label: "Pizarra" },
];
const DEFAULT_CUSTOM_SWATCHES = [
"#1c7ed6", "#15aabf", "#12b886", "#37b24d", "#82c91e",
"#fab005", "#fd7e14", "#fa5252", "#e64980", "#be4bdb",
"#7950f2", "#4c6ef5", "#868e96", "#212529",
];
export interface ColorPickerGridProps {
/** Color seleccionado actualmente (token Mantine o hex). */
value: string;
/** Callback invocado al seleccionar cualquier color, incluido el picker libre. */
onChange: (color: string) => void;
/** Lista de opciones a mostrar como swatches. Default: DEFAULT_COLOR_OPTIONS. */
options?: { value: string; label: string }[];
/** Diametro de cada swatch en px. Default: 26. */
swatchSize?: number;
/** Swatches rapidos dentro del ColorPicker de Mantine. Default: 14 hexadecimales. */
customSwatches?: string[];
/** Titulo del modal del picker libre. Default: "Color personalizado". */
modalTitle?: string;
}
/**
* Grid de swatches de color con boton "+" que abre un ColorPicker de Mantine
* para seleccionar un hexadecimal personalizado via modal.
*/
export const ColorPickerGrid: FC<ColorPickerGridProps> = ({
value,
onChange,
options = DEFAULT_COLOR_OPTIONS,
swatchSize = 26,
customSwatches = DEFAULT_CUSTOM_SWATCHES,
modalTitle = "Color personalizado",
}) => {
const [pickerOpen, setPickerOpen] = useState(false);
const [custom, setCustom] = useState(
value && value.startsWith("#") ? value : "#888888"
);
const isCustomActive =
!!value && value.startsWith("#") && !options.some((o) => o.value === value);
return (
<>
<Group gap={6} maw={280}>
{options.map((c) => {
const selected = value === c.value;
return (
<Tooltip key={c.value || "default"} label={c.label} withArrow>
<Box
role="button"
onClick={(e) => {
e.stopPropagation();
onChange(c.value);
}}
aria-label={c.label}
style={{
width: swatchSize,
height: swatchSize,
borderRadius: "50%",
background: colorSwatch(c.value),
border: `2px solid ${selected ? "var(--mantine-color-white)" : colorBorder(c.value)}`,
boxShadow: selected
? "0 0 0 2px var(--mantine-color-blue-5)"
: undefined,
cursor: "pointer",
flexShrink: 0,
transition: "transform .1s",
}}
/>
</Tooltip>
);
})}
<Tooltip label="Color personalizado" withArrow>
<Box
role="button"
onClick={(e) => {
e.stopPropagation();
setPickerOpen(true);
}}
aria-label="Color personalizado"
style={{
width: swatchSize,
height: swatchSize,
borderRadius: "50%",
background: isCustomActive ? custom : "transparent",
border: `2px dashed ${isCustomActive ? custom : "var(--mantine-color-gray-5)"}`,
boxShadow: isCustomActive
? "0 0 0 2px var(--mantine-color-blue-5)"
: undefined,
cursor: "pointer",
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "var(--mantine-color-gray-3)",
}}
>
<IconPalette size={14} />
</Box>
</Tooltip>
</Group>
<Modal
opened={pickerOpen}
onClose={() => setPickerOpen(false)}
title={modalTitle}
size="auto"
centered
withinPortal
zIndex={2000}
closeOnClickOutside={false}
closeOnEscape={false}
trapFocus={false}
>
<ColorPicker
value={custom}
onChange={(v) => {
setCustom(v);
onChange(v);
}}
format="hex"
swatches={customSwatches}
fullWidth
/>
<Text size="xs" c="dimmed" mt="sm">
{custom.toUpperCase()}
</Text>
</Modal>
</>
);
};
+47
View File
@@ -0,0 +1,47 @@
---
name: color_swatch
kind: function
lang: ts
domain: ui
version: "1.0.0"
purity: pure
signature: "colorSwatch(color: string): string"
description: "Genera el valor CSS para mostrar un swatch (muestra de color) Mantine. Sin color retorna dark-3. Hex: retorna el hex directamente. Token Mantine: retorna CSS var del tono -7."
tags: [color, mantine, css, swatch, ui, theme]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports: []
params:
- name: color
desc: "Color de entrada. Puede ser: string vacio (sin color), hex valido (#RGB o #RRGGBB), o token de color Mantine (\"blue\", \"red\", \"teal\", etc.). Valores no reconocidos retornan el color neutro."
output: "String CSS listo para usar en style.backgroundColor de un circulo/swatch. Para hex retorna el valor literal. Para tokens retorna una CSS variable Mantine. Sin color retorna dark-3."
tested: true
tests:
- "string vacio retorna dark-3"
- "hex valido retorna el hex sin modificar"
- "token mantine retorna css var tono -7"
- "valor desconocido retorna dark-3"
- "hex de 3 digitos retorna hex de 3 digitos"
test_file_path: "frontend/functions/ui/color_swatch.test.ts"
file_path: "frontend/functions/ui/color_swatch.ts"
---
## Ejemplo
```typescript
colorSwatch("") // "var(--mantine-color-dark-3)"
colorSwatch("#0ea5e9") // "#0ea5e9"
colorSwatch("#0ea") // "#0ea"
colorSwatch("blue") // "var(--mantine-color-blue-7)"
colorSwatch("unknown") // "var(--mantine-color-dark-3)"
```
## Notas
Extraida de `apps/kanban/frontend/src/components/colors.ts` lineas 60-65.
A diferencia de `color_bg` y `color_border`, para hex no aplica `color-mix` — retorna el valor crudo porque el swatch muestra el color puro, sin mezclarlo con el fondo.
**Decision de helpers compartidos:** ver `color_bg.md`. Misma logica: `_mantine_color_helpers.ts` compartido.
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { colorSwatch } from "./color_swatch";
describe("colorSwatch", () => {
it("string vacio retorna dark-3", () => {
expect(colorSwatch("")).toBe("var(--mantine-color-dark-3)");
});
it("hex valido retorna el hex sin modificar", () => {
expect(colorSwatch("#0ea5e9")).toBe("#0ea5e9");
expect(colorSwatch("#dc2626")).toBe("#dc2626");
});
it("hex de 3 digitos retorna hex de 3 digitos", () => {
expect(colorSwatch("#0ea")).toBe("#0ea");
});
it("token mantine retorna css var tono -7", () => {
expect(colorSwatch("blue")).toBe("var(--mantine-color-blue-7)");
expect(colorSwatch("grape")).toBe("var(--mantine-color-grape-7)");
});
it("valor desconocido retorna dark-3", () => {
expect(colorSwatch("unknown")).toBe("var(--mantine-color-dark-3)");
});
});
+16
View File
@@ -0,0 +1,16 @@
import { MANTINE_TOKENS, isHex } from "./_mantine_color_helpers";
/**
* Genera el valor CSS para mostrar un swatch (muestra de color) Mantine.
*
* - Sin color (""): color neutro dark-3.
* - Hex (#rgb/#rrggbb): el hex directamente (sin mezcla).
* - Token Mantine (blue, red, ...): CSS variable del tono -7.
* - Valor desconocido: color neutro dark-3.
*/
export function colorSwatch(color: string): string {
if (!color) return "var(--mantine-color-dark-3)";
if (isHex(color)) return color;
if (MANTINE_TOKENS.has(color)) return `var(--mantine-color-${color}-7)`;
return "var(--mantine-color-dark-3)";
}
+126
View File
@@ -0,0 +1,126 @@
---
name: month_heatmap
kind: component
lang: ts
domain: ui
version: "1.0.0"
framework: react
purity: pure
signature: "MonthHeatmap(props: MonthHeatmapProps): JSX.Element"
description: "Grid mensual de calor (heatmap) con inicio en lunes. Renderiza 7 columnas con header de dias de semana y celdas que muestran numero del dia, hasta dos contadores con icono, borde azul para hoy y fondo tintado segun valores primary/secondary."
tags: [calendar, heatmap, grid, month, ui, mantine]
uses_functions: [month_grid_ts_core]
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports:
- "@mantine/core"
- "@tabler/icons-react"
- "../core/month_grid"
tested: false
tests: []
test_file_path: ""
props:
- name: month
type: "Date"
required: true
description: "Mes a mostrar. Se usan getFullYear() y getMonth(). El dia se ignora."
- name: cells
type: "Map<string, MonthHeatmapCell>"
required: true
description: "Datos por dia. Clave YYYY-MM-DD. Valores: primary (contador azulado) y secondary (contador verdoso)."
- name: primaryIcon
type: "ReactNode"
required: false
description: "Icono junto al contador primary. Default: IconPlus de @tabler/icons-react."
- name: secondaryIcon
type: "ReactNode"
required: false
description: "Icono junto al contador secondary. Default: IconCheckbox de @tabler/icons-react."
- name: primaryColor
type: "string"
required: false
description: "Color Mantine del contador primary. Default: 'blue'."
- name: secondaryColor
type: "string"
required: false
description: "Color Mantine del contador secondary. Default: 'green'."
- name: dayLabels
type: "string[]"
required: false
description: "Array de 7 strings para el header de columnas, empezando por lunes. Default: ['Lun','Mar','Mie','Jue','Vie','Sab','Dom']."
- name: cellMinHeight
type: "number"
required: false
description: "Altura minima de cada celda en px. Default: 72."
emits: []
has_state: false
params:
- name: month
desc: "Mes a renderizar como objeto Date (year + month extraidos con getFullYear/getMonth)."
- name: cells
desc: "Map de YYYY-MM-DD a { primary?, secondary? } con los contadores de cada dia."
- name: primaryIcon
desc: "ReactNode opcional para el icono del contador primary (creado, pendiente, etc.)."
- name: secondaryIcon
desc: "ReactNode opcional para el icono del contador secondary (completado, resuelto, etc.)."
- name: primaryColor
desc: "Nombre de color Mantine para el contador primary y su tinte de fondo (rgba 6%)."
- name: secondaryColor
desc: "Nombre de color Mantine para el contador secondary y su tinte de fondo (rgba 8%)."
- name: dayLabels
desc: "Labels del header de dias de semana, 7 elementos, orden lunes-domingo."
- name: cellMinHeight
desc: "Altura minima en px de cada celda del grid."
output: "Componente React que renderiza un grid de 7 columnas con el calendario del mes indicado. Celdas de relleno vacias, celdas del mes con numero, contadores opcionales e iconos, borde azul en el dia de hoy y fondo tintado segun presencia de valores."
file_path: "frontend/functions/ui/month_heatmap.tsx"
---
## Ejemplo
```tsx
import { MonthHeatmap, type MonthHeatmapCell } from "@fn_library/ui/month_heatmap";
const cells = new Map<string, MonthHeatmapCell>([
["2026-05-01", { primary: 3 }],
["2026-05-09", { primary: 1, secondary: 2 }],
["2026-05-15", { secondary: 4 }],
]);
<MonthHeatmap
month={new Date(2026, 4, 1)}
cells={cells}
/>
```
## Ejemplo con iconos y colores custom
```tsx
import { IconBug, IconCheck } from "@tabler/icons-react";
<MonthHeatmap
month={new Date(2026, 4, 1)}
cells={cells}
primaryIcon={<IconBug size={10} />}
secondaryIcon={<IconCheck size={10} />}
primaryColor="red"
secondaryColor="teal"
cellMinHeight={88}
/>
```
## Notas
Componente puro (sin estado propio, sin efectos). El calculo del grid se delega a
`month_grid_ts_core` (`monthGrid(year, month)`), que genera un array de celdas
de longitud multiplo de 7 con `date: null` para el padding.
La deteccion de "hoy" usa `Date` nativo sin dayjs para no introducir dependencias.
El fondo tintado prioriza `secondary` sobre `primary` cuando ambos son > 0,
replicando la logica del componente original en `apps/kanban`.
Compatible con Mantine v9. No usa CSS variables custom — emplea props de Mantine
(`c`, `fw`, `p`, `gap`, `radius`) y solo dos `style` inline para `minHeight`
y `borderColor` condicional (que no tienen equivalente en props Mantine).
+143
View File
@@ -0,0 +1,143 @@
import { type ReactNode } from "react";
import { Box, Group, Paper, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconCheckbox, IconPlus } from "@tabler/icons-react";
import { monthGrid } from "../core/month_grid";
export interface MonthHeatmapCell {
primary?: number;
secondary?: number;
}
export interface MonthHeatmapProps {
/** Mes a mostrar (se usan year y month, el dia se ignora). */
month: Date;
/** Datos por dia, key "YYYY-MM-DD". */
cells: Map<string, MonthHeatmapCell>;
/** Icono junto al contador primary. Default: IconPlus. */
primaryIcon?: ReactNode;
/** Icono junto al contador secondary. Default: IconCheckbox. */
secondaryIcon?: ReactNode;
/** Color Mantine del counter primary. Default: "blue". */
primaryColor?: string;
/** Color Mantine del counter secondary. Default: "green". */
secondaryColor?: string;
/** Labels de cabecera, 7 elementos empezando por lunes. */
dayLabels?: string[];
/** Altura minima de cada celda en px. Default: 72. */
cellMinHeight?: number;
}
const DEFAULT_DAY_LABELS = ["Lun", "Mar", "Mie", "Jue", "Vie", "Sab", "Dom"];
/**
* Grid mensual de calor (heatmap) con inicio en lunes.
*
* Renderiza 7 columnas con header de dias de la semana.
* Cada celda muestra el numero del dia y hasta dos contadores opcionales
* (primary / secondary) con icono. El dia de hoy recibe borde azul y
* numero en negrita. El fondo de la celda se tinta segun los valores:
* secondary > 0 → verdoso, primary > 0 → azulado.
* Las celdas de relleno (fuera del mes) se renderizan vacias.
*/
export function MonthHeatmap({
month,
cells,
primaryIcon,
secondaryIcon,
primaryColor = "blue",
secondaryColor = "green",
dayLabels = DEFAULT_DAY_LABELS,
cellMinHeight = 72,
}: MonthHeatmapProps) {
const year = month.getFullYear();
const monthNum = month.getMonth() + 1; // getMonth() es 0-based
const grid = monthGrid(year, monthNum);
// Hoy como "YYYY-MM-DD" usando Date nativo
const now = new Date();
const todayStr = [
now.getFullYear(),
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
].join("-");
const resolvedPrimaryIcon = primaryIcon ?? <IconPlus size={10} />;
const resolvedSecondaryIcon = secondaryIcon ?? <IconCheckbox size={10} />;
return (
<Box>
{/* Header dias de la semana */}
<SimpleGrid cols={7} spacing={4} mb={4}>
{dayLabels.map((label) => (
<Text key={label} size="xs" c="dimmed" ta="center" fw={600}>
{label}
</Text>
))}
</SimpleGrid>
{/* Celdas del mes */}
<SimpleGrid cols={7} spacing={4}>
{grid.map((cell, i) => {
if (!cell.date) {
return <Box key={i} style={{ minHeight: cellMinHeight }} />;
}
const data = cells.get(cell.date) ?? {};
const primary = data.primary ?? 0;
const secondary = data.secondary ?? 0;
const dayNum = parseInt(cell.date.slice(8, 10), 10);
const isToday = cell.date === todayStr;
const background =
secondary > 0
? "rgba(81, 207, 102, 0.08)"
: primary > 0
? "rgba(34, 139, 230, 0.06)"
: undefined;
return (
<Paper
key={i}
p={6}
withBorder
radius="sm"
style={{
minHeight: cellMinHeight,
borderColor: isToday ? "var(--mantine-color-blue-5)" : undefined,
background,
}}
>
<Stack gap={2}>
<Text
size="xs"
fw={isToday ? 700 : 500}
c={isToday ? "blue" : undefined}
>
{dayNum}
</Text>
{primary > 0 && (
<Group gap={3} wrap="nowrap" c={primaryColor}>
{resolvedPrimaryIcon}
<Text size="xs" c={primaryColor}>
{primary}
</Text>
</Group>
)}
{secondary > 0 && (
<Group gap={3} wrap="nowrap" c={secondaryColor}>
{resolvedSecondaryIcon}
<Text size="xs" c={secondaryColor}>
{secondary}
</Text>
</Group>
)}
</Stack>
</Paper>
);
})}
</SimpleGrid>
</Box>
);
}
+105
View File
@@ -0,0 +1,105 @@
---
name: sticker_picker
kind: component
lang: ts
domain: ui
version: "1.0.0"
framework: react
purity: impure
signature: "StickerPicker(props: StickerPickerProps): JSX.Element"
description: "Selector de emoji/sticker encapsulado en un Popover de Mantine. Monta emoji-mart Picker una sola vez para evitar re-creaciones en cada render."
tags: [emoji, sticker, picker, popover, mantine, emoji-mart, react]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: ""
imports:
- "@mantine/core"
- "@emoji-mart/data"
- "emoji-mart"
tested: false
tests: []
test_file_path: ""
file_path: "frontend/functions/ui/sticker_picker.tsx"
props:
- name: opened
type: "boolean"
required: true
description: "Controla si el Popover está abierto."
- name: onClose
type: "() => void"
required: true
description: "Callback invocado al cerrar (click fuera, Escape, o tras selección)."
- name: onSelect
type: "(emoji: string) => void"
required: true
description: "Callback invocado con el emoji seleccionado. Prefiere unicode nativo; si no está disponible usa shortcode."
- name: target
type: "React.ReactNode"
required: true
description: "Elemento ancla que dispara el Popover."
- name: theme
type: '"dark" | "light" | "auto"'
required: false
description: 'Tema visual del Picker de emoji-mart. Por defecto "dark".'
- name: position
type: 'PopoverProps["position"]'
required: false
description: 'Posición del Popover respecto al ancla. Por defecto "bottom-start".'
emits: [onClose, onSelect]
has_state: false
output: "Popover de Mantine con emoji-mart Picker embebido. Tras selección emite onSelect(emoji) y onClose()."
params:
- name: opened
desc: "Estado de visibilidad del picker. Gestionado por el componente padre."
- name: onClose
desc: "Se llama cuando el picker debe cerrarse."
- name: onSelect
desc: "Se llama con el string unicode o shortcode del emoji elegido."
- name: target
desc: "Nodo React que actúa como ancla del Popover (generalmente un botón o icono)."
- name: theme
desc: 'Tema de color del picker. Valores: "dark" | "light" | "auto". Por defecto "dark".'
- name: position
desc: 'Posición del Popover. Cualquier valor de PopoverProps["position"] de Mantine. Por defecto "bottom-start".'
---
## Ejemplo
```tsx
import { useState } from "react";
import { ActionIcon } from "@mantine/core";
import { StickerPicker } from "@fn_library/sticker_picker";
function MyCard() {
const [pickerOpen, setPickerOpen] = useState(false);
const [sticker, setSticker] = useState<string | null>(null);
return (
<StickerPicker
opened={pickerOpen}
onClose={() => setPickerOpen(false)}
onSelect={(emoji) => setSticker(emoji)}
target={
<ActionIcon onClick={() => setPickerOpen((o) => !o)}>
{sticker ?? "😀"}
</ActionIcon>
}
theme="dark"
position="bottom-start"
/>
);
}
```
## Notas
**Dependencias externas requeridas** (no incluidas en el registry):
```bash
pnpm add @emoji-mart/data emoji-mart
```
El componente interno `PickerInner` instancia `emoji-mart` Picker con `useEffect` y lo monta en un `<div ref>`. El cleanup borra el `innerHTML` del div al desmontar. `onSelectRef` mantiene el callback actualizado sin recrear la instancia en cada render del padre. El prop `theme` se pasa al montar — cambios posteriores de `theme` no provocan remontaje (la instancia mantiene el tema inicial), comportamiento aceptable para la mayoría de casos de uso. Si se necesita cambio dinámico de tema, destructurar el key del componente padre fuerza remontaje.
El Popover usa `withinPortal` para evitar clipping por overflow de contenedores padre, `closeOnClickOutside` y `closeOnEscape` para comportamiento estándar, y `trapFocus={false}` para que emoji-mart gestione el foco internamente.
+108
View File
@@ -0,0 +1,108 @@
import { type FC, useEffect, useRef } from "react";
import { Popover, type PopoverProps } from "@mantine/core";
import data from "@emoji-mart/data";
import { Picker } from "emoji-mart";
export interface StickerPickerProps {
/** Whether the picker popover is open. */
opened: boolean;
/** Called when the picker should close (click outside, Escape, or after selection). */
onClose: () => void;
/** Called with the selected emoji string (native unicode preferred, fallback to shortcode). */
onSelect: (emoji: string) => void;
/** Anchor element that opens the popover. */
target: React.ReactNode;
/** Color theme for the emoji-mart Picker. Defaults to "dark". */
theme?: "dark" | "light" | "auto";
/** Popover placement relative to the anchor. Defaults to "bottom-start". */
position?: PopoverProps["position"];
}
/**
* StickerPicker — emoji picker wrapped in a Mantine Popover.
*
* Renders an emoji-mart Picker inside a transparent Popover dropdown.
* The Picker instance is created once on mount and cleaned up on unmount
* so re-renders caused by parent state changes do not recreate it.
* onSelect and theme changes are reflected via refs without remounting.
*/
export const StickerPicker: FC<StickerPickerProps> = ({
opened,
onClose,
onSelect,
target,
theme = "dark",
position = "bottom-start",
}) => {
return (
<Popover
opened={opened}
onChange={(o) => {
if (!o) onClose();
}}
onDismiss={onClose}
position={position}
withArrow
shadow="md"
withinPortal
closeOnClickOutside
closeOnEscape
trapFocus={false}
>
<Popover.Target>{target}</Popover.Target>
<Popover.Dropdown p={0} style={{ background: "transparent", border: "none" }}>
<PickerInner
theme={theme}
onSelect={(emoji) => {
onSelect(emoji);
onClose();
}}
/>
</Popover.Dropdown>
</Popover>
);
};
// ---------------------------------------------------------------------------
// Internal: mounts emoji-mart Picker once, updates callbacks via refs.
// ---------------------------------------------------------------------------
interface PickerInnerProps {
onSelect: (emoji: string) => void;
theme: "dark" | "light" | "auto";
}
function PickerInner({ onSelect, theme }: PickerInnerProps) {
const ref = useRef<HTMLDivElement | null>(null);
const instanceRef = useRef<unknown>(null);
// Keep latest callback in ref so the Picker closure never goes stale.
const onSelectRef = useRef(onSelect);
onSelectRef.current = onSelect;
const themeRef = useRef(theme);
themeRef.current = theme;
useEffect(() => {
if (!ref.current) return;
instanceRef.current = new Picker({
data,
onEmojiSelect: (e: { native?: string; shortcodes?: string }) => {
const cb = onSelectRef.current;
if (e.native) cb(e.native);
else if (e.shortcodes) cb(e.shortcodes);
},
theme: themeRef.current,
previewPosition: "none",
skinTonePosition: "search",
autoFocus: true,
maxFrequentRows: 2,
ref,
});
return () => {
if (ref.current) ref.current.innerHTML = "";
instanceRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <div ref={ref} />;
}