fix(infra): gradle_run detecta android-sdk — issue 0076 #2

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