fix(infra): gradle_run detecta android-sdk — issue 0076 #2
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 14–32) 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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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)";
|
||||
}
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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)";
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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)";
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package browser
|
||||
|
||||
// CdpSetCookie establece una cookie en el browser via Network.setCookie.
|
||||
// Util para autenticar tests e2e contra apps con sesion HttpOnly: hacer login
|
||||
// HTTP en el test, capturar el Set-Cookie, y propagar al browser antes de navegar.
|
||||
//
|
||||
// name/value/domain son obligatorios. path por defecto "/". httpOnly true por
|
||||
// defecto (replica HttpOnly de la app). secure y sameSite opcionales.
|
||||
func CdpSetCookie(c *CDPConn, name, value, domain, path string, httpOnly bool) error {
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
params := map[string]any{
|
||||
"name": name,
|
||||
"value": value,
|
||||
"domain": domain,
|
||||
"path": path,
|
||||
"httpOnly": httpOnly,
|
||||
}
|
||||
_, err := c.sendCDP("Network.setCookie", params)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: cdp_set_cookie_go_browser
|
||||
name: cdp_set_cookie
|
||||
kind: function
|
||||
lang: go
|
||||
domain: browser
|
||||
purity: impure
|
||||
version: 1.0.0
|
||||
tested: false
|
||||
description: "Establece una cookie en el browser via Network.setCookie del protocolo CDP. Soporta cookies HttpOnly. Util para tests e2e que necesitan autenticar el browser sin pasar por la UI de login."
|
||||
tags: [cdp, browser, cookie, e2e, auth]
|
||||
signature: "func CdpSetCookie(c *CDPConn, name, value, domain, path string, httpOnly bool) error"
|
||||
uses_functions: []
|
||||
uses_types:
|
||||
- cdp_conn_go_browser
|
||||
returns: ""
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: []
|
||||
file_path: "functions/browser/cdp_set_cookie.go"
|
||||
example: |
|
||||
conn, _ := browser.CdpConnect(9222)
|
||||
defer browser.CdpClose(conn, 0)
|
||||
// Tras hacer login HTTP en el test:
|
||||
if err := browser.CdpSetCookie(conn, "session", token, "localhost", "/", true); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
browser.CdpNavigate(conn, "http://localhost:8080/dashboard")
|
||||
params:
|
||||
- name: c
|
||||
desc: Conexion CDP abierta (de CdpConnect)
|
||||
- name: name
|
||||
desc: Nombre de la cookie
|
||||
- name: value
|
||||
desc: Valor de la cookie (token de sesion, etc.)
|
||||
- name: domain
|
||||
desc: Dominio sin protocolo (ej. "localhost", "example.com")
|
||||
- name: path
|
||||
desc: Path scope. Vacio se trata como "/"
|
||||
- name: httpOnly
|
||||
desc: Si true, cookie HttpOnly (no accesible desde JS)
|
||||
output: "error si Network.setCookie falla; nil en exito"
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
- Network.setCookie es un comando CDP nativo, no requiere JS evaluate.
|
||||
- Permite cookies HttpOnly que `document.cookie` no puede setear desde JS.
|
||||
- Necesita que el dominio coincida con la URL a la que se navegara despues.
|
||||
@@ -0,0 +1,26 @@
|
||||
package core
|
||||
|
||||
import "time"
|
||||
|
||||
// ParseDateOrDefault parses s as a date/datetime and returns the result.
|
||||
// Accepted formats: "2006-01-02" (YYYY-MM-DD) and time.RFC3339Nano.
|
||||
// Returns dflt when s is empty or does not match either format.
|
||||
// When endOfDay is true and the input matched YYYY-MM-DD, adds
|
||||
// 24*time.Hour - time.Nanosecond so the result is the last nanosecond of
|
||||
// that day (useful for inclusive end-of-range queries).
|
||||
// RFC3339Nano inputs are never adjusted regardless of endOfDay.
|
||||
func ParseDateOrDefault(s string, dflt time.Time, endOfDay bool) time.Time {
|
||||
if s == "" {
|
||||
return dflt
|
||||
}
|
||||
if t, err := time.Parse("2006-01-02", s); err == nil {
|
||||
if endOfDay {
|
||||
return t.Add(24*time.Hour - time.Nanosecond)
|
||||
}
|
||||
return t
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return t
|
||||
}
|
||||
return dflt
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: parse_date_or_default
|
||||
kind: function
|
||||
lang: go
|
||||
domain: core
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func ParseDateOrDefault(s string, dflt time.Time, endOfDay bool) time.Time"
|
||||
description: "Parsea s como fecha (YYYY-MM-DD) o datetime (RFC3339Nano). Retorna dflt si s esta vacio o no encaja en ninguno de los dos formatos. Si endOfDay es true y el formato fue YYYY-MM-DD, ajusta al ultimo nanosegundo del dia (util para rangos de fechas inclusivos por el final)."
|
||||
tags: [date, parse, time, default, range, YYYY-MM-DD, RFC3339Nano]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports:
|
||||
- time
|
||||
params:
|
||||
- name: s
|
||||
desc: "Cadena a parsear. Formatos aceptados: '2006-01-02' (YYYY-MM-DD) o RFC3339Nano. String vacio activa el valor por defecto."
|
||||
- name: dflt
|
||||
desc: "Valor time.Time a retornar cuando s esta vacio o no coincide con ningun formato soportado."
|
||||
- name: endOfDay
|
||||
desc: "Si es true y el input fue YYYY-MM-DD, suma 24h-1ns para apuntar al ultimo nanosegundo del dia. No tiene efecto sobre RFC3339Nano ni sobre el retorno de dflt."
|
||||
output: "time.Time parseado desde s, ajustado si endOfDay=true y formato YYYY-MM-DD; o dflt si s esta vacio o es invalido."
|
||||
tested: true
|
||||
tests:
|
||||
- "string vacio retorna dflt"
|
||||
- "formato invalido retorna dflt"
|
||||
- "YYYY-MM-DD sin endOfDay retorna inicio de dia"
|
||||
- "YYYY-MM-DD con endOfDay retorna ultimo nanosegundo del dia"
|
||||
- "RFC3339Nano sin endOfDay retorna timestamp exacto"
|
||||
- "RFC3339Nano con endOfDay no se ajusta"
|
||||
- "endOfDay false con dflt no lo modifica"
|
||||
test_file_path: "functions/core/parse_date_or_default_test.go"
|
||||
file_path: "functions/core/parse_date_or_default.go"
|
||||
source_repo: "https://github.com/egutierrez/fn_registry/apps/kanban"
|
||||
source_license: "private"
|
||||
source_file: "apps/kanban/backend/metrics.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
dflt := time.Now().UTC()
|
||||
|
||||
// Inicio de rango (incluye el dia completo desde las 00:00)
|
||||
from := ParseDateOrDefault("2024-01-01", dflt, false)
|
||||
|
||||
// Fin de rango (incluye hasta el ultimo nanosegundo del dia)
|
||||
to := ParseDateOrDefault("2024-01-31", dflt, true)
|
||||
|
||||
// Timestamp exacto desde RFC3339Nano (no se ajusta aunque endOfDay=true)
|
||||
ts := ParseDateOrDefault("2024-01-15T12:00:00Z", dflt, true)
|
||||
|
||||
// Valor invalido → dflt
|
||||
fallback := ParseDateOrDefault("no-es-fecha", dflt, false)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Unifica las dos funciones originales `parseDateOrDefault` y `parseEndDateOrDefault`
|
||||
de `apps/kanban/backend/metrics.go:132-156` en una sola funcion parametrica.
|
||||
`time.Parse` es deterministico, por lo que la funcion es pura aunque use el paquete `time`.
|
||||
@@ -0,0 +1,65 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseDateOrDefault(t *testing.T) {
|
||||
dflt := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("string vacio retorna dflt", func(t *testing.T) {
|
||||
got := ParseDateOrDefault("", dflt, false)
|
||||
if !got.Equal(dflt) {
|
||||
t.Errorf("got %v, want %v", got, dflt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("formato invalido retorna dflt", func(t *testing.T) {
|
||||
got := ParseDateOrDefault("no-es-fecha", dflt, false)
|
||||
if !got.Equal(dflt) {
|
||||
t.Errorf("got %v, want %v", got, dflt)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("YYYY-MM-DD sin endOfDay retorna inicio de dia", func(t *testing.T) {
|
||||
got := ParseDateOrDefault("2024-03-15", dflt, false)
|
||||
want := time.Date(2024, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("YYYY-MM-DD con endOfDay retorna ultimo nanosegundo del dia", func(t *testing.T) {
|
||||
got := ParseDateOrDefault("2024-03-15", dflt, true)
|
||||
want := time.Date(2024, 3, 15, 23, 59, 59, 999999999, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RFC3339Nano sin endOfDay retorna timestamp exacto", func(t *testing.T) {
|
||||
input := "2024-03-15T14:30:00.123456789Z"
|
||||
got := ParseDateOrDefault(input, dflt, false)
|
||||
want, _ := time.Parse(time.RFC3339Nano, input)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RFC3339Nano con endOfDay no se ajusta", func(t *testing.T) {
|
||||
input := "2024-03-15T00:00:00Z"
|
||||
got := ParseDateOrDefault(input, dflt, true)
|
||||
want, _ := time.Parse(time.RFC3339Nano, input)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("RFC3339Nano should not be adjusted; got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("endOfDay false con dflt no lo modifica", func(t *testing.T) {
|
||||
got := ParseDateOrDefault("", dflt, true)
|
||||
if !got.Equal(dflt) {
|
||||
t.Errorf("got %v, want %v", got, dflt)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package datascience
|
||||
|
||||
// DurationStats holds descriptive statistics for a set of durations in milliseconds.
|
||||
type DurationStats struct {
|
||||
N int `json:"n"`
|
||||
AvgMs int64 `json:"avg_ms"`
|
||||
P50Ms int64 `json:"p50_ms"`
|
||||
P90Ms int64 `json:"p90_ms"`
|
||||
P99Ms int64 `json:"p99_ms"`
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package datascience
|
||||
|
||||
import "sort"
|
||||
|
||||
// DurationStatsFrom computes descriptive statistics for a slice of durations in milliseconds.
|
||||
// It sorts a local copy so the original slice is not mutated.
|
||||
// Returns a zero-value DurationStats when the input is empty.
|
||||
func DurationStatsFrom(durations []int64) DurationStats {
|
||||
n := len(durations)
|
||||
if n == 0 {
|
||||
return DurationStats{}
|
||||
}
|
||||
cp := make([]int64, n)
|
||||
copy(cp, durations)
|
||||
sort.Slice(cp, func(i, j int) bool { return cp[i] < cp[j] })
|
||||
var sum int64
|
||||
for _, d := range cp {
|
||||
sum += d
|
||||
}
|
||||
return DurationStats{
|
||||
N: n,
|
||||
AvgMs: sum / int64(n),
|
||||
P50Ms: Percentile(cp, 0.5),
|
||||
P90Ms: Percentile(cp, 0.9),
|
||||
P99Ms: Percentile(cp, 0.99),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: duration_stats
|
||||
kind: function
|
||||
lang: go
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func DurationStatsFrom(durations []int64) DurationStats"
|
||||
description: "Calcula estadisticas descriptivas (N, media, P50/P90/P99) de un slice de duraciones en milisegundos. Ordena una copia local sin mutar el input. Retorna DurationStats{} para slice vacio."
|
||||
tags: [statistics, duration, percentile, metrics, int64]
|
||||
uses_functions:
|
||||
- percentile_int64_go_datascience
|
||||
uses_types:
|
||||
- DurationStats_go_datascience
|
||||
returns:
|
||||
- DurationStats_go_datascience
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports:
|
||||
- sort
|
||||
params:
|
||||
- name: durations
|
||||
desc: "Slice de duraciones en milisegundos. No necesita estar ordenado; se ordena internamente sobre una copia."
|
||||
output: "DurationStats con N, AvgMs, P50Ms, P90Ms y P99Ms calculados. DurationStats{} si el slice esta vacio."
|
||||
tested: true
|
||||
tests:
|
||||
- "slice vacio retorna estadisticas cero"
|
||||
- "un solo elemento produce estadisticas identicas"
|
||||
- "cinco elementos calcula media y percentiles correctos"
|
||||
- "input original no se muta"
|
||||
- "diez elementos p90 usa idx truncado"
|
||||
test_file_path: "functions/datascience/duration_stats_test.go"
|
||||
file_path: "functions/datascience/duration_stats.go"
|
||||
source_repo: "https://github.com/egutierrez/fn_registry/apps/kanban"
|
||||
source_license: "private"
|
||||
source_file: "apps/kanban/backend/metrics.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
durations := []int64{50, 10, 30, 40, 20}
|
||||
stats := DurationStatsFrom(durations)
|
||||
// stats.N = 5
|
||||
// stats.AvgMs = 30
|
||||
// stats.P50Ms = 30
|
||||
// stats.P90Ms = 50
|
||||
// stats.P99Ms = 50
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Ordena una copia con `sort.Slice` para no mutar el slice original.
|
||||
Compone `Percentile` (`percentile_int64_go_datascience`) para los calculos de P50/P90/P99.
|
||||
Extraido y generalizado desde `apps/kanban/backend/metrics.go:113-130`.
|
||||
@@ -0,0 +1,69 @@
|
||||
package datascience
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDurationStatsFrom(t *testing.T) {
|
||||
t.Run("slice vacio retorna estadisticas cero", func(t *testing.T) {
|
||||
got := DurationStatsFrom([]int64{})
|
||||
if got.N != 0 || got.AvgMs != 0 || got.P50Ms != 0 || got.P90Ms != 0 || got.P99Ms != 0 {
|
||||
t.Errorf("got %+v, want zero DurationStats", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("un solo elemento produce estadisticas identicas", func(t *testing.T) {
|
||||
got := DurationStatsFrom([]int64{100})
|
||||
if got.N != 1 || got.AvgMs != 100 || got.P50Ms != 100 || got.P90Ms != 100 || got.P99Ms != 100 {
|
||||
t.Errorf("got %+v, want all=100", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cinco elementos calcula media y percentiles correctos", func(t *testing.T) {
|
||||
// sorted: [10,20,30,40,50], n=5
|
||||
// P50: idx=int(4*0.5)=2 → 30
|
||||
// P90: idx=int(4*0.9)=int(3.6)=3 → 40
|
||||
// P99: idx=int(4*0.99)=int(3.96)=3 → 40
|
||||
got := DurationStatsFrom([]int64{50, 10, 30, 40, 20})
|
||||
if got.N != 5 {
|
||||
t.Errorf("N: got %v, want 5", got.N)
|
||||
}
|
||||
if got.AvgMs != 30 {
|
||||
t.Errorf("AvgMs: got %v, want 30", got.AvgMs)
|
||||
}
|
||||
if got.P50Ms != 30 {
|
||||
t.Errorf("P50Ms: got %v, want 30", got.P50Ms)
|
||||
}
|
||||
if got.P90Ms != 40 {
|
||||
t.Errorf("P90Ms: got %v, want 40", got.P90Ms)
|
||||
}
|
||||
if got.P99Ms != 40 {
|
||||
t.Errorf("P99Ms: got %v, want 40", got.P99Ms)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("input original no se muta", func(t *testing.T) {
|
||||
input := []int64{50, 10, 30}
|
||||
_ = DurationStatsFrom(input)
|
||||
if input[0] != 50 || input[1] != 10 || input[2] != 30 {
|
||||
t.Errorf("input mutated: got %v", input)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("diez elementos p90 usa idx truncado", func(t *testing.T) {
|
||||
// sorted: [10..100], n=10
|
||||
// P50: idx=int(9*0.5)=4 → 50
|
||||
// P90: idx=int(9*0.9)=int(8.1)=8 → 90
|
||||
got := DurationStatsFrom([]int64{10, 20, 30, 40, 50, 60, 70, 80, 90, 100})
|
||||
if got.N != 10 {
|
||||
t.Errorf("N: got %v, want 10", got.N)
|
||||
}
|
||||
if got.AvgMs != 55 {
|
||||
t.Errorf("AvgMs: got %v, want 55", got.AvgMs)
|
||||
}
|
||||
if got.P50Ms != 50 {
|
||||
t.Errorf("P50Ms: got %v, want 50", got.P50Ms)
|
||||
}
|
||||
if got.P90Ms != 90 {
|
||||
t.Errorf("P90Ms: got %v, want 90", got.P90Ms)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package datascience
|
||||
|
||||
// Percentile returns the value at percentile p (0.0–1.0) from a pre-sorted
|
||||
// ascending slice of int64 values.
|
||||
// Returns 0 for an empty slice.
|
||||
// idx is computed as int(float64(len-1)*p), clamped to [0, len-1].
|
||||
func Percentile(sorted []int64, p float64) int64 {
|
||||
if len(sorted) == 0 {
|
||||
return 0
|
||||
}
|
||||
idx := int(float64(len(sorted)-1) * p)
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
}
|
||||
if idx >= len(sorted) {
|
||||
idx = len(sorted) - 1
|
||||
}
|
||||
return sorted[idx]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: percentile_int64
|
||||
kind: function
|
||||
lang: go
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func Percentile(sorted []int64, p float64) int64"
|
||||
description: "Calcula el percentil p (0.0-1.0) de un slice de int64 pre-ordenado ascendente. Retorna 0 para slice vacio. idx = int(float64(len-1)*p), clamped a [0, len-1]."
|
||||
tags: [statistics, percentile, quantile, int64, sorted]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: []
|
||||
params:
|
||||
- name: sorted
|
||||
desc: "Slice de int64 pre-ordenado en orden ascendente. No se reordena internamente."
|
||||
- name: p
|
||||
desc: "Percentil a calcular, en rango [0.0, 1.0]. 0.5 = mediana, 0.9 = P90, 0.99 = P99."
|
||||
output: "El valor en la posicion del percentil p dentro del slice. Retorna 0 si el slice esta vacio."
|
||||
tested: true
|
||||
tests:
|
||||
- "slice vacio retorna cero"
|
||||
- "un solo elemento retorna ese elemento"
|
||||
- "p0 retorna minimo"
|
||||
- "p100 retorna maximo"
|
||||
- "p50 retorna mediana de cinco elementos"
|
||||
- "p90 de diez elementos usa idx int truncado"
|
||||
- "p99 de slice pequeno usa idx truncado a cero"
|
||||
test_file_path: "functions/datascience/percentile_int64_test.go"
|
||||
file_path: "functions/datascience/percentile_int64.go"
|
||||
source_repo: "https://github.com/egutierrez/fn_registry/apps/kanban"
|
||||
source_license: "private"
|
||||
source_file: "apps/kanban/backend/metrics.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
sorted := []int64{10, 20, 30, 40, 50}
|
||||
p50 := Percentile(sorted, 0.5) // 30
|
||||
p90 := Percentile(sorted, 0.9) // 50
|
||||
p99 := Percentile(sorted, 0.99) // 50
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
El input debe estar ordenado ascendente antes de llamar a esta funcion.
|
||||
`DurationStatsFrom` se encarga de ordenar antes de llamarla.
|
||||
Extraido de `apps/kanban/backend/metrics.go:99-111`.
|
||||
@@ -0,0 +1,56 @@
|
||||
package datascience
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPercentile(t *testing.T) {
|
||||
t.Run("slice vacio retorna cero", func(t *testing.T) {
|
||||
got := Percentile([]int64{}, 0.5)
|
||||
if got != 0 {
|
||||
t.Errorf("got %v, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("un solo elemento retorna ese elemento", func(t *testing.T) {
|
||||
got := Percentile([]int64{42}, 0.5)
|
||||
if got != 42 {
|
||||
t.Errorf("got %v, want 42", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("p0 retorna minimo", func(t *testing.T) {
|
||||
got := Percentile([]int64{10, 20, 30, 40, 50}, 0.0)
|
||||
if got != 10 {
|
||||
t.Errorf("got %v, want 10", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("p100 retorna maximo", func(t *testing.T) {
|
||||
got := Percentile([]int64{10, 20, 30, 40, 50}, 1.0)
|
||||
if got != 50 {
|
||||
t.Errorf("got %v, want 50", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("p50 retorna mediana de cinco elementos", func(t *testing.T) {
|
||||
got := Percentile([]int64{10, 20, 30, 40, 50}, 0.5)
|
||||
if got != 30 {
|
||||
t.Errorf("got %v, want 30", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("p90 de diez elementos usa idx int truncado", func(t *testing.T) {
|
||||
// idx = int(9 * 0.9) = int(8.1) = 8 → sorted[8] = 9
|
||||
got := Percentile([]int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 0.9)
|
||||
if got != 9 {
|
||||
t.Errorf("got %v, want 9", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("p99 de slice pequeno usa idx truncado a cero", func(t *testing.T) {
|
||||
// idx = int(1 * 0.99) = int(0.99) = 0 → sorted[0] = 100
|
||||
got := Percentile([]int64{100, 200}, 0.99)
|
||||
if got != 100 {
|
||||
t.Errorf("got %v, want 100", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package infra
|
||||
|
||||
import "net/http"
|
||||
|
||||
// SessionCookieClear invalidates the named session cookie by setting
|
||||
// MaxAge=-1. The browser removes the cookie immediately on receipt.
|
||||
// It does not return an error because http.SetCookie never fails at runtime.
|
||||
func SessionCookieClear(w http.ResponseWriter, name string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: http_session_cookie_clear
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func SessionCookieClear(w http.ResponseWriter, name string)"
|
||||
description: "Invalida la cookie de sesion en el browser fijando MaxAge=-1. Path='/', HttpOnly=true, SameSite=Lax. No retorna error porque http.SetCookie no falla en runtime."
|
||||
tags: [http, session, cookie, auth, logout, response]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["net/http"]
|
||||
params:
|
||||
- name: w
|
||||
desc: "ResponseWriter donde se escribe el header Set-Cookie"
|
||||
- name: name
|
||||
desc: "nombre de la cookie a invalidar (debe coincidir con el nombre usado al crearla)"
|
||||
output: "escribe el header Set-Cookie con MaxAge=-1 en w; sin valor de retorno"
|
||||
tested: true
|
||||
tests:
|
||||
- "cookie clear setea MaxAge negativo"
|
||||
- "cookie clear valor es vacio"
|
||||
- "header Set-Cookie contiene HttpOnly y SameSite=Lax"
|
||||
test_file_path: "functions/infra/http_session_cookie_clear_test.go"
|
||||
file_path: "functions/infra/http_session_cookie_clear.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
func handleLogout(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := infra.SessionTokenExtract(r, "my_session")
|
||||
if token != "" {
|
||||
_ = infra.SessionDelete(db.conn, token)
|
||||
}
|
||||
infra.SessionCookieClear(w, "my_session")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/auth.go. MaxAge=-1 hace que el browser elimine la cookie inmediatamente independientemente de la fecha Expires original. La funcion no retorna error porque `http.SetCookie` escribe directamente en los headers y nunca falla. Complemento de `http_session_cookie_set_go_infra`.
|
||||
@@ -0,0 +1,50 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionCookieClear(t *testing.T) {
|
||||
t.Run("cookie clear setea MaxAge negativo", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SessionCookieClear(w, "my_session")
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
c := cookies[0]
|
||||
if c.Name != "my_session" {
|
||||
t.Errorf("Name: got %q, want %q", c.Name, "my_session")
|
||||
}
|
||||
if c.MaxAge >= 0 {
|
||||
t.Errorf("MaxAge: got %d, want negative", c.MaxAge)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cookie clear valor es vacio", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SessionCookieClear(w, "sess")
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Fatal("no cookie set")
|
||||
}
|
||||
if cookies[0].Value != "" {
|
||||
t.Errorf("Value: got %q, want empty", cookies[0].Value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("header Set-Cookie contiene HttpOnly y SameSite=Lax", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SessionCookieClear(w, "sess")
|
||||
header := w.Header().Get("Set-Cookie")
|
||||
if !strings.Contains(header, "HttpOnly") {
|
||||
t.Errorf("Set-Cookie header missing HttpOnly: %s", header)
|
||||
}
|
||||
if !strings.Contains(header, "SameSite=Lax") {
|
||||
t.Errorf("Set-Cookie header missing SameSite=Lax: %s", header)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionCookieSet writes a session cookie to the response.
|
||||
// The cookie is HttpOnly, Path="/", SameSite=Lax and expires at the
|
||||
// Unix timestamp expiresAt (seconds). It does not return an error
|
||||
// because http.SetCookie never fails at runtime.
|
||||
func SessionCookieSet(w http.ResponseWriter, name, token string, expiresAt int64) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Expires: time.Unix(expiresAt, 0),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: http_session_cookie_set
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func SessionCookieSet(w http.ResponseWriter, name, token string, expiresAt int64)"
|
||||
description: "Escribe una cookie de sesion HttpOnly en la respuesta HTTP. Path='/', SameSite=Lax, Expires=time.Unix(expiresAt,0). No retorna error porque http.SetCookie no falla en runtime."
|
||||
tags: [http, session, cookie, auth, response]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["net/http", "time"]
|
||||
params:
|
||||
- name: w
|
||||
desc: "ResponseWriter donde se escribe el header Set-Cookie"
|
||||
- name: name
|
||||
desc: "nombre de la cookie (p.ej. 'kanban_session')"
|
||||
- name: token
|
||||
desc: "valor del token de sesion"
|
||||
- name: expiresAt
|
||||
desc: "timestamp Unix (segundos) de expiracion de la cookie"
|
||||
output: "escribe el header Set-Cookie en w; sin valor de retorno"
|
||||
tested: true
|
||||
tests:
|
||||
- "cookie set con nombre y token correctos"
|
||||
- "header Set-Cookie contiene HttpOnly y SameSite=Lax"
|
||||
test_file_path: "functions/infra/http_session_cookie_set_test.go"
|
||||
file_path: "functions/infra/http_session_cookie_set.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
sess, err := infra.SessionCreate(db, userID, 7*24*time.Hour, nil)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", 500)
|
||||
return
|
||||
}
|
||||
infra.SessionCookieSet(w, "my_session", sess.Token, sess.ExpiresAt)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/auth.go. La funcion no retorna error porque `http.SetCookie` escribe directamente en los headers del ResponseWriter y nunca falla. El campo `error_type` se omite porque la firma no tiene retorno de error — hay precedente en el registry (componentes C++ y otros helpers HTTP impuros sin error_type).
|
||||
@@ -0,0 +1,46 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionCookieSet(t *testing.T) {
|
||||
t.Run("cookie set con nombre y token correctos", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
expires := time.Now().Add(24 * time.Hour).Unix()
|
||||
SessionCookieSet(w, "my_session", "tok_abc", expires)
|
||||
|
||||
cookies := w.Result().Cookies()
|
||||
if len(cookies) != 1 {
|
||||
t.Fatalf("expected 1 cookie, got %d", len(cookies))
|
||||
}
|
||||
c := cookies[0]
|
||||
if c.Name != "my_session" {
|
||||
t.Errorf("Name: got %q, want %q", c.Name, "my_session")
|
||||
}
|
||||
if c.Value != "tok_abc" {
|
||||
t.Errorf("Value: got %q, want %q", c.Value, "tok_abc")
|
||||
}
|
||||
if c.Path != "/" {
|
||||
t.Errorf("Path: got %q, want %q", c.Path, "/")
|
||||
}
|
||||
if !c.HttpOnly {
|
||||
t.Errorf("expected HttpOnly=true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("header Set-Cookie contiene HttpOnly y SameSite=Lax", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
SessionCookieSet(w, "s", "v", time.Now().Add(time.Hour).Unix())
|
||||
header := w.Header().Get("Set-Cookie")
|
||||
if !strings.Contains(header, "HttpOnly") {
|
||||
t.Errorf("Set-Cookie header missing HttpOnly: %s", header)
|
||||
}
|
||||
if !strings.Contains(header, "SameSite=Lax") {
|
||||
t.Errorf("Set-Cookie header missing SameSite=Lax: %s", header)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package infra
|
||||
|
||||
import "net/http"
|
||||
|
||||
// SessionTokenExtract extracts a session token from the request.
|
||||
// It checks the cookie named cookieName first; if present and non-empty,
|
||||
// that value is returned. Otherwise it checks the Authorization header
|
||||
// for a "Bearer <token>" prefix and returns the token part.
|
||||
// Returns "" if no token is found in either source.
|
||||
func SessionTokenExtract(r *http.Request, cookieName string) string {
|
||||
if c, err := r.Cookie(cookieName); err == nil && c.Value != "" {
|
||||
return c.Value
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
if len(auth) > 7 && auth[:7] == "Bearer " {
|
||||
return auth[7:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: http_session_token_extract
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: pure
|
||||
signature: "func SessionTokenExtract(r *http.Request, cookieName string) string"
|
||||
description: "Extrae el token de sesion de un request HTTP. Comprueba primero la cookie con el nombre indicado; si no esta, parsea el header Authorization 'Bearer <token>'. Retorna cadena vacia si no hay token."
|
||||
tags: [http, session, cookie, bearer, auth, token]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: ""
|
||||
imports: ["net/http"]
|
||||
params:
|
||||
- name: r
|
||||
desc: "request HTTP entrante"
|
||||
- name: cookieName
|
||||
desc: "nombre de la cookie de sesion a buscar (p.ej. 'kanban_session')"
|
||||
output: "token extraido de la cookie o del header Authorization; cadena vacia si no hay token en ninguna fuente"
|
||||
tested: true
|
||||
tests:
|
||||
- "cookie present retorna token de cookie"
|
||||
- "bearer header retorna token de header"
|
||||
- "cookie gana sobre bearer header"
|
||||
- "sin token retorna cadena vacia"
|
||||
test_file_path: "functions/infra/http_session_token_extract_test.go"
|
||||
file_path: "functions/infra/http_session_token_extract.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
func authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := infra.SessionTokenExtract(r, "my_session")
|
||||
if token == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// validate token...
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/auth.go. Funcion pura: solo lee el request, no muta estado. La cookie tiene precedencia sobre el header Authorization para mantener consistencia con el comportamiento del browser (la cookie es el canal primario; el header es para clientes API que no gestionan cookies).
|
||||
@@ -0,0 +1,46 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSessionTokenExtract(t *testing.T) {
|
||||
const cookieName = "test_session"
|
||||
|
||||
t.Run("cookie present retorna token de cookie", func(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: cookieName, Value: "tok_cookie"})
|
||||
got := SessionTokenExtract(r, cookieName)
|
||||
if got != "tok_cookie" {
|
||||
t.Errorf("got %q, want %q", got, "tok_cookie")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bearer header retorna token de header", func(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Authorization", "Bearer tok_bearer")
|
||||
got := SessionTokenExtract(r, cookieName)
|
||||
if got != "tok_bearer" {
|
||||
t.Errorf("got %q, want %q", got, "tok_bearer")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cookie gana sobre bearer header", func(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: cookieName, Value: "tok_cookie"})
|
||||
r.Header.Set("Authorization", "Bearer tok_bearer")
|
||||
got := SessionTokenExtract(r, cookieName)
|
||||
if got != "tok_cookie" {
|
||||
t.Errorf("got %q, want %q (cookie should win)", got, "tok_cookie")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sin token retorna cadena vacia", func(t *testing.T) {
|
||||
r, _ := http.NewRequest("GET", "/", nil)
|
||||
got := SessionTokenExtract(r, cookieName)
|
||||
if got != "" {
|
||||
t.Errorf("got %q, want empty string", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ApplyMigrations reads SQL files matching glob from fsys, sorts them
|
||||
// lexicographically (NNN_name.sql order), and executes every statement
|
||||
// found in each file against conn.
|
||||
//
|
||||
// If glob is empty, it defaults to "migrations/*.sql".
|
||||
//
|
||||
// Each statement is executed individually. Errors that look like
|
||||
// idempotent SQLite errors (duplicate column, already exists) are
|
||||
// silently ignored so that migrations can be replayed safely against
|
||||
// a database that was partially migrated.
|
||||
//
|
||||
// NOTE: the internal statement parser splits on ";" at the end of a
|
||||
// trimmed line. This is intentionally simple and will break if SQL
|
||||
// strings contain a literal semicolon at end-of-line. Avoid using
|
||||
// such patterns in migration files.
|
||||
func ApplyMigrations(conn *sql.DB, fsys fs.FS, glob string) error {
|
||||
if glob == "" {
|
||||
glob = "migrations/*.sql"
|
||||
}
|
||||
files, err := fs.Glob(fsys, glob)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.Strings(files)
|
||||
for _, f := range files {
|
||||
b, err := fs.ReadFile(fsys, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, stmt := range splitSQLStatements(string(b)) {
|
||||
s := strings.TrimSpace(stmt)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := conn.Exec(s); err != nil {
|
||||
if isIdempotentMigrationError(err) {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("%s: %w", f, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitSQLStatements splits a SQL script into individual statements.
|
||||
// Lines starting with "--" (comments) and blank lines are skipped.
|
||||
// A statement ends when a trimmed line ends with ";".
|
||||
func splitSQLStatements(s string) []string {
|
||||
out := []string{}
|
||||
cur := strings.Builder{}
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
trim := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trim, "--") || trim == "" {
|
||||
continue
|
||||
}
|
||||
cur.WriteString(line)
|
||||
cur.WriteString("\n")
|
||||
if strings.HasSuffix(trim, ";") {
|
||||
out = append(out, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
out = append(out, cur.String())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isIdempotentMigrationError returns true for SQLite errors that arise
|
||||
// from re-applying a migration that was already applied (duplicate
|
||||
// column, table/index already exists).
|
||||
func isIdempotentMigrationError(err error) bool {
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "duplicate column") ||
|
||||
strings.Contains(msg, "already exists")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: sqlite_apply_migrations
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ApplyMigrations(conn *sql.DB, fsys fs.FS, glob string) error"
|
||||
description: "Aplica migraciones SQL desde un fs.FS en orden lexicografico. Lee archivos con glob (default 'migrations/*.sql'), divide por sentencias y ejecuta cada una contra conn. Errores idempotentes (duplicate column, already exists) se ignoran."
|
||||
tags: [database, sqlite, migration, schema, embed, fs]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["database/sql", "fmt", "io/fs", "sort", "strings"]
|
||||
params:
|
||||
- name: conn
|
||||
desc: "conexion *sql.DB donde se ejecutan las migraciones"
|
||||
- name: fsys
|
||||
desc: "sistema de archivos con los .sql (tipicamente embed.FS del caller)"
|
||||
- name: glob
|
||||
desc: "patron glob para seleccionar archivos (vacio = 'migrations/*.sql')"
|
||||
output: "nil si todas las migraciones se aplicaron correctamente; error del primer fallo no idempotente con nombre de archivo incluido"
|
||||
tested: true
|
||||
tests:
|
||||
- "una migracion se aplica correctamente"
|
||||
- "multiples migraciones en orden"
|
||||
- "error real se propaga"
|
||||
- "ALTER TABLE ADD COLUMN duplicado se ignora"
|
||||
test_file_path: "functions/infra/sqlite_apply_migrations_test.go"
|
||||
file_path: "functions/infra/sqlite_apply_migrations.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
func openDB(path string) (*sql.DB, error) {
|
||||
db, err := infra.SQLiteOpen(path, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := infra.ApplyMigrations(db, migrationsFS, ""); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/db.go. El parser de sentencias SQL es intencionalmente simple: separa por `;` al final de una linea (ignorando comentarios `--` y lineas vacias). Esta logica falla si el SQL contiene un `;` dentro de un string literal al final de linea — evitar ese patron en los archivos de migracion.
|
||||
|
||||
Los errores idempotentes (`duplicate column`, `already exists`) se ignoran para que las migraciones sean re-ejecutables contra DBs que ya tenian parte del schema. Esto permite un flujo sin tabla `_migrations` para proyectos pequenos; para proyectos con muchas migraciones y rollback, usar `migration_up_go_infra` / `migration_down_go_infra`.
|
||||
@@ -0,0 +1,98 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// makeFS builds a simple in-memory FS with the given path→content pairs.
|
||||
func makeFS(files map[string]string) fstest.MapFS {
|
||||
m := make(fstest.MapFS, len(files))
|
||||
for path, content := range files {
|
||||
m[path] = &fstest.MapFile{Data: []byte(content)}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestApplyMigrations(t *testing.T) {
|
||||
t.Run("una migracion se aplica correctamente", func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
fsys := makeFS(map[string]string{
|
||||
"migrations/001_init.sql": `CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, name TEXT);`,
|
||||
})
|
||||
if err := ApplyMigrations(db, fsys, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Verify table exists.
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='items'`).Scan(&count); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("table 'items' not created; count=%d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multiples migraciones en orden", func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
fsys := makeFS(map[string]string{
|
||||
"migrations/001_init.sql": `CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY);`,
|
||||
"migrations/002_add_column.sql": `ALTER TABLE t ADD COLUMN val TEXT;`,
|
||||
})
|
||||
if err := ApplyMigrations(db, fsys, ""); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Insert using the new column.
|
||||
if _, err := db.Exec(`INSERT INTO t (id, val) VALUES (1, 'hello')`); err != nil {
|
||||
t.Fatalf("insert failed (column may be missing): %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error real se propaga", func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
fsys := makeFS(map[string]string{
|
||||
"migrations/001_bad.sql": `THIS IS NOT VALID SQL;`,
|
||||
})
|
||||
if err := ApplyMigrations(db, fsys, ""); err == nil {
|
||||
t.Errorf("expected error for invalid SQL, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ALTER TABLE ADD COLUMN duplicado se ignora", func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
setup := `CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, val TEXT);`
|
||||
if _, err := db.Exec(setup); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Run ALTER that would fail with "duplicate column".
|
||||
fsys := makeFS(map[string]string{
|
||||
"migrations/001_dup.sql": `ALTER TABLE t ADD COLUMN val TEXT;`,
|
||||
})
|
||||
if err := ApplyMigrations(db, fsys, ""); err != nil {
|
||||
t.Errorf("duplicate column error should be ignored, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ColumnExists reports whether the named column exists in the given table
|
||||
// by querying PRAGMA table_info. Returns false if the table does not exist.
|
||||
func ColumnExists(conn *sql.DB, table, name string) (bool, error) {
|
||||
rows, err := conn.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName, ctype string
|
||||
var notnull int
|
||||
var dflt sql.NullString
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &colName, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if colName == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: sqlite_column_exists
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func ColumnExists(conn *sql.DB, table, name string) (bool, error)"
|
||||
description: "Comprueba si una columna existe en una tabla SQLite consultando PRAGMA table_info. Retorna false sin error si la tabla no existe."
|
||||
tags: [database, sqlite, schema, pragma, column, migration]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["database/sql", "fmt"]
|
||||
params:
|
||||
- name: conn
|
||||
desc: "conexion SQLite abierta"
|
||||
- name: table
|
||||
desc: "nombre de la tabla a inspeccionar"
|
||||
- name: name
|
||||
desc: "nombre de la columna a buscar"
|
||||
output: "true si la columna existe, false si no existe o la tabla no existe; error si la query falla"
|
||||
tested: true
|
||||
tests:
|
||||
- "columna existente retorna true"
|
||||
- "columna inexistente retorna false"
|
||||
- "tabla inexistente retorna false sin error"
|
||||
test_file_path: "functions/infra/sqlite_column_exists_test.go"
|
||||
file_path: "functions/infra/sqlite_column_exists.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
exists, err := ColumnExists(db, "cards", "assignee_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
_, err = db.Exec(`ALTER TABLE cards ADD COLUMN assignee_id TEXT`)
|
||||
}
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de apps/kanban/backend/db.go. Util como comprobacion previa a ALTER TABLE ADD COLUMN en scripts de migracion que necesitan ser idempotentes. PRAGMA table_info retorna cero filas si la tabla no existe, por lo que la funcion retorna false sin error en ese caso.
|
||||
@@ -0,0 +1,59 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func openMemDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("open :memory: db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestColumnExists(t *testing.T) {
|
||||
t.Run("columna existente retorna true", func(t *testing.T) {
|
||||
db := openMemDB(t)
|
||||
if _, err := db.Exec(`CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ColumnExists(db, "t", "name")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !got {
|
||||
t.Errorf("expected true for existing column 'name'")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("columna inexistente retorna false", func(t *testing.T) {
|
||||
db := openMemDB(t)
|
||||
if _, err := db.Exec(`CREATE TABLE t (id INTEGER PRIMARY KEY)`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ColumnExists(db, "t", "missing")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Errorf("expected false for missing column")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("tabla inexistente retorna false sin error", func(t *testing.T) {
|
||||
db := openMemDB(t)
|
||||
got, err := ColumnExists(db, "no_such_table", "col")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got {
|
||||
t.Errorf("expected false for non-existent table")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: DurationStats
|
||||
lang: go
|
||||
domain: datascience
|
||||
version: "1.0.0"
|
||||
algebraic: product
|
||||
definition: |
|
||||
type DurationStats struct {
|
||||
N int `json:"n"`
|
||||
AvgMs int64 `json:"avg_ms"`
|
||||
P50Ms int64 `json:"p50_ms"`
|
||||
P90Ms int64 `json:"p90_ms"`
|
||||
P99Ms int64 `json:"p99_ms"`
|
||||
}
|
||||
description: "Estadisticas descriptivas de un conjunto de duraciones expresadas en milisegundos: conteo, media y percentiles P50/P90/P99."
|
||||
tags: [statistics, duration, percentile, metrics, int64]
|
||||
uses_types: []
|
||||
file_path: "functions/datascience/DurationStats.go"
|
||||
source_repo: "https://github.com/egutierrez/fn_registry/apps/kanban"
|
||||
source_license: "private"
|
||||
source_file: "apps/kanban/backend/metrics.go"
|
||||
---
|
||||
|
||||
## Notas
|
||||
|
||||
Extraido de `apps/kanban/backend/metrics.go:62-68`.
|
||||
Producido por `DurationStatsFrom` (`duration_stats_go_datascience`).
|
||||
El valor cero `DurationStats{}` indica un conjunto de duraciones vacio (N=0).
|
||||
Reference in New Issue
Block a user