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/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 => (
+ |
+ {col.label}
+ |
+ ))}
+
+
+
+ {(data ?? []).map((row, i) => (
+
+ {effectiveColumns.map(col => {
+ const align = col.align ?? (typeof row[col.key] === 'number' ? 'right' : 'left')
+ return (
+ |
+ {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/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/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 }
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
})}
)
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 = ""`.