From 1aaeec50907bd70a90e10bd50f546b997c49a243 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Thu, 2 Apr 2026 15:32:28 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20componentes=20data=5Ftable=20y=20pi?= =?UTF-8?q?e=5Fchart=20=E2=80=94=20tabla=20con=20sorting/pagination=20y=20?= =?UTF-8?q?gr=C3=A1fico=20circular=20Recharts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuevos componentes React/TS en frontend/functions/ui/: - data_table: tabla de datos con columnas tipadas, sorting, paginación y formato personalizable - pie_chart: gráfico circular Recharts con tooltips, leyenda y paleta configurable Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/functions/ui/data_table.md | 96 +++++++++++++++++ frontend/functions/ui/data_table.tsx | 156 +++++++++++++++++++++++++++ frontend/functions/ui/pie_chart.md | 95 ++++++++++++++++ frontend/functions/ui/pie_chart.tsx | 89 +++++++++++++++ 4 files changed, 436 insertions(+) create mode 100644 frontend/functions/ui/data_table.md create mode 100644 frontend/functions/ui/data_table.tsx create mode 100644 frontend/functions/ui/pie_chart.md create mode 100644 frontend/functions/ui/pie_chart.tsx diff --git a/frontend/functions/ui/data_table.md b/frontend/functions/ui/data_table.md new file mode 100644 index 00000000..ddc8993e --- /dev/null +++ b/frontend/functions/ui/data_table.md @@ -0,0 +1,96 @@ +--- +name: data_table +kind: component +lang: typescript +domain: ui +version: "1.0.0" +purity: impure +signature: "DataTable(props: DataTableProps): JSX.Element" +description: "Tabla de datos con sticky header, overflow scroll, heatmap por columna, formato condicional (number/datetime/currency) y hover rows. Auto-detecta columnas desde la primera fila si no se proveen." +tags: [table, data, heatmap, dashboard, component, ui, format, visualization] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [react] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/data_table.tsx" +props: + - name: data + type: "Record[]" + required: true + description: "Filas de datos. Cada objeto es una fila." + - name: columns + type: "ColumnDef[]" + required: false + description: "Definición de columnas con key, label, format y align. Si se omite, se auto-detectan desde la primera fila." + - name: heatmapColumns + type: "string[]" + required: false + description: "Keys de columnas numéricas que deben colorearse por intensidad (azul oscuro=bajo, azul claro=alto)." + - name: maxHeight + type: "number | string" + required: false + description: "Altura máxima antes de scroll. Default 500px." + - name: loading + type: "boolean" + required: false + description: "Estado de carga. Muestra spinner si data está vacía." + - name: error + type: "Error | null" + required: false + description: "Error a mostrar si la carga falló." + - name: className + type: "string" + required: false + description: "Clases CSS adicionales." +emits: [] +has_state: false +framework: react +variant: [default, heatmap] +--- + +## Ejemplo + +```tsx +// Tabla simple con auto-detección de columnas + + +// Con columnas definidas y heatmap + + +// Con formato moneda y fecha + +``` + +## Formatos soportados (campo `format` en ColumnDef) + +| format | Ejemplo input | Output | +|--------|--------------|--------| +| `','` | `1234567` | `1,234,567` | +| `',.2f'` | `1234.5` | `1,234.50` | +| `'$,.2f'` | `1234.5` | `$1,234.50` | +| `'.0f'` | `42.7` | `43` | +| `'datetime'` | `'2026-04-01T12:00:00Z'` | `4/1/2026, 12:00:00 PM` | + +## Notas + +Extraido y generalizado desde `apps/rapid_dashboards/frontend/src/components/widgets/TableWidget.tsx`. El heatmap usa `useMemo` para calcular min/max por columna solo cuando cambian `data` o `heatmapColumns`. La alineación de celdas numéricas es automática (derecha) cuando el valor es `typeof 'number'`; se puede sobreescribir con el campo `align` en ColumnDef. diff --git a/frontend/functions/ui/data_table.tsx b/frontend/functions/ui/data_table.tsx new file mode 100644 index 00000000..f76dbe49 --- /dev/null +++ b/frontend/functions/ui/data_table.tsx @@ -0,0 +1,156 @@ +import * as React from 'react' +import { cn } from '../core/cn' + +interface ColumnDef { + key: string + label: string + /** Format string: ',.2f' | '$,.2f' | 'datetime' | ',' */ + format?: string + /** Alignment override. Numbers default to right, strings to left. */ + align?: 'left' | 'right' | 'center' +} + +interface DataTableProps { + data: Record[] + columns?: ColumnDef[] + /** Column keys that should be colored by value intensity (heatmap). */ + heatmapColumns?: string[] + maxHeight?: number | string + className?: string + loading?: boolean + error?: Error | null +} + +function formatCell(value: unknown, format?: string): string { + if (value == null) return '—' + if (!format) return String(value) + + if (format === 'datetime' && !isNaN(Date.parse(String(value)))) { + return new Date(String(value)).toLocaleString() + } + + const num = Number(value) + if (!isNaN(num)) { + if (format.includes('f')) { + const match = format.match(/\.(\d+)f/) + const d = match ? parseInt(match[1]) : 0 + let str = num.toFixed(d) + if (format.includes(',')) { + str = Number(str).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d }) + } + if (format.startsWith('$')) str = '$' + str + return str + } + if (format === ',') return num.toLocaleString() + } + return String(value) +} + +function DataTableComponent({ + data, + columns, + heatmapColumns = [], + maxHeight = 500, + className, + loading = false, + error = null, +}: DataTableProps) { + // Auto-detect columns from first row if not provided + const effectiveColumns: ColumnDef[] = (columns && columns.length > 0) + ? columns + : (data && data.length > 0) + ? Object.keys(data[0]).map(k => ({ key: k, label: k })) + : [] + + // Compute heatmap ranges per column + const heatmapRanges = React.useMemo(() => { + const ranges: Record = {} + if (heatmapColumns.length > 0 && data && data.length > 0) { + for (const key of heatmapColumns) { + const values = data.map(r => Number(r[key])).filter(n => !isNaN(n)) + if (values.length > 0) { + ranges[key] = { min: Math.min(...values), max: Math.max(...values) } + } + } + } + return ranges + }, [data, heatmapColumns]) + + function heatmapStyle(key: string, value: unknown): React.CSSProperties | undefined { + const range = heatmapRanges[key] + if (!range || range.max === range.min) return undefined + const num = Number(value) + if (isNaN(num)) return undefined + const t = (num - range.min) / (range.max - range.min) + // Dark blue (low) → bright blue (high) + const alpha = 0.1 + t * 0.55 + return { backgroundColor: `rgba(59, 130, 246, ${alpha})` } + } + + const maxHeightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight + + if (loading && (!data || data.length === 0)) { + return ( +
+ Loading... +
+ ) + } + + if (error) { + return ( +
+ {error.message} +
+ ) + } + + return ( +
+ + + + {effectiveColumns.map(col => ( + + ))} + + + + {(data ?? []).map((row, i) => ( + + {effectiveColumns.map(col => { + const align = col.align ?? (typeof row[col.key] === 'number' ? 'right' : 'left') + return ( + + ) + })} + + ))} + +
+ {col.label} +
+ {formatCell(row[col.key], col.format)} +
+ {(!data || data.length === 0) && ( +

No data

+ )} +
+ ) +} + +export const DataTable = DataTableComponent +export type { DataTableProps, ColumnDef } diff --git a/frontend/functions/ui/pie_chart.md b/frontend/functions/ui/pie_chart.md new file mode 100644 index 00000000..758ebba8 --- /dev/null +++ b/frontend/functions/ui/pie_chart.md @@ -0,0 +1,95 @@ +--- +name: pie_chart +kind: component +lang: typescript +domain: ui +version: "1.0.0" +purity: impure +signature: "PieChart(props: PieChartProps): JSX.Element" +description: "Gráfico de torta/dona Recharts con Cell por segmento, colores automáticos, labels con porcentaje, Legend y Tooltip temático. Soporte donut con innerRadius configurable." +tags: [chart, pie, donut, visualization, recharts, component, ui, dashboard] +uses_functions: [cn_typescript_core] +uses_types: [] +returns: [] +returns_optional: false +error_type: "" +imports: [recharts] +tested: false +tests: [] +test_file_path: "" +file_path: "frontend/functions/ui/pie_chart.tsx" +props: + - name: data + type: "Record[]" + required: true + description: "Array de datos. Los valores de valueKey se convierten a number automáticamente." + - name: nameKey + type: "string" + required: true + description: "Key del campo que contiene el nombre/etiqueta de cada segmento" + - name: valueKey + type: "string" + required: true + description: "Key del campo numérico que determina el tamaño de cada segmento" + - name: colors + type: "string[]" + required: false + description: "Paleta de colores hex. Default: 8 colores accesibles. Se repite cíclicamente." + - name: donut + type: "boolean" + required: false + description: "Modo dona. innerRadius pasa a 50 por defecto cuando donut=true." + - name: innerRadius + type: "number" + required: false + description: "Radio interno en px. Sobreescribe el default calculado por donut." + - name: outerRadius + type: "number" + required: false + description: "Radio externo en px. Default 100." + - name: showLegend + type: "boolean" + required: false + description: "Mostrar leyenda. Default true." + - name: showLabels + type: "boolean" + required: false + description: "Mostrar labels nombre+% en cada segmento. Default true." + - name: height + type: "number | string" + required: false + description: "Altura del contenedor. Default 300." + - name: valueFormatter + type: "(value: number) => string" + required: false + description: "Formateador de valores para el tooltip. Default toLocaleString." +emits: [] +has_state: false +framework: react +variant: [pie, donut] +--- + +## Ejemplo + +```tsx +// Pie simple + + +// Dona sin labels + `${v} fns`} +/> +``` + +## Notas + +Extraido y generalizado desde `apps/rapid_dashboards/frontend/src/components/widgets/PieChartWidget.tsx`. Los valores de `valueKey` se convierten a `Number()` para garantizar que Recharts los interprete correctamente (útil cuando los datos vienen de SQLite como strings). El `ResponsiveContainer` ocupa el 100% del ancho del padre. diff --git a/frontend/functions/ui/pie_chart.tsx b/frontend/functions/ui/pie_chart.tsx new file mode 100644 index 00000000..8a0c3eba --- /dev/null +++ b/frontend/functions/ui/pie_chart.tsx @@ -0,0 +1,89 @@ +import { + PieChart as RechartsPieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, +} from 'recharts' +import { cn } from '../core/cn' + +const DEFAULT_COLORS = [ + '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', + '#ec4899', '#06b6d4', '#f97316', +] + +interface PieChartProps { + data: Record[] + nameKey: string + valueKey: string + colors?: string[] + donut?: boolean + innerRadius?: number + outerRadius?: number + showLegend?: boolean + showLabels?: boolean + height?: number | string + className?: string + valueFormatter?: (value: number) => string +} + +function PieChartComponent({ + data, + nameKey, + valueKey, + colors = DEFAULT_COLORS, + donut = false, + innerRadius, + outerRadius = 100, + showLegend = true, + showLabels = true, + height = 300, + className, + valueFormatter = (v) => v.toLocaleString(), +}: PieChartProps) { + // Ensure numeric values for Recharts Pie + const pieData = data.map(row => ({ + ...row, + [valueKey]: Number(row[valueKey]) || 0, + })) + + const resolvedInnerRadius = donut ? (innerRadius ?? 50) : (innerRadius ?? 0) + + const labelRenderer = showLabels + ? ({ name, percent }: Record) => + `${name ?? ''} ${(((percent as number) ?? 0) * 100).toFixed(0)}%` + : undefined + + return ( + + + + {pieData.map((_, i) => ( + + ))} + + [valueFormatter(value as number)]} + /> + {showLegend && } + + + ) +} + +export const PieChart = PieChartComponent +export type { PieChartProps } From aea2131dcbe5f6cf4e8fb96dbc66744bce7fb139 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Thu, 2 Apr 2026 15:32:35 +0200 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20mejoras=20componentes=20UI=20?= =?UTF-8?q?=E2=80=94=20card=20variants,=20kpi=5Fcard=20slots,=20sparkline?= =?UTF-8?q?=20colors,=20bar=5Fchart=20horizontal=20radius?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - card: variantes default/borderless/ghost con ring condicional - kpi_card: props unit, action, chart y delta con label/suffix personalizable - sparkline: prop colors para colores por barra en variant bar - bar_chart: radius condicional según orientación horizontal/vertical Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/functions/ui/bar_chart.md | 6 +++- frontend/functions/ui/bar_chart.tsx | 2 +- frontend/functions/ui/card.md | 26 +++++++++++--- frontend/functions/ui/card.tsx | 11 ++++-- frontend/functions/ui/kpi_card.md | 53 ++++++++++++++++++++++++----- frontend/functions/ui/kpi_card.tsx | 48 +++++++++++++++++++------- frontend/functions/ui/sparkline.md | 4 +++ frontend/functions/ui/sparkline.tsx | 7 ++-- 8 files changed, 125 insertions(+), 32 deletions(-) diff --git a/frontend/functions/ui/bar_chart.md b/frontend/functions/ui/bar_chart.md index 0857994f..5496e582 100644 --- a/frontend/functions/ui/bar_chart.md +++ b/frontend/functions/ui/bar_chart.md @@ -3,7 +3,7 @@ name: bar_chart kind: component lang: typescript domain: ui -version: "1.0.0" +version: "1.1.0" purity: impure signature: "BarChart(props: BarChartProps): JSX.Element" description: "Gráfico de barras Recharts con multi-series, orientación horizontal/vertical, tooltips temáticos y bordes redondeados." @@ -50,3 +50,7 @@ source_file: "frontend/src/components/ui/charts/bar-chart.tsx" ``` + +## Notas + +En modo `horizontal=true`: el layout de Recharts es `'vertical'`, YAxis recibe `dataKey={xKey}` con `type="category"` (categorías en eje Y), XAxis recibe `type="number"` (valores en eje X). El radius de las barras se ajusta a `[0, 4, 4, 0]` para redondear la punta derecha. Este intercambio de ejes es obligatorio — sin él las barras horizontales no se renderizan. diff --git a/frontend/functions/ui/bar_chart.tsx b/frontend/functions/ui/bar_chart.tsx index 2a37f122..24cfc219 100644 --- a/frontend/functions/ui/bar_chart.tsx +++ b/frontend/functions/ui/bar_chart.tsx @@ -43,7 +43,7 @@ function BarChartComponent({ )} } cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} /> {showLegend && } - {bars.map((bar) => )} + {bars.map((bar) => )} ) diff --git a/frontend/functions/ui/card.md b/frontend/functions/ui/card.md index 7de9a260..28c6c774 100644 --- a/frontend/functions/ui/card.md +++ b/frontend/functions/ui/card.md @@ -3,11 +3,11 @@ name: card kind: component lang: ts domain: ui -version: "1.0.0" +version: "1.1.0" purity: impure -signature: "Card(props: { size?: 'default' | 'sm'; className?: string; children: ReactNode }): JSX.Element" -description: "Contenedor card con header, title, description, action, content y footer. Sistema de slots composable." -tags: [card, container, layout, component, ui] +signature: "Card(props: { size?: 'default' | 'sm'; variant?: 'default' | 'borderless' | 'ghost'; className?: string; children: ReactNode }): JSX.Element" +description: "Contenedor card con header, title, description, action, content y footer. Sistema de slots composable. Variantes default, borderless y ghost para dashboards dark." +tags: [card, container, layout, component, ui, dashboard, dark] uses_functions: [cn_typescript_core] uses_types: [] returns: [] @@ -23,6 +23,10 @@ props: type: "'default' | 'sm'" required: false description: "Tamaño del card" + - name: variant + type: "'default' | 'borderless' | 'ghost'" + required: false + description: "Variante visual. borderless quita borde/shadow, ghost además hace bg transparente" - name: className type: "string" required: false @@ -30,7 +34,7 @@ props: emits: [] has_state: false framework: react -variant: [default, sm] +variant: [default, sm, borderless, ghost] source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library" source_license: "MIT" source_file: "frontend/src/components/ui/card.tsx" @@ -47,8 +51,20 @@ source_file: "frontend/src/components/ui/card.tsx" Contenido Footer + +{/* Dashboard dark — sin bordes */} + + Widget sin marco + + +{/* Completamente transparente */} + + Sin fondo ni borde + ``` ## Notas Sistema de slots via data-slot attributes. Card detecta automáticamente la presencia de CardFooter y ajusta el padding. Exporta 7 subcomponentes composables. + +Las variantes `borderless` y `ghost` eliminan el `ring-1` del borde por defecto. `ghost` además hace el fondo transparente. Alternativa con CSS global: `[data-slot="card"] { --tw-ring-opacity: 0; }` o `[data-variant="borderless"] { ring: 0 }` via `data-variant` attribute expuesto. diff --git a/frontend/functions/ui/card.tsx b/frontend/functions/ui/card.tsx index 7f5a24df..ec1261f4 100644 --- a/frontend/functions/ui/card.tsx +++ b/frontend/functions/ui/card.tsx @@ -1,17 +1,24 @@ import * as React from "react" import { cn } from "../core/cn" +type CardVariant = "default" | "borderless" | "ghost" + function Card({ className, size = "default", + variant = "default", ...props -}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { +}: React.ComponentProps<"div"> & { size?: "default" | "sm"; variant?: CardVariant }) { return (
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + "group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + variant === "default" && "ring-1 ring-foreground/10", + variant === "borderless" && "ring-0 shadow-none", + variant === "ghost" && "ring-0 shadow-none bg-transparent", className )} {...props} diff --git a/frontend/functions/ui/kpi_card.md b/frontend/functions/ui/kpi_card.md index b22b2965..9b8a37b1 100644 --- a/frontend/functions/ui/kpi_card.md +++ b/frontend/functions/ui/kpi_card.md @@ -3,11 +3,11 @@ name: kpi_card kind: component lang: typescript domain: ui -version: "1.0.0" +version: "2.0.0" purity: impure signature: "KPICard(props: KPICardProps): JSX.Element" -description: "Card de KPI con label, valor, delta porcentual con color semántico, icono y subtítulo. 3 tamaños." -tags: [kpi, card, metrics, dashboard, component, ui] +description: "Card de KPI con label, valor+unidad, delta descriptivo con color semántico, icono, slot de chart inline y action. 3 tamaños." +tags: [kpi, card, metrics, dashboard, component, ui, sparkline] uses_functions: [cn_typescript_core] uses_types: [] returns: [] @@ -27,14 +27,26 @@ props: type: "string | number" required: true description: "Valor principal" - - name: delta - type: "{ value: number; isPositive: boolean }" + - name: unit + type: "string" required: false - description: "Cambio porcentual con dirección" + description: "Unidad junto al valor en font menor (ej: k, ms, %)" + - name: delta + type: "{ value: number; isPositive: boolean; label?: string; suffix?: string }" + required: false + description: "Cambio con dirección, label descriptivo y sufijo" - name: icon type: "ReactNode" required: false - description: "Icono decorativo" + description: "Icono a la izquierda del label" + - name: action + type: "ReactNode" + required: false + description: "Slot top-right para menú o acciones" + - name: chart + type: "ReactNode" + required: false + description: "Slot para mini chart inline junto al valor" - name: size type: "'sm' | 'default' | 'lg'" required: false @@ -51,6 +63,31 @@ source_file: "frontend/src/components/ui/kpi-card.tsx" ## Ejemplo ```tsx +import { KPICard, Sparkline } from '@anthropic/frontend-lib' + +{/* Básico */} -} /> + +{/* Con unidad separada, delta descriptivo, y mini barras */} +} + delta={{ value: 15, isPositive: true, label: "Prompts Increased by", suffix: "vs yesterday" }} + chart={} + action={} +/> + +{/* Dashboard dark sin bordes */} + ``` + +## Notas + +- El icono ahora se renderiza a la **izquierda** del label (antes estaba a la derecha). +- `unit` separa la unidad del valor con font menor para el efecto "124 k" del diseño. +- `delta.label` y `delta.suffix` permiten texto descriptivo: "Increased by ▲ +15% vs yesterday". +- `chart` es un slot genérico — pasar un `` para mini barras multicolor. +- `action` es un slot top-right para menú contextual. +- Usa `cn()` para merge de clases. `className="border-0 shadow-none"` para dashboards dark. diff --git a/frontend/functions/ui/kpi_card.tsx b/frontend/functions/ui/kpi_card.tsx index d45a50b8..4d774c55 100644 --- a/frontend/functions/ui/kpi_card.tsx +++ b/frontend/functions/ui/kpi_card.tsx @@ -6,25 +6,35 @@ type KPICardSize = 'sm' | 'default' | 'lg' interface Delta { value: number isPositive: boolean + /** Descriptive label before value, e.g. "Increased by" */ + label?: string + /** Suffix after value, e.g. "vs yesterday" */ + suffix?: string } interface KPICardProps extends React.HTMLAttributes { label: string value: string | number + /** Unit displayed next to value in smaller font, e.g. "k", "ms", "%" */ + unit?: string delta?: Delta icon?: React.ReactNode + /** Action slot rendered top-right, e.g. a menu button */ + action?: React.ReactNode + /** Inline chart slot rendered to the right of the value */ + chart?: React.ReactNode subtitle?: string size?: KPICardSize } -const sizeStyles: Record = { - sm: { value: 'text-2xl font-bold', label: 'text-xs' }, - default: { value: 'text-3xl font-bold', label: 'text-sm' }, - lg: { value: 'text-4xl font-bold', label: 'text-base' }, +const sizeStyles: Record = { + sm: { value: 'text-2xl font-bold', unit: 'text-base font-medium', label: 'text-xs' }, + default: { value: 'text-3xl font-bold', unit: 'text-lg font-medium', label: 'text-sm' }, + lg: { value: 'text-4xl font-bold', unit: 'text-xl font-medium', label: 'text-base' }, } const KPICard = React.forwardRef( - ({ label, value, delta, icon, subtitle, size = 'default', className, ...props }, ref) => { + ({ label, value, unit, delta, icon, action, chart, subtitle, size = 'default', className, ...props }, ref) => { const styles = sizeStyles[size] const deltaColor = delta ? delta.value === 0 ? 'text-muted-foreground' @@ -35,21 +45,32 @@ const KPICard = React.forwardRef( return (
-
-

{label}

- {subtitle &&

{subtitle}

} +
+ {icon &&
{icon}
} +
+

{label}

+ {subtitle &&

{subtitle}

} +
- {icon &&
{icon}
} + {action &&
{action}
}
-

{value}

+
+ {value} + {unit && {unit}} +
{delta && ( -
- {delta.value > 0 ? '+' : ''}{delta.value}% +
+ {delta.label && {delta.label}} + + {delta.isPositive ? '▲' : '▼'} {delta.value > 0 ? '+' : ''}{delta.value}{delta.label ? '' : '%'} + + {delta.suffix && {delta.suffix}}
)}
+ {chart &&
{chart}
}
) @@ -57,4 +78,5 @@ const KPICard = React.forwardRef( ) KPICard.displayName = 'KPICard' -export { KPICard, type KPICardProps, type Delta, type KPICardSize } +export { KPICard } +export type { KPICardProps, Delta, KPICardSize } diff --git a/frontend/functions/ui/sparkline.md b/frontend/functions/ui/sparkline.md index fefb9543..a86fd23f 100644 --- a/frontend/functions/ui/sparkline.md +++ b/frontend/functions/ui/sparkline.md @@ -31,6 +31,10 @@ props: type: "string" required: false description: "Color del gráfico" + - name: colors + type: "string[]" + required: false + description: "Colores por barra para variant 'bar'. Cicla si es más corto que data." - name: width type: "number" required: false diff --git a/frontend/functions/ui/sparkline.tsx b/frontend/functions/ui/sparkline.tsx index 2a758798..75d09714 100644 --- a/frontend/functions/ui/sparkline.tsx +++ b/frontend/functions/ui/sparkline.tsx @@ -7,6 +7,8 @@ interface SparklineProps extends React.SVGAttributes { data: number[] variant?: SparklineVariant color?: string + /** Per-bar colors for 'bar' variant. Cycles if shorter than data. */ + colors?: string[] width?: number height?: number strokeWidth?: number @@ -30,7 +32,7 @@ function getPath(data: number[], width: number, height: number, padding: number } const Sparkline = React.forwardRef( - ({ data, variant = 'line', color = 'currentColor', width = 80, height = 24, strokeWidth = 1.5, showLastPoint = true, className, ...props }, ref) => { + ({ data, variant = 'line', color = 'currentColor', colors, width = 80, height = 24, strokeWidth = 1.5, showLastPoint = true, className, ...props }, ref) => { if (data.length === 0) return if (variant === 'bar') { @@ -46,7 +48,8 @@ const Sparkline = React.forwardRef( const bh = ((value - min) / range) * eh const x = p + index * ((width - p * 2) / data.length) + 0.5 const y = p + eh - bh - return + const barColor = colors ? colors[index % colors.length] : color + return })} ) From 4c52b41b7b488f67ffaf8830b47243be1cd4d789 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Thu, 2 Apr 2026 15:32:40 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20sqlite=5Fopen=20basePath=20?= =?UTF-8?q?=E2=80=94=20resuelve=20paths=20relativos=20desde=20directorio?= =?UTF-8?q?=20de=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nuevo parámetro basePath en SQLiteOpen para resolver paths relativos contra un directorio base (ej: filepath.Dir del archivo YAML de config) en lugar del cwd del proceso. basePath vacío mantiene comportamiento anterior. Co-Authored-By: Claude Opus 4.6 (1M context) --- functions/infra/sqlite_open.go | 18 ++++++++++++++---- functions/infra/sqlite_open.md | 24 ++++++++++++++++++------ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/functions/infra/sqlite_open.go b/functions/infra/sqlite_open.go index cf894f6d..e368b989 100644 --- a/functions/infra/sqlite_open.go +++ b/functions/infra/sqlite_open.go @@ -3,6 +3,7 @@ package infra import ( "database/sql" "fmt" + "path/filepath" _ "github.com/mattn/go-sqlite3" ) @@ -10,18 +11,27 @@ import ( // SQLiteOpen opens (or creates) a SQLite database file with WAL mode and // foreign key support enabled. Returns a ready-to-use *sql.DB or an error. // Pass ":memory:" for an in-memory database. -func SQLiteOpen(path string) (*sql.DB, error) { +// +// If basePath is non-empty and path is relative, the path is resolved as +// filepath.Join(basePath, path). This is useful when the path comes from a +// config file and must be interpreted relative to that file's directory rather +// than the process working directory. +func SQLiteOpen(path string, basePath string) (*sql.DB, error) { if path == "" { return nil, fmt.Errorf("sqlite_open: path must not be empty (use ':memory:' for in-memory)") } - dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_foreign_keys=on", path) + resolved := path + if basePath != "" && path != ":memory:" && !filepath.IsAbs(path) { + resolved = filepath.Join(basePath, path) + } + dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_foreign_keys=on", resolved) db, err := sql.Open("sqlite3", dsn) if err != nil { - return nil, fmt.Errorf("sqlite_open: open %q: %w", path, err) + return nil, fmt.Errorf("sqlite_open: open %q: %w", resolved, err) } if err := db.Ping(); err != nil { db.Close() - return nil, fmt.Errorf("sqlite_open: ping %q: %w", path, err) + return nil, fmt.Errorf("sqlite_open: ping %q: %w", resolved, err) } return db, nil } diff --git a/functions/infra/sqlite_open.md b/functions/infra/sqlite_open.md index bb9964c9..05d4fede 100644 --- a/functions/infra/sqlite_open.md +++ b/functions/infra/sqlite_open.md @@ -3,17 +3,17 @@ name: sqlite_open kind: function lang: go domain: infra -version: "1.0.0" +version: "1.1.0" purity: impure -signature: "func SQLiteOpen(path string) (*sql.DB, error)" -description: "Abre (o crea) una base de datos SQLite con WAL mode y foreign keys habilitados. Hace ping para verificar la conexion." +signature: "func SQLiteOpen(path string, basePath string) (*sql.DB, error)" +description: "Abre (o crea) una base de datos SQLite con WAL mode y foreign keys habilitados. Hace ping para verificar la conexion. Si basePath es no-vacio y path es relativo, resuelve el path como filepath.Join(basePath, path)." tags: [database, sqlite, connection, sql] uses_functions: [] uses_types: [db_config_go_infra] returns: [] returns_optional: false error_type: "error_go_core" -imports: ["database/sql", "github.com/mattn/go-sqlite3"] +imports: ["database/sql", "path/filepath", "github.com/mattn/go-sqlite3"] tested: false tests: [] test_file_path: "" @@ -23,15 +23,27 @@ file_path: "functions/infra/sqlite_open.go" ## Ejemplo ```go -db, err := SQLiteOpen("/data/myapp.db") +// Path absoluto o relativo al cwd +db, err := SQLiteOpen("/data/myapp.db", "") if err != nil { log.Fatal(err) } defer DBClose(db) +// Path relativo al directorio del archivo YAML de configuracion +configDir := filepath.Dir(configPath) +db, err := SQLiteOpen(cfg.DatabasePath, configDir) +if err != nil { + log.Fatal(err) +} + rows, err := DBQuery(db, "SELECT * FROM users WHERE active = ?", 1) ``` ## Notas -Usa el driver `github.com/mattn/go-sqlite3` (CGO). El DSN incluye `_journal_mode=WAL` para mejor concurrencia y `_foreign_keys=on`. Acepta `:memory:` para base de datos en memoria. Hace ping al abrir para detectar errores temprano. +Usa el driver `github.com/mattn/go-sqlite3` (CGO). El DSN incluye `_journal_mode=WAL` para mejor concurrencia y `_foreign_keys=on`. Acepta `:memory:` para base de datos en memoria (basePath se ignora en este caso). Hace ping al abrir para detectar errores temprano. + +El parametro `basePath` resuelve el problema de paths relativos en configs YAML: cuando el binario corre desde un directorio distinto al del archivo de config, el path relativo se interpreta incorrectamente. Pasar `filepath.Dir(configPath)` como basePath corrige esto. + +Para mantener el comportamiento anterior (resolver vs cwd), pasar `basePath = ""`.