chore: sync from fn-registry agent
This commit is contained in:
+17
@@ -0,0 +1,17 @@
|
||||
# Built binary
|
||||
kanban
|
||||
*.exe
|
||||
|
||||
# Operations DB (per-PC state)
|
||||
operations.db
|
||||
operations.db-shm
|
||||
operations.db-wal
|
||||
|
||||
# Frontend build artifacts
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/operations.db
|
||||
frontend/tsconfig.tsbuildinfo
|
||||
|
||||
# Local files
|
||||
local_files/
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
# Plan: Chat lateral con tools sobre todo el kanban
|
||||
|
||||
## Objetivo
|
||||
|
||||
Panel de chat a la derecha (Mantine `AppShell.Aside`) que conversa con un LLM y manipula el kanban via tool-calling: crear/renombrar/mover/borrar columnas y tarjetas, consultar metricas (tiempo en columna, historial), explicar el board, etc.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
+---------------------------+----------------+
|
||||
| Board (DnD) | Chat Aside |
|
||||
| | |
|
||||
| Cols / Cards | msg msg msg |
|
||||
| | [input...] |
|
||||
+---------------------------+----------------+
|
||||
| |
|
||||
v v
|
||||
/api/... /api/chat (SSE)
|
||||
\ /
|
||||
v v
|
||||
kanban backend (Go)
|
||||
|
|
||||
+-- llamadas internas a las
|
||||
mismas funciones del backend
|
||||
(no HTTP loopback)
|
||||
```
|
||||
|
||||
- **No HTTP loopback**: el endpoint `/api/chat` ejecuta los tools llamando directamente a `db.*` (mismas funciones que usan los handlers HTTP). Cero overhead, cero auth-loop.
|
||||
- **SSE streaming**: respuestas + `tool_use` deltas streamed al frontend para UX viva.
|
||||
- **State sharing**: el chat ve y modifica el mismo `operations.db` que la UI. Tras una mutacion via tool, el frontend hace `reload()` del board para reflejar cambios.
|
||||
|
||||
## Backend Go
|
||||
|
||||
### Nuevo paquete `apps/kanban/chat/`
|
||||
|
||||
| Archivo | Responsabilidad |
|
||||
|---|---|
|
||||
| `chat.go` | Endpoint `POST /api/chat` con SSE. Loop: send msgs+tools a Claude API, recibe `tool_use`, ejecuta, reinyecta `tool_result`, hasta `end_turn`. |
|
||||
| `tools.go` | Catalogo de tools: nombre, JSON schema, dispatch a `db.*`. |
|
||||
| `claude.go` | Cliente Claude API (HTTP directo, sin SDK Go porque no hay oficial). Usa `infra.HttpPostJSON` + streaming manual. |
|
||||
|
||||
### Tool catalog (mapeo 1:1 con la API REST + extras)
|
||||
|
||||
| Tool name | Input | Output | DB call |
|
||||
|---|---|---|---|
|
||||
| `list_board` | `{}` | `{columns,cards}` con `time_in_column_ms` | `ListColumns` + `ListCardsWithTime` |
|
||||
| `create_column` | `{name:string}` | `Column` | `CreateColumn` |
|
||||
| `rename_column` | `{id:string, name:string}` | `{}` | `UpdateColumn` |
|
||||
| `delete_column` | `{id:string}` | `{}` | `DeleteColumn` |
|
||||
| `reorder_columns` | `{ids:string[]}` | `{}` | `ReorderColumns` |
|
||||
| `create_card` | `{column_id, requester?, title, description?}` | `Card` | `CreateCard` |
|
||||
| `update_card` | `{id, requester?, title?, description?}` | `{}` | `UpdateCard` |
|
||||
| `delete_card` | `{id}` | `{}` | `DeleteCard` |
|
||||
| `move_card` | `{id, column_id, position?}` | `{}` | `MoveCard` (calcula `ordered_ids` si solo `position`) |
|
||||
| `card_history` | `{id}` | `[{column_name,duration_ms,...}]` | `CardHistory` |
|
||||
| `find_cards` | `{query?:string, column_id?:string, requester?:string}` | `Card[]` | filtro en memoria sobre `ListCardsWithTime` |
|
||||
| `column_stats` | `{column_id}` | `{count, total_time_ms, avg_time_ms, oldest_card_id}` | derivado |
|
||||
| `bulk_create_cards` | `{column_id, cards:[{requester?,title,description?}]}` | `Card[]` | loop `CreateCard` |
|
||||
|
||||
Tools puros (sin escritura) marcados `read_only` en metadata para mostrar badge "solo lectura" en UI.
|
||||
|
||||
### Configuracion
|
||||
|
||||
- Env vars: `ANTHROPIC_API_KEY` (obligatoria), `KANBAN_CHAT_MODEL` (default: `claude-sonnet-4-6`), `KANBAN_CHAT_SYSTEM` (opcional, override del system prompt).
|
||||
- Sistema prompt incluido en el binario:
|
||||
> Eres asistente del tablero kanban. Antes de modificar, llama `list_board` para ver el estado. Cuando el usuario nombre tarjetas o columnas, resuelve el `id` con `find_cards`/`list_board`. Confirma cambios destructivos antes de borrar.
|
||||
- Coste: prompt-caching del system + tools schema (5 min TTL) → llamadas siguientes baratas.
|
||||
|
||||
### SSE protocol al frontend
|
||||
|
||||
```
|
||||
event: text
|
||||
data: "Voy a mover la tarjeta..."
|
||||
|
||||
event: tool_use
|
||||
data: {"id":"toolu_01","name":"move_card","input":{...}}
|
||||
|
||||
event: tool_result
|
||||
data: {"tool_use_id":"toolu_01","ok":true,"result":{...}}
|
||||
|
||||
event: text
|
||||
data: "Hecho. Ahora esta en Doing."
|
||||
|
||||
event: done
|
||||
data: {"stop_reason":"end_turn","usage":{"input_tokens":1234,"output_tokens":56}}
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
### Layout
|
||||
|
||||
`AppShell` con `aside={{ width: 360, breakpoint: "md" }}`. Toggle con icon en header (`IconMessageChatbot`) — colapsable para no robar espacio en monitores chicos.
|
||||
|
||||
### Componentes nuevos
|
||||
|
||||
| Componente | Funcion |
|
||||
|---|---|
|
||||
| `ChatPanel.tsx` | Lista de mensajes + input. Usa `EventSource` para SSE. |
|
||||
| `ChatMessage.tsx` | Renderiza turn: texto markdown, tool_use cards (nombre+input pretty-printed), tool_result chips. |
|
||||
| `useKanbanChat.ts` | Hook: estado de turnos, persistencia en `localStorage`, trigger de `onBoardChange` (reload) tras cada `tool_result` exitoso. |
|
||||
|
||||
### Markdown
|
||||
|
||||
`react-markdown` + `remark-gfm` para tablas/listas en respuestas del LLM.
|
||||
|
||||
### Botones rapidos en cada tarjeta/columna
|
||||
|
||||
`ActionIcon` "Preguntar al chat sobre esto" inyecta contexto:
|
||||
> Sobre la tarjeta `{title}` (id `{id}`): ...
|
||||
|
||||
## Persistencia chat
|
||||
|
||||
Conversaciones por sesion en `localStorage` (clave `kanban_chat_v1`). NO se persiste en SQLite por ahora — es state efimero. Si en el futuro se quiere historico → tabla `chat_messages` en `operations.db`.
|
||||
|
||||
## Seguridad
|
||||
|
||||
- Tools de borrado (`delete_column`, `delete_card`) requieren confirmacion explicita en el system prompt. Si el LLM las invoca sin confirmar, el frontend abre modal de confirmacion antes de pasar el resultado al loop.
|
||||
- `ANTHROPIC_API_KEY` solo en backend, NUNCA expuesta al frontend.
|
||||
|
||||
## Hitos (orden de ejecucion sugerido)
|
||||
|
||||
1. **Tools.go + tests**: catalogo + dispatch puro Go, sin LLM. Probar con curl manual.
|
||||
2. **Endpoint `/api/chat` no streaming**: request → response sincrono. Validar Claude API + tool loop.
|
||||
3. **Streaming SSE**.
|
||||
4. **ChatPanel UI** + `useKanbanChat`.
|
||||
5. **Toggle aside + layout responsive**.
|
||||
6. **Confirmacion de tools destructivos**.
|
||||
7. **Botones contextuales en cards/columns**.
|
||||
|
||||
## Funciones del registry a delegar a `fn-constructor` (registry-first)
|
||||
|
||||
Antes de codear el chat, crear estas primitivas reutilizables:
|
||||
|
||||
| ID | Lenguaje | Proposito |
|
||||
|---|---|---|
|
||||
| `claude_messages_call_go_infra` | Go | Wrapper sobre POST `https://api.anthropic.com/v1/messages` con tools, system, prompt-caching. Impure, error_type. |
|
||||
| `claude_messages_stream_go_infra` | Go | Idem pero con SSE streaming, retorna canal de eventos. Impure. |
|
||||
| `sse_writer_go_infra` | Go | Helper para escribir SSE eventos en `http.ResponseWriter` (set headers, flusher, format `event:/data:`). Pure factory. |
|
||||
|
||||
Estas tres son utiles en cualquier app que necesite chat o LLM tools — no se quedan en el kanban.
|
||||
|
||||
## Decisiones pendientes (preguntar al usuario antes de codear)
|
||||
|
||||
1. **Modelo**: `claude-opus-4-7` (mas capaz, mas caro) vs `claude-sonnet-4-6` (default razonable) vs `claude-haiku-4-5` (rapido, barato).
|
||||
2. **Streaming**: empezar simple (request/response sincrono) o ir directo a SSE.
|
||||
3. **Persistencia chat**: solo `localStorage` o tambien tabla en `operations.db` para historico cross-session.
|
||||
4. **Confirmaciones destructivas**: bloqueo en frontend o solo via prompt? Si bloqueo, ¿qué define "destructivo"?
|
||||
5. **Limite de turnos por sesion**: para evitar tool-loops infinitos, cap (ej. 20 iteraciones por mensaje del usuario).
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
name: kanban
|
||||
lang: go
|
||||
domain: tools
|
||||
description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit) y tracking del tiempo que cada tarjeta pasa en cada columna. Frontend Vite + React + Mantine v9 embebido en el binario Go."
|
||||
tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking]
|
||||
uses_functions:
|
||||
- random_hex_id_go_core
|
||||
- sqlite_open_go_infra
|
||||
- spa_handler_go_infra
|
||||
- http_router_go_infra
|
||||
- http_serve_go_infra
|
||||
- http_middleware_chain_go_infra
|
||||
- http_cors_middleware_go_infra
|
||||
- http_logger_middleware_go_infra
|
||||
- http_json_response_go_infra
|
||||
- http_error_response_go_infra
|
||||
- http_parse_body_go_infra
|
||||
uses_types: []
|
||||
framework: "net/http + vite + react + mantine + dnd-kit"
|
||||
entry_point: "main.go"
|
||||
dir_path: "apps/kanban"
|
||||
---
|
||||
|
||||
## Arquitectura
|
||||
|
||||
Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tablas (`columns`, `cards`, `card_column_history`) y endpoints REST.
|
||||
|
||||
```
|
||||
./kanban --port 8095 --db kanban.db
|
||||
```
|
||||
|
||||
### Schema SQLite (`migrations/001_init.sql`)
|
||||
|
||||
- **columns** — id, name, position, created_at
|
||||
- **cards** — id, title, description, column_id (FK), position, created_at, updated_at
|
||||
- **card_column_history** — id, card_id (FK), column_id, entered_at, exited_at
|
||||
- Una entrada con `exited_at IS NULL` = posicion actual
|
||||
- Al mover una tarjeta a otra columna: cierra la entrada activa (`exited_at = now`) e inserta una nueva
|
||||
- El borrado de tarjeta hace CASCADE sobre el historial
|
||||
|
||||
### API REST
|
||||
|
||||
| Metodo | Path | Cuerpo |
|
||||
|---|---|---|
|
||||
| GET | `/api/board` | — (retorna `{columns, cards}`, cada card incluye `time_in_column_ms`) |
|
||||
| POST | `/api/columns` | `{name}` |
|
||||
| PATCH | `/api/columns/{id}` | `{name?, position?}` |
|
||||
| DELETE | `/api/columns/{id}` | — (cascade a cards) |
|
||||
| POST | `/api/columns/reorder` | `{ids: [...]}` |
|
||||
| POST | `/api/cards` | `{column_id, title, description?}` |
|
||||
| PATCH | `/api/cards/{id}` | `{title?, description?}` |
|
||||
| DELETE | `/api/cards/{id}` | — |
|
||||
| POST | `/api/cards/{id}/move` | `{column_id, ordered_ids: [...]}` |
|
||||
| GET | `/api/cards/{id}/history` | — (timeline con duraciones por columna) |
|
||||
|
||||
### Frontend
|
||||
|
||||
- **dnd-kit** (`@dnd-kit/core` + `@dnd-kit/sortable`) para drag-and-drop entre y dentro de columnas (multi-container).
|
||||
- **Mantine v9** + `@tabler/icons-react` para UI.
|
||||
- **Modales** con `@mantine/modals` (confirmacion borrado, history timeline).
|
||||
- Time-in-column live: `time_in_column_ms` del backend + tick local cada segundo para que el badge se actualice sin reload.
|
||||
- DnD con `closestCorners` + `DragOverlay` para feedback visual al arrastrar.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
cd frontend && pnpm install && pnpm build
|
||||
cd .. && CGO_ENABLED=1 go build -tags fts5 -o kanban .
|
||||
./kanban --port 8095 --db kanban.db
|
||||
# Browser: http://localhost:8095
|
||||
```
|
||||
|
||||
### Dev (frontend con HMR contra backend)
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
./kanban --port 8095 --db kanban.db
|
||||
|
||||
# Terminal 2
|
||||
cd frontend && pnpm dev
|
||||
# Browser: http://localhost:5180 (vite proxy /api → 8095)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- Puerto por defecto 8095.
|
||||
- DB por defecto `kanban.db` en cwd.
|
||||
- IDs de columnas y tarjetas: 16 chars hex (8 bytes random) via `random_hex_id_go_core`.
|
||||
- El historial conserva la cronologia exacta — incluso despues de cerrar y reabrir el server, los tiempos vivos siguen contando desde `entered_at`.
|
||||
- El borrado de columna hace CASCADE: las tarjetas se borran y su historial tambien. Si se quiere preservar el historial al borrar, deberia archivarse en lugar de borrar.
|
||||
@@ -0,0 +1,325 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
const chatSystemPrompt = `Eres el asistente del tablero kanban. Tu trabajo es responder al usuario y, cuando pida cambios, modificar el tablero llamando a tools.
|
||||
|
||||
Cuando necesites modificar el tablero, responde EXCLUSIVAMENTE con un bloque <actions>...</actions> que contenga JSON valido (un array de acciones). Sin texto antes ni despues.
|
||||
|
||||
Ejemplo:
|
||||
<actions>
|
||||
[
|
||||
{"tool": "create_card", "input": {"column_id": "abc123", "requester": "Lucas", "title": "Revisar PR", "description": ""}},
|
||||
{"tool": "rename_column", "input": {"id": "def456", "name": "En curso"}}
|
||||
]
|
||||
</actions>
|
||||
|
||||
Tools disponibles (todas con sus inputs):
|
||||
- list_board {} -> {columns, cards}
|
||||
- create_column {name}
|
||||
- update_column {id, name?, location?, width?} // location: "board" | "sidebar". width: 200..800 px.
|
||||
- delete_column {id}
|
||||
- reorder_columns {ids:[...]}
|
||||
- create_card {column_id, requester?, title, description?}
|
||||
- update_card {id, requester?, title?, description?, color?} // color: "blue", "teal", "violet", "pink", "orange", "green", "yellow", "red", "" (default)
|
||||
- delete_card {id}
|
||||
- move_card {id, column_id, ordered_ids?} // si omites ordered_ids la tarjeta se anade al final
|
||||
- card_history {id}
|
||||
- find_cards {query?, column_id?, requester?}
|
||||
|
||||
Si el usuario solo conversa o pide informacion (sin pedir cambios), responde texto natural en markdown SIN bloque <actions>.
|
||||
|
||||
Para resolver IDs a partir de nombres, mira el board_state que viene al final del prompt del usuario. NO inventes IDs.
|
||||
|
||||
LOOP ITERATIVO: Despues de aplicar tus acciones, el sistema te volvera a llamar con:
|
||||
- Los resultados de las tool calls anteriores (incluyendo IDs reales de columnas/tarjetas creadas).
|
||||
- El board_state actualizado.
|
||||
- Tu mensaje de usuario original.
|
||||
|
||||
Cuando recibas resultados de iteraciones anteriores, USA LOS IDs REALES devueltos en lugar de inventar placeholders. Continua emitiendo mas <actions> hasta completar la tarea.
|
||||
|
||||
Cuando hayas terminado COMPLETAMENTE la tarea, responde texto natural (markdown) SIN bloque <actions> — eso señala el fin del loop.`
|
||||
|
||||
const claudeBin = "claude"
|
||||
const claudeModel = "claude-sonnet-4-6"
|
||||
const claudeTimeout = 120 * time.Second
|
||||
const maxChatIterations = 8
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Messages []chatMessage `json:"messages"`
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
BoardChanged bool `json:"board_changed"`
|
||||
ToolCalls []toolCallInfo `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
type toolCallInfo struct {
|
||||
Tool string `json:"tool"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Iteration int `json:"iteration,omitempty"`
|
||||
// Result is included only for the loop's internal feedback to claude;
|
||||
// it is omitted from the JSON response sent to the frontend (clients
|
||||
// can use board_changed + reload to fetch fresh state).
|
||||
Result any `json:"-"`
|
||||
}
|
||||
|
||||
type claudeJSONResult struct {
|
||||
Type string `json:"type"`
|
||||
IsError bool `json:"is_error"`
|
||||
Result string `json:"result"`
|
||||
StopReason string `json:"stop_reason"`
|
||||
}
|
||||
|
||||
// runClaude invokes the `claude` CLI in print mode with the given system prompt
|
||||
// and user message. The board JSON is appended to the user message under a
|
||||
// `board_state` marker so the assistant can resolve names to IDs.
|
||||
//
|
||||
// stdin: the user-facing prompt (history flattened).
|
||||
// returns: assistant's text reply.
|
||||
func runClaude(ctx context.Context, systemPrompt, userInput, boardJSON, workdir string) (string, error) {
|
||||
if _, err := exec.LookPath(claudeBin); err != nil {
|
||||
return "", errors.New("claude CLI not found in PATH")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, claudeTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, claudeBin,
|
||||
"-p",
|
||||
"--model", claudeModel,
|
||||
"--output-format", "json",
|
||||
"--no-session-persistence",
|
||||
"--tools", "",
|
||||
"--system-prompt", systemPrompt,
|
||||
)
|
||||
cmd.Dir = workdir
|
||||
|
||||
prompt := userInput
|
||||
if boardJSON != "" {
|
||||
prompt += "\n\n<board_state>\n" + boardJSON + "\n</board_state>\n"
|
||||
}
|
||||
cmd.Stdin = bytes.NewBufferString(prompt)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("claude exec: %w (stderr: %s)", err, stderr.String())
|
||||
}
|
||||
|
||||
var res claudeJSONResult
|
||||
if err := json.Unmarshal(stdout.Bytes(), &res); err != nil {
|
||||
return "", fmt.Errorf("parse claude json: %w (raw: %s)", err, stdout.String())
|
||||
}
|
||||
if res.IsError {
|
||||
return "", fmt.Errorf("claude error: %s", res.Result)
|
||||
}
|
||||
return res.Result, nil
|
||||
}
|
||||
|
||||
// flattenMessages converts a chat history into a single text prompt for `claude -p`.
|
||||
// Format: lines of `Usuario: ...` / `Asistente: ...`. Last user message ends the prompt.
|
||||
func flattenMessages(msgs []chatMessage) string {
|
||||
var b bytes.Buffer
|
||||
for _, m := range msgs {
|
||||
role := "Usuario"
|
||||
if m.Role == "assistant" {
|
||||
role = "Asistente"
|
||||
}
|
||||
b.WriteString(role)
|
||||
b.WriteString(": ")
|
||||
b.WriteString(m.Content)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func handleChat(db *DB, workdir string, logger *ChatLogger) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var req chatRequest
|
||||
if err := infra.HTTPParseBody(r, &req, 1<<20); err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if len(req.Messages) == 0 {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 400, Code: "bad_request", Message: "messages required"})
|
||||
return
|
||||
}
|
||||
|
||||
baseUserInput := flattenMessages(req.Messages)
|
||||
allCalls := []toolCallInfo{}
|
||||
var finalText string
|
||||
boardChanged := false
|
||||
|
||||
for iter := 1; iter <= maxChatIterations; iter++ {
|
||||
boardJSON, err := boardSnapshot(db)
|
||||
if err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "internal", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
prompt := buildIterationPrompt(baseUserInput, allCalls, iter)
|
||||
|
||||
assistantText, err := runClaude(r.Context(), chatSystemPrompt, prompt, boardJSON, workdir)
|
||||
if err != nil {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: 500, Code: "claude_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
actionsJSON, stripped, found := extractActions(assistantText)
|
||||
if !found {
|
||||
finalText = assistantText
|
||||
break
|
||||
}
|
||||
|
||||
calls, changed := applyActions(db, actionsJSON, logger)
|
||||
for i := range calls {
|
||||
calls[i].Iteration = iter
|
||||
}
|
||||
allCalls = append(allCalls, calls...)
|
||||
if changed {
|
||||
boardChanged = true
|
||||
}
|
||||
|
||||
finalText = stripped // tentative; overwritten if next iter responds free text
|
||||
if iter == maxChatIterations {
|
||||
finalText = strings.TrimSpace(stripped + "\n\n_Limite de iteraciones alcanzado._")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Strip Result fields before serializing (not exported but defensive).
|
||||
respCalls := make([]toolCallInfo, len(allCalls))
|
||||
for i, c := range allCalls {
|
||||
respCalls[i] = toolCallInfo{Tool: c.Tool, OK: c.OK, Error: c.Error, Iteration: c.Iteration}
|
||||
}
|
||||
resp := chatResponse{
|
||||
Role: "assistant",
|
||||
Content: finalText,
|
||||
ToolCalls: respCalls,
|
||||
BoardChanged: boardChanged,
|
||||
}
|
||||
if resp.Content == "" {
|
||||
resp.Content = summarizeCalls(respCalls)
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, resp)
|
||||
}
|
||||
}
|
||||
|
||||
// buildIterationPrompt composes the user prompt for iteration N.
|
||||
// Iteration 1 = original user input; later iterations also include a summary
|
||||
// of previous tool calls so the assistant can use real IDs.
|
||||
func buildIterationPrompt(baseUserInput string, prevCalls []toolCallInfo, iter int) string {
|
||||
if iter == 1 || len(prevCalls) == 0 {
|
||||
return baseUserInput
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString(baseUserInput)
|
||||
b.WriteString("\n[Resultados de iteraciones anteriores]\n")
|
||||
for _, c := range prevCalls {
|
||||
if c.OK {
|
||||
summary := summarizeResult(c.Result)
|
||||
fmt.Fprintf(&b, "- iter %d %s: ok %s\n", c.Iteration, c.Tool, summary)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "- iter %d %s: ERROR %s\n", c.Iteration, c.Tool, c.Error)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "\n[Iteracion %d] Continua con las acciones pendientes. Si terminaste, responde texto natural sin <actions>.\n", iter)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func boardSnapshot(db *DB) (string, error) {
|
||||
cols, err := db.ListColumns()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cards, err := db.ListCardsWithTime()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := json.MarshalIndent(map[string]any{"columns": cols, "cards": cards}, "", " ")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func applyActions(db *DB, actionsJSON string, logger *ChatLogger) ([]toolCallInfo, bool) {
|
||||
var actions []struct {
|
||||
Tool string `json:"tool"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(actionsJSON), &actions); err != nil {
|
||||
return []toolCallInfo{{Tool: "<parse>", OK: false, Error: err.Error()}}, false
|
||||
}
|
||||
|
||||
results := make([]toolCallInfo, 0, len(actions))
|
||||
changed := false
|
||||
for _, a := range actions {
|
||||
if err := validateToolName(a.Tool); err != nil {
|
||||
info := toolCallInfo{Tool: a.Tool, OK: false, Error: err.Error()}
|
||||
results = append(results, info)
|
||||
logger.Log(a.Tool, a.Input, ToolResult{OK: false, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
res := executeTool(db, a.Tool, a.Input)
|
||||
logger.Log(a.Tool, a.Input, res)
|
||||
info := toolCallInfo{Tool: a.Tool, OK: res.OK, Result: res.Result}
|
||||
if !res.OK {
|
||||
info.Error = res.Error
|
||||
} else if toolMutates(a.Tool) {
|
||||
changed = true
|
||||
}
|
||||
results = append(results, info)
|
||||
}
|
||||
return results, changed
|
||||
}
|
||||
|
||||
func summarizeCalls(calls []toolCallInfo) string {
|
||||
if len(calls) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString("Acciones aplicadas:\n")
|
||||
for _, c := range calls {
|
||||
if c.OK {
|
||||
fmt.Fprintf(&b, "- %s: ok\n", c.Tool)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "- %s: error (%s)\n", c.Tool, c.Error)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// chatWorkdir resolves an absolute working directory for `claude -p` (avoids
|
||||
// inheriting CLAUDE.md from parent directories with unrelated context).
|
||||
func chatWorkdir(dbPath string) string {
|
||||
abs, err := filepath.Abs(dbPath)
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return filepath.Dir(abs)
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ChatLogger appends one JSON line per tool invocation to a file. Thread-safe.
|
||||
// Format per line: {"ts":"...","tool":"...","input":{...},"ok":bool,"error":"...","result_summary":"..."}
|
||||
type ChatLogger struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newChatLogger(path string) *ChatLogger {
|
||||
return &ChatLogger{path: path}
|
||||
}
|
||||
|
||||
type ChatLogEntry struct {
|
||||
TS string `json:"ts"`
|
||||
Tool string `json:"tool"`
|
||||
Input json.RawMessage `json:"input"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ResultSummary string `json:"result_summary,omitempty"`
|
||||
}
|
||||
|
||||
func (l *ChatLogger) Log(tool string, input json.RawMessage, res ToolResult) {
|
||||
if l == nil || l.path == "" {
|
||||
return
|
||||
}
|
||||
entry := ChatLogEntry{
|
||||
TS: time.Now().UTC().Format(time.RFC3339Nano),
|
||||
Tool: tool,
|
||||
Input: input,
|
||||
OK: res.OK,
|
||||
Error: res.Error,
|
||||
}
|
||||
if res.OK && res.Result != nil {
|
||||
entry.ResultSummary = summarizeResult(res.Result)
|
||||
}
|
||||
line, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
f, err := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
f.Write(line)
|
||||
f.Write([]byte("\n"))
|
||||
}
|
||||
|
||||
// summarizeResult produces a short description of a tool result for the log.
|
||||
// Keeps the log line compact: full payloads can be reconstructed from operations.db.
|
||||
func summarizeResult(v any) string {
|
||||
switch r := v.(type) {
|
||||
case *Column:
|
||||
return fmt.Sprintf("column %s name=%q", r.ID, r.Name)
|
||||
case *Card:
|
||||
return fmt.Sprintf("card %s title=%q col=%s", r.ID, r.Title, r.ColumnID)
|
||||
case []Card:
|
||||
return fmt.Sprintf("%d cards", len(r))
|
||||
case []HistoryEntry:
|
||||
return fmt.Sprintf("%d history entries", len(r))
|
||||
case map[string]any:
|
||||
// list_board shape
|
||||
cols, _ := r["columns"].([]Column)
|
||||
cards, _ := r["cards"].([]Card)
|
||||
return fmt.Sprintf("board: %d cols, %d cards", len(cols), len(cards))
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil || len(b) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(b) > 200 {
|
||||
return string(b[:200]) + "..."
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/core"
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
//go:embed migrations/001_init.sql
|
||||
var migrationSQL string
|
||||
|
||||
type Column struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
Location string `json:"location"`
|
||||
Width int `json:"width"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type Card struct {
|
||||
ID string `json:"id"`
|
||||
Requester string `json:"requester"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Color string `json:"color"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Position int `json:"position"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
EnteredAt string `json:"entered_at"`
|
||||
TimeInColumn int64 `json:"time_in_column_ms"`
|
||||
}
|
||||
|
||||
type HistoryEntry struct {
|
||||
ID string `json:"id"`
|
||||
CardID string `json:"card_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
ColumnName string `json:"column_name"`
|
||||
EnteredAt string `json:"entered_at"`
|
||||
ExitedAt *string `json:"exited_at"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
}
|
||||
|
||||
type DB struct{ conn *sql.DB }
|
||||
|
||||
func openDB(path string) (*DB, error) {
|
||||
conn, err := infra.SQLiteOpen(path, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := conn.Exec(migrationSQL); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
// Idempotent column adds for forward-compat with older DBs.
|
||||
if err := ensureColumns(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("ensure columns: %w", err)
|
||||
}
|
||||
return &DB{conn: conn}, nil
|
||||
}
|
||||
|
||||
// ensureColumns adds columns missing from older schemas without dropping data.
|
||||
// SQLite ALTER TABLE ADD COLUMN supports NOT NULL with literal DEFAULT but not CHECK,
|
||||
// so location's CHECK is enforced in Go (UpdateColumn) when the column is added later.
|
||||
func ensureColumns(conn *sql.DB) error {
|
||||
type colSpec struct{ table, name, ddl string }
|
||||
specs := []colSpec{
|
||||
{"columns", "location", "TEXT NOT NULL DEFAULT 'board'"},
|
||||
{"columns", "width", "INTEGER NOT NULL DEFAULT 300"},
|
||||
{"cards", "color", "TEXT NOT NULL DEFAULT ''"},
|
||||
}
|
||||
for _, s := range specs {
|
||||
exists, err := columnExists(conn, s.table, s.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
if _, err := conn.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", s.table, s.name, s.ddl)); err != nil {
|
||||
return fmt.Errorf("add %s.%s: %w", s.table, s.name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func columnExists(conn *sql.DB, table, name string) (bool, error) {
|
||||
rows, err := conn.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var colName, ctype string
|
||||
var notnull int
|
||||
var dflt sql.NullString
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &colName, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if colName == name {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) Close() error { return db.conn.Close() }
|
||||
|
||||
func newID() string {
|
||||
id, err := core.RandomHexID(8)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("kanban: cannot generate id: %w", err))
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339Nano) }
|
||||
|
||||
// --- Columns ---
|
||||
|
||||
func (db *DB) ListColumns() ([]Column, error) {
|
||||
rows, err := db.conn.Query(`SELECT id, name, position, location, width, created_at FROM columns ORDER BY position, created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Column{}
|
||||
for rows.Next() {
|
||||
var c Column
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Position, &c.Location, &c.Width, &c.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) CreateColumn(name string) (*Column, error) {
|
||||
var maxPos sql.NullInt64
|
||||
if err := db.conn.QueryRow(`SELECT MAX(position) FROM columns`).Scan(&maxPos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pos := 0
|
||||
if maxPos.Valid {
|
||||
pos = int(maxPos.Int64) + 1
|
||||
}
|
||||
c := Column{ID: newID(), Name: name, Position: pos, Location: "board", Width: 300, CreatedAt: nowRFC3339()}
|
||||
_, err := db.conn.Exec(
|
||||
`INSERT INTO columns (id, name, position, location, width, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.Name, c.Position, c.Location, c.Width, c.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
type ColumnPatch struct {
|
||||
Name *string
|
||||
Position *int
|
||||
Location *string
|
||||
Width *int
|
||||
}
|
||||
|
||||
func (db *DB) UpdateColumn(id string, patch ColumnPatch) error {
|
||||
if patch.Name != nil {
|
||||
if _, err := db.conn.Exec(`UPDATE columns SET name=? WHERE id=?`, *patch.Name, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patch.Position != nil {
|
||||
if _, err := db.conn.Exec(`UPDATE columns SET position=? WHERE id=?`, *patch.Position, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patch.Location != nil {
|
||||
if *patch.Location != "board" && *patch.Location != "sidebar" {
|
||||
return fmt.Errorf("invalid location: %s", *patch.Location)
|
||||
}
|
||||
if _, err := db.conn.Exec(`UPDATE columns SET location=? WHERE id=?`, *patch.Location, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patch.Width != nil {
|
||||
w := *patch.Width
|
||||
if w < 200 {
|
||||
w = 200
|
||||
} else if w > 800 {
|
||||
w = 800
|
||||
}
|
||||
if _, err := db.conn.Exec(`UPDATE columns SET width=? WHERE id=?`, w, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteColumn(id string) error {
|
||||
_, err := db.conn.Exec(`DELETE FROM columns WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) ReorderColumns(ids []string) error {
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for i, id := range ids {
|
||||
if _, err := tx.Exec(`UPDATE columns SET position=? WHERE id=?`, i, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// --- Cards ---
|
||||
|
||||
func (db *DB) ListCardsWithTime() ([]Card, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT c.id, c.requester, c.title, c.description, c.color, c.column_id, c.position, c.created_at, c.updated_at,
|
||||
h.entered_at
|
||||
FROM cards c
|
||||
LEFT JOIN card_column_history h
|
||||
ON h.card_id = c.id AND h.exited_at IS NULL
|
||||
ORDER BY c.column_id, c.position, c.created_at
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
now := time.Now().UTC()
|
||||
out := []Card{}
|
||||
for rows.Next() {
|
||||
var c Card
|
||||
var entered sql.NullString
|
||||
if err := rows.Scan(&c.ID, &c.Requester, &c.Title, &c.Description, &c.Color, &c.ColumnID, &c.Position, &c.CreatedAt, &c.UpdatedAt, &entered); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entered.Valid {
|
||||
c.EnteredAt = entered.String
|
||||
if t, err := time.Parse(time.RFC3339Nano, entered.String); err == nil {
|
||||
c.TimeInColumn = now.Sub(t).Milliseconds()
|
||||
}
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) CreateCard(columnID, requester, title, description string) (*Card, error) {
|
||||
var maxPos sql.NullInt64
|
||||
if err := db.conn.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, columnID).Scan(&maxPos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pos := 0
|
||||
if maxPos.Valid {
|
||||
pos = int(maxPos.Int64) + 1
|
||||
}
|
||||
now := nowRFC3339()
|
||||
c := Card{
|
||||
ID: newID(), Requester: requester, Title: title, Description: description, ColumnID: columnID, Position: pos,
|
||||
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
|
||||
}
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO cards (id, requester, title, description, color, column_id, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.ID, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position, c.CreatedAt, c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO card_column_history (id, card_id, column_id, entered_at) VALUES (?, ?, ?, ?)`,
|
||||
newID(), c.ID, c.ColumnID, now,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
type CardPatch struct {
|
||||
Requester *string
|
||||
Title *string
|
||||
Description *string
|
||||
Color *string
|
||||
}
|
||||
|
||||
func (db *DB) UpdateCard(id string, patch CardPatch) error {
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if patch.Requester != nil {
|
||||
if _, err := tx.Exec(`UPDATE cards SET requester=?, updated_at=? WHERE id=?`, *patch.Requester, nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patch.Title != nil {
|
||||
if _, err := tx.Exec(`UPDATE cards SET title=?, updated_at=? WHERE id=?`, *patch.Title, nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patch.Description != nil {
|
||||
if _, err := tx.Exec(`UPDATE cards SET description=?, updated_at=? WHERE id=?`, *patch.Description, nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if patch.Color != nil {
|
||||
if _, err := tx.Exec(`UPDATE cards SET color=?, updated_at=? WHERE id=?`, *patch.Color, nowRFC3339(), id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (db *DB) DeleteCard(id string) error {
|
||||
_, err := db.conn.Exec(`DELETE FROM cards WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// MoveCard updates the card's column and/or position. If the column changes,
|
||||
// the open history entry is closed and a new one is opened.
|
||||
// orderedIDs is the new order of cards in the destination column (including this card).
|
||||
func (db *DB) MoveCard(cardID, destColumnID string, orderedIDs []string) error {
|
||||
tx, err := db.conn.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var srcColumnID string
|
||||
if err := tx.QueryRow(`SELECT column_id FROM cards WHERE id=?`, cardID).Scan(&srcColumnID); err != nil {
|
||||
return fmt.Errorf("card not found: %w", err)
|
||||
}
|
||||
|
||||
now := nowRFC3339()
|
||||
|
||||
if srcColumnID != destColumnID {
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE card_column_history SET exited_at=? WHERE card_id=? AND exited_at IS NULL`,
|
||||
now, cardID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO card_column_history (id, card_id, column_id, entered_at) VALUES (?, ?, ?, ?)`,
|
||||
newID(), cardID, destColumnID, now,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`UPDATE cards SET column_id=?, updated_at=? WHERE id=?`,
|
||||
destColumnID, now, cardID,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for i, id := range orderedIDs {
|
||||
if _, err := tx.Exec(`UPDATE cards SET position=? WHERE id=?`, i, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Re-pack source column positions to keep them dense.
|
||||
if srcColumnID != destColumnID {
|
||||
rows, err := tx.Query(`SELECT id FROM cards WHERE column_id=? ORDER BY position, created_at`, srcColumnID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var srcIDs []string
|
||||
for rows.Next() {
|
||||
var sid string
|
||||
if err := rows.Scan(&sid); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
srcIDs = append(srcIDs, sid)
|
||||
}
|
||||
rows.Close()
|
||||
for i, sid := range srcIDs {
|
||||
if _, err := tx.Exec(`UPDATE cards SET position=? WHERE id=?`, i, sid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (db *DB) CardHistory(cardID string) ([]HistoryEntry, error) {
|
||||
rows, err := db.conn.Query(`
|
||||
SELECT h.id, h.card_id, h.column_id, COALESCE(c.name, ''), h.entered_at, h.exited_at
|
||||
FROM card_column_history h
|
||||
LEFT JOIN columns c ON c.id = h.column_id
|
||||
WHERE h.card_id=?
|
||||
ORDER BY h.entered_at
|
||||
`, cardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
now := time.Now().UTC()
|
||||
out := []HistoryEntry{}
|
||||
for rows.Next() {
|
||||
var h HistoryEntry
|
||||
var exited sql.NullString
|
||||
if err := rows.Scan(&h.ID, &h.CardID, &h.ColumnID, &h.ColumnName, &h.EnteredAt, &exited); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entered, err := time.Parse(time.RFC3339Nano, h.EnteredAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var end time.Time
|
||||
if exited.Valid {
|
||||
h.ExitedAt = &exited.String
|
||||
end, _ = time.Parse(time.RFC3339Nano, exited.String)
|
||||
} else {
|
||||
end = now
|
||||
}
|
||||
h.DurationMs = end.Sub(entered).Milliseconds()
|
||||
out = append(out, h)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kanban</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "kanban-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@mantine/core": "^9.0.2",
|
||||
"@mantine/hooks": "^9.0.2",
|
||||
"@mantine/modals": "^9.0.2",
|
||||
"@mantine/notifications": "^9.0.2",
|
||||
"@tabler/icons-react": "^3.31.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"postcss": "^8.5.4",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
Generated
+2498
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"postcss-preset-mantine": {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,747 @@
|
||||
import {
|
||||
CollisionDetection,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
closestCorners,
|
||||
pointerWithin,
|
||||
rectIntersection,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import {
|
||||
ActionIcon,
|
||||
AppShell,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconColumnInsertRight,
|
||||
IconLayoutKanban,
|
||||
IconMenu2,
|
||||
IconMessageChatbot,
|
||||
IconPlus,
|
||||
IconRefresh,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as api from "./api";
|
||||
import { CardForm } from "./components/CardForm";
|
||||
import { ChatPanel } from "./components/ChatPanel";
|
||||
import { HistoryModal } from "./components/HistoryModal";
|
||||
import { KanbanCard } from "./components/KanbanCard";
|
||||
import { KanbanColumn } from "./components/KanbanColumn";
|
||||
import { colorBg, colorBorder } from "./components/colors";
|
||||
import type { Board, Card, CardColor, Column, ColumnLocation } from "./types";
|
||||
|
||||
const COL_PREFIX = "column-";
|
||||
|
||||
function AddColumnDialog({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
onSubmit: (name: string) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const submit = () => {
|
||||
const n = name.trim();
|
||||
if (n) onSubmit(n);
|
||||
};
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Nombre"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
data-autofocus
|
||||
autoComplete="off"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button variant="subtle" color="gray" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={!name.trim()}>
|
||||
Crear
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom collision detection: prefiere otras columnas como destino al arrastrar
|
||||
// columnas; al arrastrar cards prefiere cards/columnas via closestCorners.
|
||||
function makeCollisionDetection(activeType: string | undefined): CollisionDetection {
|
||||
if (activeType === "column") {
|
||||
return (args) => {
|
||||
// Solo considerar drops sobre otras columnas (ids con COL_PREFIX).
|
||||
const filtered = args.droppableContainers.filter((c) =>
|
||||
String(c.id).startsWith(COL_PREFIX)
|
||||
);
|
||||
const inter = rectIntersection({ ...args, droppableContainers: filtered });
|
||||
if (inter.length > 0) return inter;
|
||||
return closestCenter({ ...args, droppableContainers: filtered });
|
||||
};
|
||||
}
|
||||
return (args) => {
|
||||
const pw = pointerWithin(args);
|
||||
if (pw.length > 0) return pw;
|
||||
return closestCorners(args);
|
||||
};
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [activeCard, setActiveCard] = useState<Card | null>(null);
|
||||
const [activeColumnId, setActiveColumnId] = useState<string | null>(null);
|
||||
const [activeType, setActiveType] = useState<string | undefined>(undefined);
|
||||
const [addingCol, setAddingCol] = useState(false);
|
||||
const [colName, setColName] = useState("");
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [navWidth, setNavWidth] = useState<number>(() => {
|
||||
const stored = localStorage.getItem("kanban_nav_width");
|
||||
const n = stored ? parseInt(stored, 10) : NaN;
|
||||
return Number.isFinite(n) && n >= 180 && n <= 600 ? n : 240;
|
||||
});
|
||||
const navWidthRef = useRef(navWidth);
|
||||
useEffect(() => {
|
||||
navWidthRef.current = navWidth;
|
||||
localStorage.setItem("kanban_nav_width", String(navWidth));
|
||||
}, [navWidth]);
|
||||
|
||||
const onNavResizeMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = navWidthRef.current;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const dx = ev.clientX - startX;
|
||||
const next = Math.min(600, Math.max(180, startWidth + dx));
|
||||
setNavWidth(next);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const b = await api.getBoard();
|
||||
setBoard(b);
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
const sortedColumns = useMemo(() => {
|
||||
if (!board) return [];
|
||||
return [...board.columns].sort((a, b) => a.position - b.position);
|
||||
}, [board]);
|
||||
|
||||
const boardColumns = useMemo(() => sortedColumns.filter((c) => c.location !== "sidebar"), [sortedColumns]);
|
||||
const sidebarColumns = useMemo(() => sortedColumns.filter((c) => c.location === "sidebar"), [sortedColumns]);
|
||||
|
||||
const boardSortableIds = useMemo(() => boardColumns.map((c) => `${COL_PREFIX}${c.id}`), [boardColumns]);
|
||||
const sidebarSortableIds = useMemo(() => sidebarColumns.map((c) => `${COL_PREFIX}${c.id}`), [sidebarColumns]);
|
||||
|
||||
const cardsByColumn = useMemo(() => {
|
||||
const map = new Map<string, Card[]>();
|
||||
if (!board) return map;
|
||||
for (const col of board.columns) map.set(col.id, []);
|
||||
for (const c of [...board.cards].sort((a, b) => a.position - b.position)) {
|
||||
const arr = map.get(c.column_id);
|
||||
if (arr) arr.push(c);
|
||||
}
|
||||
return map;
|
||||
}, [board]);
|
||||
|
||||
const findCard = (id: string): Card | undefined => board?.cards.find((c) => c.id === id);
|
||||
const findColumn = (id: string): Column | undefined => board?.columns.find((c) => c.id === id);
|
||||
const findColumnIdOfCard = (id: string): string | undefined => findCard(id)?.column_id;
|
||||
|
||||
const isColumnId = (id: string) => id.startsWith(COL_PREFIX);
|
||||
const stripColumnPrefix = (id: string) => id.slice(COL_PREFIX.length);
|
||||
|
||||
const resolveColumnId = (overId: string): string | undefined => {
|
||||
if (!board) return undefined;
|
||||
if (isColumnId(overId)) return stripColumnPrefix(overId);
|
||||
return findColumnIdOfCard(overId);
|
||||
};
|
||||
|
||||
// --- DnD handlers ---
|
||||
|
||||
const onDragStart = (e: DragStartEvent) => {
|
||||
const id = e.active.id as string;
|
||||
const type = e.active.data.current?.type as string | undefined;
|
||||
setActiveType(type);
|
||||
if (type === "column") {
|
||||
setActiveColumnId(stripColumnPrefix(id));
|
||||
return;
|
||||
}
|
||||
const c = findCard(id);
|
||||
if (c) setActiveCard(c);
|
||||
};
|
||||
|
||||
const onDragOver = (e: DragOverEvent) => {
|
||||
if (!board) return;
|
||||
if (e.active.data.current?.type !== "card") return;
|
||||
|
||||
const activeId = e.active.id as string;
|
||||
const overId = e.over?.id as string | undefined;
|
||||
if (!overId) return;
|
||||
|
||||
const fromCol = findColumnIdOfCard(activeId);
|
||||
const toCol = resolveColumnId(overId);
|
||||
if (!fromCol || !toCol || fromCol === toCol) return;
|
||||
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
const cards = prev.cards.map((c) => (c.id === activeId ? { ...c, column_id: toCol } : c));
|
||||
return { ...prev, cards };
|
||||
});
|
||||
};
|
||||
|
||||
const onDragEnd = async (e: DragEndEvent) => {
|
||||
const type = e.active.data.current?.type as string | undefined;
|
||||
const activeId = e.active.id as string;
|
||||
const overId = e.over?.id as string | undefined;
|
||||
setActiveCard(null);
|
||||
setActiveColumnId(null);
|
||||
setActiveType(undefined);
|
||||
|
||||
if (!board || !overId) return;
|
||||
|
||||
if (type === "column") {
|
||||
if (!isColumnId(overId)) return;
|
||||
const activeColId = stripColumnPrefix(activeId);
|
||||
const overColId = stripColumnPrefix(overId);
|
||||
if (activeColId === overColId) return;
|
||||
|
||||
const activeCol = findColumn(activeColId);
|
||||
const overCol = findColumn(overColId);
|
||||
if (!activeCol || !overCol) return;
|
||||
|
||||
// Determine destination location: same as the column it was dropped on.
|
||||
const destLocation: ColumnLocation = overCol.location;
|
||||
const destSiblings = sortedColumns.filter((c) => c.location === destLocation);
|
||||
const destIds = destSiblings.map((c) => c.id);
|
||||
const oldIdx = destIds.indexOf(activeColId);
|
||||
const newIdx = destIds.indexOf(overColId);
|
||||
|
||||
let reordered: string[];
|
||||
if (oldIdx === -1) {
|
||||
// Coming from another location: append at overCol position.
|
||||
const insertAt = newIdx === -1 ? destIds.length : newIdx;
|
||||
reordered = [...destIds.slice(0, insertAt), activeColId, ...destIds.slice(insertAt)];
|
||||
} else {
|
||||
if (oldIdx === newIdx) return;
|
||||
reordered = arrayMove(destIds, oldIdx, newIdx);
|
||||
}
|
||||
|
||||
// Optimistic update.
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
const posMap = new Map(reordered.map((id, i) => [id, i]));
|
||||
const columns = prev.columns.map((c) => {
|
||||
if (c.id === activeColId) return { ...c, location: destLocation, position: posMap.get(c.id) ?? c.position };
|
||||
if (posMap.has(c.id)) return { ...c, position: posMap.get(c.id)! };
|
||||
return c;
|
||||
});
|
||||
return { ...prev, columns };
|
||||
});
|
||||
|
||||
try {
|
||||
if (activeCol.location !== destLocation) {
|
||||
await api.updateColumn(activeColId, { location: destLocation });
|
||||
}
|
||||
await api.reorderColumns(reordered);
|
||||
} catch (err) {
|
||||
notifications.show({ color: "red", message: (err as Error).message });
|
||||
}
|
||||
reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// Card drag
|
||||
const destCol = resolveColumnId(overId);
|
||||
if (!destCol) return;
|
||||
const destCards = board.cards
|
||||
.filter((c) => c.column_id === destCol)
|
||||
.sort((a, b) => a.position - b.position);
|
||||
const oldIdx = destCards.findIndex((c) => c.id === activeId);
|
||||
|
||||
let orderedIds: string[];
|
||||
if (isColumnId(overId) || oldIdx === -1) {
|
||||
orderedIds = [...destCards.filter((c) => c.id !== activeId).map((c) => c.id), activeId];
|
||||
} else {
|
||||
const newIdx = destCards.findIndex((c) => c.id === overId);
|
||||
orderedIds = arrayMove(destCards.map((c) => c.id), oldIdx, newIdx);
|
||||
}
|
||||
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
const orderMap = new Map(orderedIds.map((id, i) => [id, i]));
|
||||
const cards = prev.cards.map((c) => {
|
||||
if (c.column_id === destCol && orderMap.has(c.id)) return { ...c, position: orderMap.get(c.id)! };
|
||||
return c;
|
||||
});
|
||||
return { ...prev, cards };
|
||||
});
|
||||
|
||||
try {
|
||||
await api.moveCard(activeId, destCol, orderedIds);
|
||||
} catch (err) {
|
||||
notifications.show({ color: "red", message: (err as Error).message });
|
||||
}
|
||||
reload();
|
||||
};
|
||||
|
||||
// --- mutations ---
|
||||
|
||||
const handleAddColumn = async () => {
|
||||
const n = colName.trim();
|
||||
if (!n) return;
|
||||
try {
|
||||
await api.createColumn(n);
|
||||
setColName("");
|
||||
setAddingCol(false);
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
const openAddColumnModal = useCallback(() => {
|
||||
const id = modals.open({
|
||||
title: "Nueva columna",
|
||||
size: "sm",
|
||||
children: <AddColumnDialog onSubmit={async (name) => {
|
||||
try {
|
||||
await api.createColumn(name);
|
||||
modals.close(id);
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}} onCancel={() => modals.close(id)} />,
|
||||
});
|
||||
}, [reload]);
|
||||
|
||||
const handleRenameColumn = useCallback(async (id: string, name: string) => {
|
||||
try {
|
||||
await api.updateColumn(id, { name });
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const handleResizeColumn = useCallback(async (id: string, width: number) => {
|
||||
try {
|
||||
await api.updateColumn(id, { width });
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const handleMoveColumnLocation = useCallback(async (id: string, location: ColumnLocation) => {
|
||||
try {
|
||||
await api.updateColumn(id, { location });
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const handleDeleteColumn = useCallback((id: string) => {
|
||||
modals.openConfirmModal({
|
||||
title: "Eliminar columna",
|
||||
children: <Text size="sm">Se borraran todas sus tarjetas. Continuar?</Text>,
|
||||
labels: { confirm: "Eliminar", cancel: "Cancelar" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api.deleteColumn(id);
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [reload]);
|
||||
|
||||
const openCreateCard = useCallback((columnId: string) => {
|
||||
const id = modals.open({
|
||||
title: "Nueva tarjeta",
|
||||
size: "md",
|
||||
children: (
|
||||
<CardForm
|
||||
submitLabel="Crear"
|
||||
onCancel={() => modals.close(id)}
|
||||
onSubmit={async (v) => {
|
||||
try {
|
||||
await api.createCard({
|
||||
column_id: columnId,
|
||||
requester: v.requester,
|
||||
title: v.title,
|
||||
description: v.description,
|
||||
});
|
||||
modals.close(id);
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [reload]);
|
||||
|
||||
const openEditCard = useCallback((card: Card) => {
|
||||
const id = modals.open({
|
||||
title: "Editar tarjeta",
|
||||
size: "md",
|
||||
children: (
|
||||
<CardForm
|
||||
initial={{ requester: card.requester, title: card.title, description: card.description }}
|
||||
submitLabel="Guardar"
|
||||
onCancel={() => modals.close(id)}
|
||||
onSubmit={async (v) => {
|
||||
try {
|
||||
await api.updateCard(card.id, {
|
||||
requester: v.requester,
|
||||
title: v.title,
|
||||
description: v.description,
|
||||
});
|
||||
modals.close(id);
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [reload]);
|
||||
|
||||
const handleDeleteCard = useCallback(async (id: string) => {
|
||||
try {
|
||||
await api.deleteCard(id);
|
||||
reload();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const handleChangeCardColor = useCallback(async (id: string, color: CardColor) => {
|
||||
setBoard((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, cards: prev.cards.map((c) => (c.id === id ? { ...c, color } : c)) };
|
||||
});
|
||||
try {
|
||||
await api.updateCard(id, { color });
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
reload();
|
||||
}
|
||||
}, [reload]);
|
||||
|
||||
const handleShowHistory = useCallback((card: Card) => {
|
||||
modals.open({
|
||||
title: card.title,
|
||||
size: "md",
|
||||
children: <HistoryModal card={card} />,
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!board) {
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
const dragOverlayCard = activeCard;
|
||||
const dragOverlayColumn = activeColumnId ? findColumn(activeColumnId) : null;
|
||||
|
||||
// Memo configs — objetos inline causan re-emit del <style> inline de Mantine
|
||||
// cada vez que `now` (tick 1s) o cualquier otro state actualice.
|
||||
const headerConfig = useMemo(() => ({ height: 50 }), []);
|
||||
const navbarConfig = useMemo(
|
||||
() => ({
|
||||
width: navWidth,
|
||||
breakpoint: "md" as const,
|
||||
collapsed: { mobile: !navOpen, desktop: !navOpen },
|
||||
}),
|
||||
[navWidth, navOpen]
|
||||
);
|
||||
const asideConfig = useMemo(
|
||||
() => ({
|
||||
width: 380,
|
||||
breakpoint: "md" as const,
|
||||
collapsed: { mobile: !chatOpen, desktop: !chatOpen },
|
||||
}),
|
||||
[chatOpen]
|
||||
);
|
||||
const appShellStyles = useMemo(
|
||||
() => ({ main: { paddingInlineStart: 0, paddingInlineEnd: 0 } }),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={makeCollisionDetection(activeType)}
|
||||
onDragStart={onDragStart}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
<AppShell
|
||||
header={headerConfig}
|
||||
navbar={navbarConfig}
|
||||
aside={asideConfig}
|
||||
padding={0}
|
||||
styles={appShellStyles}
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between">
|
||||
<Group gap={6}>
|
||||
<ActionIcon
|
||||
variant={navOpen ? "filled" : "subtle"}
|
||||
onClick={() => setNavOpen((v) => !v)}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<IconMenu2 size={16} />
|
||||
</ActionIcon>
|
||||
<IconLayoutKanban size={22} />
|
||||
<Title order={4}>Kanban</Title>
|
||||
</Group>
|
||||
<Group gap={4}>
|
||||
<Tooltip label="Nueva columna" withArrow>
|
||||
<ActionIcon variant="subtle" onClick={openAddColumnModal} aria-label="Add column">
|
||||
<IconColumnInsertRight size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon variant="subtle" onClick={reload} aria-label="Refresh">
|
||||
<IconRefresh size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant={chatOpen ? "filled" : "subtle"}
|
||||
onClick={() => setChatOpen((v) => !v)}
|
||||
aria-label="Toggle chat"
|
||||
>
|
||||
<IconMessageChatbot size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar p="xs">
|
||||
{/* Drag handle to resize navbar — absolute relative to navbar (which is position:fixed in v9) */}
|
||||
<Box
|
||||
onMouseDown={onNavResizeMouseDown}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: -3,
|
||||
width: 6,
|
||||
height: "100%",
|
||||
cursor: "col-resize",
|
||||
zIndex: 10,
|
||||
}}
|
||||
aria-label="Resize sidebar"
|
||||
/>
|
||||
<Stack gap="xs" h="100%">
|
||||
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||
Columnas parqueadas
|
||||
</Text>
|
||||
<Box style={{ flex: 1, overflowY: "auto" }}>
|
||||
<SortableContext items={sidebarSortableIds} strategy={verticalListSortingStrategy}>
|
||||
<Stack gap="xs">
|
||||
{sidebarColumns.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
Vacio. Mueve columnas aqui con el icono "archivar" en su cabecera.
|
||||
</Text>
|
||||
)}
|
||||
{sidebarColumns.map((col) => (
|
||||
<KanbanColumn
|
||||
key={col.id}
|
||||
column={col}
|
||||
cards={cardsByColumn.get(col.id) ?? []}
|
||||
now={now}
|
||||
collapsed
|
||||
onAddCard={openCreateCard}
|
||||
onRenameColumn={handleRenameColumn}
|
||||
onResizeColumn={handleResizeColumn}
|
||||
onMoveColumnLocation={handleMoveColumnLocation}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
onEditCard={openEditCard}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onChangeCardColor={handleChangeCardColor}
|
||||
onShowHistory={handleShowHistory}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SortableContext>
|
||||
</Box>
|
||||
</Stack>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Aside>
|
||||
<ChatPanel onBoardChange={reload} />
|
||||
</AppShell.Aside>
|
||||
|
||||
<AppShell.Main>
|
||||
<Box style={{ height: "calc(100vh - 50px)", overflow: "hidden" }}>
|
||||
<SortableContext items={boardSortableIds} strategy={horizontalListSortingStrategy}>
|
||||
<Group
|
||||
align="stretch"
|
||||
wrap="nowrap"
|
||||
gap="md"
|
||||
p="md"
|
||||
style={{ height: "100%", overflowX: "auto" }}
|
||||
>
|
||||
{boardColumns.map((col) => (
|
||||
<KanbanColumn
|
||||
key={col.id}
|
||||
column={col}
|
||||
cards={cardsByColumn.get(col.id) ?? []}
|
||||
now={now}
|
||||
onAddCard={openCreateCard}
|
||||
onRenameColumn={handleRenameColumn}
|
||||
onResizeColumn={handleResizeColumn}
|
||||
onMoveColumnLocation={handleMoveColumnLocation}
|
||||
onDeleteColumn={handleDeleteColumn}
|
||||
onEditCard={openEditCard}
|
||||
onDeleteCard={handleDeleteCard}
|
||||
onChangeCardColor={handleChangeCardColor}
|
||||
onShowHistory={handleShowHistory}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Box style={{ minWidth: 280, maxWidth: 320 }}>
|
||||
{addingCol ? (
|
||||
<Stack gap={4}>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Nombre de columna..."
|
||||
value={colName}
|
||||
onChange={(e) => setColName(e.currentTarget.value)}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddColumn();
|
||||
if (e.key === "Escape") {
|
||||
setAddingCol(false);
|
||||
setColName("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Group gap={4}>
|
||||
<Button size="xs" onClick={handleAddColumn}>
|
||||
Anadir
|
||||
</Button>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => setAddingCol(false)}>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => setAddingCol(true)}
|
||||
>
|
||||
Anadir columna
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
</SortableContext>
|
||||
</Box>
|
||||
</AppShell.Main>
|
||||
|
||||
</AppShell>
|
||||
|
||||
<DragOverlay>
|
||||
{dragOverlayCard ? (
|
||||
<KanbanCard
|
||||
card={dragOverlayCard}
|
||||
now={now}
|
||||
onDelete={() => {}}
|
||||
onEdit={() => {}}
|
||||
onChangeColor={() => {}}
|
||||
onShowHistory={() => {}}
|
||||
isOverlay
|
||||
/>
|
||||
) : dragOverlayColumn ? (
|
||||
<Box
|
||||
style={{
|
||||
width: dragOverlayColumn.location === "sidebar" ? 220 : dragOverlayColumn.width,
|
||||
padding: 8,
|
||||
background: colorBg(""),
|
||||
border: `1px solid ${colorBorder("")}`,
|
||||
borderRadius: 8,
|
||||
opacity: 0.9,
|
||||
}}
|
||||
>
|
||||
<Text fw={600} size="sm">
|
||||
{dragOverlayColumn.name}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { Board, Card, Column, HistoryEntry } from "./types";
|
||||
|
||||
const BASE = "/api";
|
||||
|
||||
async function fetchJSON<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers || {}) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ Message: res.statusText }));
|
||||
throw new Error(err.Message || err.message || res.statusText);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function getBoard(): Promise<Board> {
|
||||
return fetchJSON("/board");
|
||||
}
|
||||
|
||||
export function createColumn(name: string): Promise<Column> {
|
||||
return fetchJSON("/columns", { method: "POST", body: JSON.stringify({ name }) });
|
||||
}
|
||||
|
||||
export interface UpdateColumnInput {
|
||||
name?: string;
|
||||
position?: number;
|
||||
location?: "board" | "sidebar";
|
||||
width?: number;
|
||||
}
|
||||
|
||||
export function updateColumn(id: string, patch: UpdateColumnInput): Promise<void> {
|
||||
return fetchJSON(`/columns/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteColumn(id: string): Promise<void> {
|
||||
return fetchJSON(`/columns/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function reorderColumns(ids: string[]): Promise<void> {
|
||||
return fetchJSON("/columns/reorder", { method: "POST", body: JSON.stringify({ ids }) });
|
||||
}
|
||||
|
||||
export interface CreateCardInput {
|
||||
column_id: string;
|
||||
requester?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function createCard(input: CreateCardInput): Promise<Card> {
|
||||
return fetchJSON("/cards", { method: "POST", body: JSON.stringify(input) });
|
||||
}
|
||||
|
||||
export interface UpdateCardInput {
|
||||
requester?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function updateCard(id: string, patch: UpdateCardInput): Promise<void> {
|
||||
return fetchJSON(`/cards/${id}`, { method: "PATCH", body: JSON.stringify(patch) });
|
||||
}
|
||||
|
||||
export function deleteCard(id: string): Promise<void> {
|
||||
return fetchJSON(`/cards/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function moveCard(id: string, column_id: string, ordered_ids: string[]): Promise<void> {
|
||||
return fetchJSON(`/cards/${id}/move`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ column_id, ordered_ids }),
|
||||
});
|
||||
}
|
||||
|
||||
export function cardHistory(id: string): Promise<HistoryEntry[]> {
|
||||
return fetchJSON(`/cards/${id}/history`);
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatToolCall {
|
||||
tool: string;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
role: "assistant";
|
||||
content: string;
|
||||
board_changed: boolean;
|
||||
tool_calls?: ChatToolCall[];
|
||||
}
|
||||
|
||||
export function sendChat(messages: ChatMessage[]): Promise<ChatResponse> {
|
||||
return fetchJSON("/chat", { method: "POST", body: JSON.stringify({ messages }) });
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { FormEvent, KeyboardEvent, useState } from "react";
|
||||
|
||||
export interface CardFormValues {
|
||||
requester: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
initial?: Partial<CardFormValues>;
|
||||
submitLabel?: string;
|
||||
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function CardForm({ initial, submitLabel = "Guardar", onSubmit, onCancel }: Props) {
|
||||
const [requester, setRequester] = useState(initial?.requester ?? "");
|
||||
const [title, setTitle] = useState(initial?.title ?? "");
|
||||
const [description, setDescription] = useState(initial?.description ?? "");
|
||||
|
||||
const submit = async (e?: FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const t = title.trim();
|
||||
if (!t) return;
|
||||
await onSubmit({ requester: requester.trim(), title: t, description });
|
||||
};
|
||||
|
||||
// Enter en TextInput envia el form. Enter en Textarea inserta newline; Ctrl/Cmd+Enter envia.
|
||||
const enterSubmit = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
const textareaEnter = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Solicitante"
|
||||
value={requester}
|
||||
onChange={(e) => setRequester(e.currentTarget.value)}
|
||||
tabIndex={1}
|
||||
autoComplete="off"
|
||||
data-autofocus
|
||||
onKeyDown={enterSubmit}
|
||||
/>
|
||||
<TextInput
|
||||
label="Tarea"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.currentTarget.value)}
|
||||
tabIndex={2}
|
||||
required
|
||||
autoComplete="off"
|
||||
onKeyDown={enterSubmit}
|
||||
/>
|
||||
<Textarea
|
||||
label="Descripcion"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
||||
tabIndex={3}
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={8}
|
||||
onKeyDown={textareaEnter}
|
||||
description="Ctrl+Enter para guardar"
|
||||
/>
|
||||
<Group justify="flex-end" gap="xs" mt="xs">
|
||||
<Button variant="subtle" color="gray" tabIndex={5} type="button" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button tabIndex={4} type="submit" disabled={!title.trim()}>
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IconMessageChatbot, IconSend, IconTrash } from "@tabler/icons-react";
|
||||
import { KeyboardEvent, useEffect, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { ChatMessage, ChatToolCall, sendChat } from "../api";
|
||||
|
||||
const STORAGE_KEY = "kanban_chat_v1";
|
||||
|
||||
interface StoredMessage extends ChatMessage {
|
||||
ts: number;
|
||||
tool_calls?: ChatToolCall[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onBoardChange: () => void;
|
||||
}
|
||||
|
||||
function loadStored(): StoredMessage[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function ChatPanel({ onBoardChange }: Props) {
|
||||
const [messages, setMessages] = useState<StoredMessage[]>(() => loadStored());
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
||||
}, [messages, loading]);
|
||||
|
||||
const send = async () => {
|
||||
const text = input.trim();
|
||||
if (!text || loading) return;
|
||||
const userMsg: StoredMessage = { role: "user", content: text, ts: Date.now() };
|
||||
const next = [...messages, userMsg];
|
||||
setMessages(next);
|
||||
setInput("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload: ChatMessage[] = next.map((m) => ({ role: m.role, content: m.content }));
|
||||
const res = await sendChat(payload);
|
||||
const assistant: StoredMessage = {
|
||||
role: "assistant",
|
||||
content: res.content,
|
||||
ts: Date.now(),
|
||||
tool_calls: res.tool_calls,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistant]);
|
||||
if (res.board_changed) onBoardChange();
|
||||
} catch (e) {
|
||||
notifications.show({ color: "red", message: (e as Error).message });
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: `Error: ${(e as Error).message}`, ts: Date.now() },
|
||||
]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setMessages([]);
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%">
|
||||
<Group justify="space-between" p="xs" style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }}>
|
||||
<Group gap={6}>
|
||||
<IconMessageChatbot size={18} />
|
||||
<Text fw={600} size="sm">
|
||||
Asistente
|
||||
</Text>
|
||||
</Group>
|
||||
<Tooltip label="Limpiar conversacion" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" onClick={clear} disabled={messages.length === 0}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<ScrollArea viewportRef={scrollRef} style={{ flex: 1 }} type="auto" p="xs">
|
||||
<Stack gap="xs">
|
||||
{messages.length === 0 && (
|
||||
<Text size="sm" c="dimmed" ta="center" mt="md">
|
||||
Escribe algo. Ejemplos:
|
||||
<br />- "crea columna Backlog"
|
||||
<br />- "anade tarjeta para revisar PR de Lucas en Doing"
|
||||
<br />- "que hay en Doing?"
|
||||
</Text>
|
||||
)}
|
||||
{messages.map((m, i) => (
|
||||
<ChatBubble key={i} msg={m} />
|
||||
))}
|
||||
{loading && (
|
||||
<Group gap={6} pl="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Pensando...
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
<Stack gap={4} p="xs" style={{ borderTop: "1px solid var(--mantine-color-dark-4)" }}>
|
||||
<Group align="flex-end" gap={4} wrap="nowrap">
|
||||
<Textarea
|
||||
placeholder="Pide algo... (Enter envia, Shift+Enter newline)"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.currentTarget.value)}
|
||||
onKeyDown={onKey}
|
||||
disabled={loading}
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="filled"
|
||||
onClick={send}
|
||||
disabled={!input.trim() || loading}
|
||||
aria-label="Send"
|
||||
>
|
||||
{loading ? <Loader size="xs" color="white" /> : <IconSend size={16} />}
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatBubble({ msg }: { msg: StoredMessage }) {
|
||||
const isUser = msg.role === "user";
|
||||
return (
|
||||
<Paper
|
||||
p="xs"
|
||||
radius="md"
|
||||
withBorder
|
||||
bg={isUser ? "blue.9" : "dark.6"}
|
||||
style={{ alignSelf: isUser ? "flex-end" : "flex-start", maxWidth: "92%" }}
|
||||
>
|
||||
<Stack gap={4}>
|
||||
{msg.content && (
|
||||
<Box
|
||||
className="kanban-md"
|
||||
style={{ fontSize: 13, lineHeight: 1.45, color: "var(--mantine-color-text)" }}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||
</Box>
|
||||
)}
|
||||
{msg.tool_calls && msg.tool_calls.length > 0 && (
|
||||
<Group gap={4} wrap="wrap">
|
||||
{msg.tool_calls.map((c, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
size="xs"
|
||||
color={c.ok ? "teal" : "red"}
|
||||
variant="light"
|
||||
title={c.error || ""}
|
||||
>
|
||||
{c.tool}
|
||||
{!c.ok && c.error ? `: ${c.error}` : ""}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
|
||||
import { IconColumns3 } from "@tabler/icons-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { cardHistory } from "../api";
|
||||
import type { Card, HistoryEntry } from "../types";
|
||||
import { formatDuration } from "./format";
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
}
|
||||
|
||||
export function HistoryModal({ card }: Props) {
|
||||
const [entries, setEntries] = useState<HistoryEntry[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cardHistory(card.id).then(setEntries).catch(() => setEntries([]));
|
||||
}, [card.id]);
|
||||
|
||||
if (!entries) {
|
||||
return (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <Text c="dimmed">Sin historial.</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Tiempo total en cada columna desde que se creo la tarjeta.
|
||||
</Text>
|
||||
<Timeline active={entries.length} bulletSize={22} lineWidth={2}>
|
||||
{entries.map((e) => (
|
||||
<Timeline.Item
|
||||
key={e.id}
|
||||
bullet={<IconColumns3 size={12} />}
|
||||
title={
|
||||
<Group gap={6}>
|
||||
<Text fw={500} size="sm">
|
||||
{e.column_name || e.column_id}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color={e.exited_at ? "gray" : "blue"}>
|
||||
{formatDuration(e.duration_ms)}
|
||||
</Badge>
|
||||
{!e.exited_at && (
|
||||
<Badge size="xs" variant="filled" color="blue">
|
||||
actual
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(e.entered_at).toLocaleString()}
|
||||
{e.exited_at && ` -> ${new Date(e.exited_at).toLocaleString()}`}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Group,
|
||||
Paper,
|
||||
Popover,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconClock,
|
||||
IconEdit,
|
||||
IconGripVertical,
|
||||
IconHistory,
|
||||
IconPalette,
|
||||
IconTrash,
|
||||
IconUser,
|
||||
} from "@tabler/icons-react";
|
||||
import { memo, useState } from "react";
|
||||
import type { Card, CardColor } from "../types";
|
||||
import { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors";
|
||||
import { formatDuration } from "./format";
|
||||
|
||||
interface Props {
|
||||
card: Card;
|
||||
now: number;
|
||||
onDelete: (id: string) => void;
|
||||
onEdit: (card: Card) => void;
|
||||
onChangeColor: (id: string, color: CardColor) => void;
|
||||
onShowHistory: (card: Card) => void;
|
||||
isOverlay?: boolean;
|
||||
}
|
||||
|
||||
function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHistory, isOverlay }: Props) {
|
||||
const [popOpen, setPopOpen] = useState(false);
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: card.id,
|
||||
data: { type: "card", columnId: card.column_id },
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
background: colorBg(card.color),
|
||||
borderColor: colorBorder(card.color),
|
||||
};
|
||||
|
||||
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
|
||||
const liveMs = Math.max(0, now - enteredAt);
|
||||
|
||||
return (
|
||||
<Paper ref={setNodeRef} style={style} withBorder p="xs" shadow={isOverlay ? "lg" : "xs"} radius="md">
|
||||
<Stack gap={6}>
|
||||
<Group justify="space-between" gap={4} wrap="nowrap">
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{ cursor: "grab" }}
|
||||
aria-label="Drag"
|
||||
>
|
||||
<IconGripVertical size={14} />
|
||||
</ActionIcon>
|
||||
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>
|
||||
{card.title}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<Popover opened={popOpen} onChange={setPopOpen} withArrow shadow="md" position="bottom-end">
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => setPopOpen((v) => !v)}
|
||||
aria-label="Color"
|
||||
>
|
||||
<IconPalette size={14} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<Group gap={4} maw={200}>
|
||||
{CARD_COLORS.map((c) => (
|
||||
<Tooltip key={c.value} label={c.label} withArrow>
|
||||
<ActionIcon
|
||||
variant={card.color === c.value ? "filled" : "default"}
|
||||
size="md"
|
||||
radius="xl"
|
||||
style={{
|
||||
background: colorSwatch(c.value),
|
||||
borderColor: colorBorder(c.value),
|
||||
}}
|
||||
onClick={() => {
|
||||
onChangeColor(card.id, c.value);
|
||||
setPopOpen(false);
|
||||
}}
|
||||
aria-label={c.label}
|
||||
/>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Group>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" onClick={() => onEdit(card)} aria-label="Edit">
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => onShowHistory(card)}
|
||||
aria-label="History"
|
||||
>
|
||||
<IconHistory size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => onDelete(card.id)} aria-label="Delete">
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
{card.requester && (
|
||||
<Group gap={4}>
|
||||
<IconUser size={12} />
|
||||
<Text size="xs" c="dimmed">
|
||||
{card.requester}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{card.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={3}>
|
||||
{card.description}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap={4}>
|
||||
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
|
||||
{formatDuration(liveMs)}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// memo: re-render solo cuando cambian props relevantes (card, now). Evita rerenders
|
||||
// en cascada cuando otra columna cambia durante drag-over.
|
||||
export const KanbanCard = memo(KanbanCardImpl);
|
||||
@@ -0,0 +1,296 @@
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArchive,
|
||||
IconArchiveOff,
|
||||
IconCheck,
|
||||
IconGripVertical,
|
||||
IconPencil,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { memo, MouseEvent, useEffect, useRef, useState } from "react";
|
||||
import type { Card, CardColor, Column } from "../types";
|
||||
import { KanbanCard } from "./KanbanCard";
|
||||
|
||||
interface Props {
|
||||
column: Column;
|
||||
cards: Card[];
|
||||
now: number;
|
||||
collapsed?: boolean;
|
||||
onAddCard: (columnId: string) => void;
|
||||
onRenameColumn: (id: string, name: string) => void;
|
||||
onResizeColumn: (id: string, width: number) => void;
|
||||
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
|
||||
onDeleteColumn: (id: string) => void;
|
||||
onEditCard: (card: Card) => void;
|
||||
onDeleteCard: (id: string) => void;
|
||||
onChangeCardColor: (id: string, color: CardColor) => void;
|
||||
onShowHistory: (card: Card) => void;
|
||||
}
|
||||
|
||||
function KanbanColumnImpl({
|
||||
column,
|
||||
cards,
|
||||
now,
|
||||
collapsed,
|
||||
onAddCard,
|
||||
onRenameColumn,
|
||||
onResizeColumn,
|
||||
onMoveColumnLocation,
|
||||
onDeleteColumn,
|
||||
onEditCard,
|
||||
onDeleteCard,
|
||||
onChangeCardColor,
|
||||
onShowHistory,
|
||||
}: Props) {
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [name, setName] = useState(column.name);
|
||||
const [localWidth, setLocalWidth] = useState<number | null>(null);
|
||||
|
||||
// sync local width when column.width changes from outside (other clients).
|
||||
useEffect(() => {
|
||||
setLocalWidth(null);
|
||||
}, [column.width]);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: `column-${column.id}`,
|
||||
data: { type: "column", columnId: column.id, location: column.location },
|
||||
});
|
||||
|
||||
const effectiveWidth = collapsed ? 220 : localWidth ?? column.width;
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
width: effectiveWidth,
|
||||
minWidth: effectiveWidth,
|
||||
maxWidth: effectiveWidth,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
position: "relative",
|
||||
};
|
||||
|
||||
const cardIds = cards.map((c) => c.id);
|
||||
|
||||
const submitRename = () => {
|
||||
const trimmed = name.trim();
|
||||
if (trimmed && trimmed !== column.name) onRenameColumn(column.id, trimmed);
|
||||
setRenaming(false);
|
||||
};
|
||||
|
||||
// --- resize handle ---
|
||||
const resizingRef = useRef<{ startX: number; startWidth: number } | null>(null);
|
||||
|
||||
const onResizeMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
resizingRef.current = { startX: e.clientX, startWidth: column.width };
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
const onMove = (ev: globalThis.MouseEvent) => {
|
||||
if (!resizingRef.current) return;
|
||||
const dx = ev.clientX - resizingRef.current.startX;
|
||||
const next = Math.min(800, Math.max(200, resizingRef.current.startWidth + dx));
|
||||
setLocalWidth(next);
|
||||
};
|
||||
const onUp = () => {
|
||||
if (resizingRef.current && localWidthRef.current !== null) {
|
||||
onResizeColumn(column.id, localWidthRef.current);
|
||||
}
|
||||
resizingRef.current = null;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
window.removeEventListener("mousemove", onMove);
|
||||
window.removeEventListener("mouseup", onUp);
|
||||
};
|
||||
window.addEventListener("mousemove", onMove);
|
||||
window.addEventListener("mouseup", onUp);
|
||||
};
|
||||
|
||||
// mirror localWidth into a ref so the mouseup handler always sees the latest value.
|
||||
const localWidthRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
localWidthRef.current = localWidth;
|
||||
}, [localWidth]);
|
||||
|
||||
const isInSidebar = column.location === "sidebar";
|
||||
const archiveLabel = isInSidebar ? "Restaurar al board" : "Mover al sidebar";
|
||||
const ArchiveIcon = isInSidebar ? IconArchiveOff : IconArchive;
|
||||
|
||||
return (
|
||||
<Paper ref={setNodeRef} style={style} withBorder radius="md" p="sm" bg="dark.7">
|
||||
<Group justify="space-between" mb="xs" wrap="nowrap">
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{ cursor: "grab" }}
|
||||
aria-label="Drag column"
|
||||
>
|
||||
<IconGripVertical size={14} />
|
||||
</ActionIcon>
|
||||
{renaming ? (
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
autoFocus
|
||||
onBlur={submitRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submitRename();
|
||||
if (e.key === "Escape") {
|
||||
setName(column.name);
|
||||
setRenaming(false);
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
truncate
|
||||
onDoubleClick={() => {
|
||||
setName(column.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
style={{ flex: 1, cursor: "text" }}
|
||||
title="Doble click para renombrar"
|
||||
>
|
||||
{column.name}
|
||||
</Text>
|
||||
)}
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{cards.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap={2} wrap="nowrap">
|
||||
{renaming ? (
|
||||
<>
|
||||
<ActionIcon variant="subtle" color="green" size="sm" onClick={submitRename} aria-label="Save">
|
||||
<IconCheck size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setName(column.name);
|
||||
setRenaming(false);
|
||||
}}
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setName(column.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
aria-label="Rename"
|
||||
>
|
||||
<IconPencil size={14} />
|
||||
</ActionIcon>
|
||||
<Tooltip label={archiveLabel} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
|
||||
aria-label={archiveLabel}
|
||||
>
|
||||
<ArchiveIcon size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => onDeleteColumn(column.id)}
|
||||
aria-label="Delete column"
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} type="auto">
|
||||
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
|
||||
<Stack gap="xs" pb="xs" style={{ minHeight: 40 }}>
|
||||
{cards.map((c) => (
|
||||
<KanbanCard
|
||||
key={c.id}
|
||||
card={c}
|
||||
now={now}
|
||||
onDelete={onDeleteCard}
|
||||
onEdit={onEditCard}
|
||||
onChangeColor={onChangeCardColor}
|
||||
onShowHistory={onShowHistory}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</SortableContext>
|
||||
</ScrollArea>
|
||||
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={() => onAddCard(column.id)}
|
||||
mt="xs"
|
||||
fullWidth
|
||||
>
|
||||
Anadir tarjeta
|
||||
</Button>
|
||||
|
||||
{/* Resize handle (only on board, not sidebar) */}
|
||||
{!isInSidebar && (
|
||||
<Box
|
||||
onMouseDown={onResizeMouseDown}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: -3,
|
||||
width: 6,
|
||||
height: "100%",
|
||||
cursor: "col-resize",
|
||||
zIndex: 5,
|
||||
}}
|
||||
aria-label="Resize column"
|
||||
/>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export const KanbanColumn = memo(KanbanColumnImpl);
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { CardColor } from "../types";
|
||||
|
||||
export const CARD_COLORS: { value: CardColor; label: string }[] = [
|
||||
{ value: "", label: "Default" },
|
||||
{ value: "blue", label: "Azul" },
|
||||
{ value: "teal", label: "Teal" },
|
||||
{ value: "green", label: "Verde" },
|
||||
{ value: "yellow", label: "Amarillo" },
|
||||
{ value: "orange", label: "Naranja" },
|
||||
{ value: "red", label: "Rojo" },
|
||||
{ value: "pink", label: "Rosa" },
|
||||
{ value: "violet", label: "Violeta" },
|
||||
{ value: "indigo", label: "Indigo" },
|
||||
];
|
||||
|
||||
// color-mix mezcla 18% del tono base con dark.6 → suave en dark mode.
|
||||
// Border 30% del tono mas claro con dark.4 para definicion sutil.
|
||||
// Swatch (boton picker) usa tono pleno -7 para que sea visible.
|
||||
export function colorBg(color: CardColor): string {
|
||||
if (color === "") return "var(--mantine-color-dark-6)";
|
||||
return `color-mix(in srgb, var(--mantine-color-${color}-9) 18%, var(--mantine-color-dark-6))`;
|
||||
}
|
||||
|
||||
export function colorBorder(color: CardColor): string {
|
||||
if (color === "") return "var(--mantine-color-dark-4)";
|
||||
return `color-mix(in srgb, var(--mantine-color-${color}-7) 30%, var(--mantine-color-dark-4))`;
|
||||
}
|
||||
|
||||
export function colorSwatch(color: CardColor): string {
|
||||
if (color === "") return "var(--mantine-color-dark-3)";
|
||||
return `var(--mantine-color-${color}-7)`;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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;
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms < 0) return "0m";
|
||||
if (ms < HOUR) return `${Math.floor(ms / MIN)}m`;
|
||||
if (ms < DAY) {
|
||||
const h = Math.floor(ms / HOUR);
|
||||
const m = Math.floor((ms % HOUR) / MIN);
|
||||
return m === 0 ? `${h}h` : `${h}h ${m}m`;
|
||||
}
|
||||
if (ms < WEEK) {
|
||||
const d = Math.floor(ms / DAY);
|
||||
const h = Math.floor((ms % DAY) / HOUR);
|
||||
return h === 0 ? `${d}D` : `${d}D ${h}h`;
|
||||
}
|
||||
if (ms < MONTH) {
|
||||
const w = Math.floor(ms / WEEK);
|
||||
const d = Math.floor((ms % WEEK) / DAY);
|
||||
return d === 0 ? `${w}S` : `${w}S ${d}D`;
|
||||
}
|
||||
const m = Math.floor(ms / MONTH);
|
||||
const w = Math.floor((ms % MONTH) / WEEK);
|
||||
return w === 0 ? `${m}M` : `${m}M ${w}S`;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import { MantineProvider, createTheme } from "@mantine/core";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
const theme = createTheme({
|
||||
primaryColor: "blue",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||
<ModalsProvider>
|
||||
<Notifications position="top-right" />
|
||||
<App />
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
@@ -0,0 +1,41 @@
|
||||
export type ColumnLocation = "board" | "sidebar";
|
||||
|
||||
export interface Column {
|
||||
id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
location: ColumnLocation;
|
||||
width: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type CardColor = "" | "blue" | "teal" | "green" | "yellow" | "orange" | "red" | "pink" | "violet" | "indigo";
|
||||
|
||||
export interface Card {
|
||||
id: string;
|
||||
requester: string;
|
||||
title: string;
|
||||
description: string;
|
||||
color: CardColor;
|
||||
column_id: string;
|
||||
position: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
entered_at: string;
|
||||
time_in_column_ms: number;
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
columns: Column[];
|
||||
cards: Card[];
|
||||
}
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
card_id: string;
|
||||
column_id: string;
|
||||
column_name: string;
|
||||
entered_at: string;
|
||||
exited_at: string | null;
|
||||
duration_ms: number;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5180,
|
||||
proxy: {
|
||||
"/api": "http://localhost:8095",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
module kanban
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require fn-registry v0.0.0-00010101000000-000000000000
|
||||
|
||||
require (
|
||||
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/apache/arrow-go/v18 v18.1.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/google/flatbuffers v25.1.24+incompatible // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/klauspost/compress v1.18.3 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/marcboeker/go-duckdb v1.8.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.37 // indirect
|
||||
github.com/paulmach/orb v0.12.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.25 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
nhooyr.io/websocket v1.8.17 // indirect
|
||||
)
|
||||
|
||||
replace fn-registry => ../..
|
||||
@@ -0,0 +1,176 @@
|
||||
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
||||
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/apache/arrow-go/v18 v18.1.0 h1:agLwJUiVuwXZdwPYVrlITfx7bndULJ/dggbnLFgDp/Y=
|
||||
github.com/apache/arrow-go/v18 v18.1.0/go.mod h1:tigU/sIgKNXaesf5d7Y95jBBKS5KsxTqYBKXFsvKzo0=
|
||||
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
|
||||
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
|
||||
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/flatbuffers v25.1.24+incompatible h1:4wPqL3K7GzBd1CwyhSd3usxLKOaJN/AC6puCca6Jm7o=
|
||||
github.com/google/flatbuffers v25.1.24+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/asmfmt v1.3.2 h1:4Ri7ox3EwapiOjCki+hw14RyKk201CN4rzyCJRFLpK4=
|
||||
github.com/klauspost/asmfmt v1.3.2/go.mod h1:AG8TuvYojzulgDAMCnYn50l/5QV3Bs/tp6j0HLHbNSE=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/marcboeker/go-duckdb v1.8.5 h1:tkYp+TANippy0DaIOP5OEfBEwbUINqiFqgwMQ44jME0=
|
||||
github.com/marcboeker/go-duckdb v1.8.5/go.mod h1:6mK7+WQE4P4u5AFLvVBmhFxY5fvhymFptghgJX6B+/8=
|
||||
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
|
||||
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8 h1:AMFGa4R4MiIpspGNG7Z948v4n35fFGB3RR3G/ry4FWs=
|
||||
github.com/minio/asm2plan9s v0.0.0-20200509001527-cdd76441f9d8/go.mod h1:mC1jAcsrzbxHt8iiaC+zU4b1ylILSosueou12R++wfY=
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3 h1:+n/aFZefKZp7spd8DFdX7uMikMLXX4oubIzJF4kv/wI=
|
||||
github.com/minio/c2goasm v0.0.0-20190812172519-36a3d3bbc4f3/go.mod h1:RagcQ7I8IeTMnF8JTXieKnO4Z6JCsikNEzj0DwauVzE=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
|
||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
|
||||
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
||||
gonum.org/v1/gonum v0.15.1 h1:FNy7N6OUZVUaWG9pTiD+jlhdQ3lMP+/LcTpJ6+a8sQ0=
|
||||
gonum.org/v1/gonum v0.15.1/go.mod h1:eZTZuRFrzu5pcyjN5wJhcIhnUdNijYxX1T2IcrOGY0o=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=
|
||||
nhooyr.io/websocket v1.8.17/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c=
|
||||
+230
@@ -0,0 +1,230 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
const maxBodyBytes = 1 << 20 // 1 MiB
|
||||
|
||||
func badRequest(w http.ResponseWriter, msg string) {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusBadRequest, Code: "bad_request", Message: msg})
|
||||
}
|
||||
|
||||
func notFound(w http.ResponseWriter, msg string) {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusNotFound, Code: "not_found", Message: msg})
|
||||
}
|
||||
|
||||
func serverError(w http.ResponseWriter, err error) {
|
||||
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusInternalServerError, Code: "internal", Message: err.Error()})
|
||||
}
|
||||
|
||||
// GET /api/board → { columns: [...], cards: [...] }
|
||||
func handleGetBoard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
cols, err := db.ListColumns()
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
cards, err := db.ListCardsWithTime()
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, map[string]any{
|
||||
"columns": cols,
|
||||
"cards": cards,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/columns { name }
|
||||
func handleCreateColumn(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ Name string `json:"name"` }
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Name) == "" {
|
||||
badRequest(w, "name required")
|
||||
return
|
||||
}
|
||||
c, err := db.CreateColumn(body.Name)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusCreated, c)
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/columns/{id} { name?, position?, location?, width? }
|
||||
func handleUpdateColumn(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var body struct {
|
||||
Name *string `json:"name"`
|
||||
Position *int `json:"position"`
|
||||
Location *string `json:"location"`
|
||||
Width *int `json:"width"`
|
||||
}
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := db.UpdateColumn(id, ColumnPatch{Name: body.Name, Position: body.Position, Location: body.Location, Width: body.Width}); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/columns/{id}
|
||||
func handleDeleteColumn(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := db.DeleteColumn(id); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/columns/reorder { ids: [...] }
|
||||
func handleReorderColumns(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct{ IDs []string `json:"ids"` }
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := db.ReorderColumns(body.IDs); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/cards { column_id, requester?, title, description? }
|
||||
func handleCreateCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
ColumnID string `json:"column_id"`
|
||||
Requester string `json:"requester"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if body.ColumnID == "" || strings.TrimSpace(body.Title) == "" {
|
||||
badRequest(w, "column_id and title required")
|
||||
return
|
||||
}
|
||||
c, err := db.CreateCard(body.ColumnID, body.Requester, body.Title, body.Description)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusCreated, c)
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/cards/{id} { requester?, title?, description?, color? }
|
||||
func handleUpdateCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var body struct {
|
||||
Requester *string `json:"requester"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := db.UpdateCard(id, CardPatch{Requester: body.Requester, Title: body.Title, Description: body.Description, Color: body.Color}); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/cards/{id}
|
||||
func handleDeleteCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := db.DeleteCard(id); err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/cards/{id}/move { column_id, ordered_ids }
|
||||
func handleMoveCard(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
var body struct {
|
||||
ColumnID string `json:"column_id"`
|
||||
OrderedIDs []string `json:"ordered_ids"`
|
||||
}
|
||||
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if body.ColumnID == "" {
|
||||
badRequest(w, "column_id required")
|
||||
return
|
||||
}
|
||||
if err := db.MoveCard(id, body.ColumnID, body.OrderedIDs); err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
notFound(w, "card not found")
|
||||
return
|
||||
}
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/cards/{id}/history → [HistoryEntry, ...]
|
||||
func handleCardHistory(db *DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
entries, err := db.CardHistory(id)
|
||||
if err != nil {
|
||||
serverError(w, err)
|
||||
return
|
||||
}
|
||||
infra.HTTPJSONResponse(w, http.StatusOK, entries)
|
||||
}
|
||||
}
|
||||
|
||||
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger) []infra.Route {
|
||||
return []infra.Route{
|
||||
{Method: "GET", Path: "/api/board", Handler: handleGetBoard(db)},
|
||||
{Method: "POST", Path: "/api/columns", Handler: handleCreateColumn(db)},
|
||||
{Method: "POST", Path: "/api/columns/reorder", Handler: handleReorderColumns(db)},
|
||||
{Method: "PATCH", Path: "/api/columns/{id}", Handler: handleUpdateColumn(db)},
|
||||
{Method: "DELETE", Path: "/api/columns/{id}", Handler: handleDeleteColumn(db)},
|
||||
{Method: "POST", Path: "/api/cards", Handler: handleCreateCard(db)},
|
||||
{Method: "PATCH", Path: "/api/cards/{id}", Handler: handleUpdateCard(db)},
|
||||
{Method: "DELETE", Path: "/api/cards/{id}", Handler: handleDeleteCard(db)},
|
||||
{Method: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(db)},
|
||||
{Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)},
|
||||
{Method: "POST", Path: "/api/chat", Handler: handleChat(db, chatWorkdir, logger)},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
)
|
||||
|
||||
//go:embed all:frontend/dist
|
||||
var frontendDist embed.FS
|
||||
|
||||
func main() {
|
||||
flags := flag.NewFlagSet("kanban", flag.ExitOnError)
|
||||
port := flags.Int("port", 8095, "HTTP port")
|
||||
dbPath := flags.String("db", "operations.db", "SQLite database path")
|
||||
flags.Parse(os.Args[1:])
|
||||
|
||||
db, err := openDB(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open db: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
wd := chatWorkdir(*dbPath)
|
||||
logger := newChatLogger(filepath.Join(wd, "chat.log"))
|
||||
log.Printf("chat tool log: %s", logger.path)
|
||||
mux := infra.HTTPRouter(apiRoutes(db, wd, logger))
|
||||
|
||||
feHandler := frontendHandler()
|
||||
if feHandler != nil {
|
||||
mux.Handle("/", feHandler)
|
||||
log.Printf("serving frontend from embedded dist/")
|
||||
} else {
|
||||
log.Printf("no frontend build found, API-only mode")
|
||||
}
|
||||
|
||||
chain := infra.HTTPMiddlewareChain(
|
||||
infra.HTTPLoggerMiddleware(os.Stdout),
|
||||
infra.HTTPCORSMiddleware([]string{"*"}, []string{"GET", "POST", "PATCH", "DELETE", "OPTIONS"}),
|
||||
)
|
||||
handler := chain(mux)
|
||||
|
||||
addr := fmt.Sprintf(":%d", *port)
|
||||
log.Printf("kanban server starting on http://0.0.0.0%s", addr)
|
||||
log.Printf("database: %s", *dbPath)
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
if err := infra.HTTPServe(addr, handler, ctx); err != nil {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func frontendHandler() http.Handler {
|
||||
sub, err := fs.Sub(frontendDist, "frontend/dist")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
entries, _ := fs.ReadDir(sub, ".")
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
return infra.SPAHandler(sub, "index.html")
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
CREATE TABLE IF NOT EXISTS columns (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT NOT NULL DEFAULT 'board' CHECK(location IN ('board','sidebar')),
|
||||
width INTEGER NOT NULL DEFAULT 300,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cards (
|
||||
id TEXT PRIMARY KEY,
|
||||
requester TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
column_id TEXT NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS card_column_history (
|
||||
id TEXT PRIMARY KEY,
|
||||
card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
|
||||
column_id TEXT NOT NULL,
|
||||
entered_at TEXT NOT NULL,
|
||||
exited_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cards_column ON cards(column_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cards_position ON cards(column_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_card ON card_column_history(card_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_columns_position ON columns(position);
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Lanza backend Go (puerto 8095) + frontend Vite dev (puerto 5180) en paralelo.
|
||||
# Vite hace proxy /api -> 8095, asi que abrir http://localhost:5180
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
PORT_BACK="${PORT_BACK:-8095}"
|
||||
PORT_FRONT="${PORT_FRONT:-5180}"
|
||||
DB_PATH="${DB_PATH:-./operations.db}"
|
||||
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo ">>> Stopping..."
|
||||
[[ -n "${BACK_PID:-}" ]] && kill "$BACK_PID" 2>/dev/null || true
|
||||
[[ -n "${FRONT_PID:-}" ]] && kill "$FRONT_PID" 2>/dev/null || true
|
||||
wait 2>/dev/null || true
|
||||
exit 0
|
||||
}
|
||||
trap cleanup INT TERM EXIT
|
||||
|
||||
# 1. Build backend si no existe o si los .go/.sql son mas nuevos que el binario
|
||||
if [[ ! -x ./kanban ]] || [[ -n "$(find . -maxdepth 3 \( -name '*.go' -o -name '*.sql' \) -newer ./kanban 2>/dev/null)" ]]; then
|
||||
echo ">>> Building backend..."
|
||||
CGO_ENABLED=1 go build -tags fts5 -o kanban .
|
||||
fi
|
||||
|
||||
# 2. Asegurar deps frontend
|
||||
if [[ ! -d frontend/node_modules ]]; then
|
||||
echo ">>> Installing frontend deps..."
|
||||
(cd frontend && pnpm install)
|
||||
fi
|
||||
|
||||
# 3. Lanzar backend
|
||||
echo ">>> Backend http://localhost:$PORT_BACK (db=$DB_PATH)"
|
||||
./kanban --port "$PORT_BACK" --db "$DB_PATH" &
|
||||
BACK_PID=$!
|
||||
|
||||
# 4. Lanzar frontend (Vite con HMR + proxy a backend)
|
||||
echo ">>> Frontend http://localhost:$PORT_FRONT (HMR)"
|
||||
(cd frontend && pnpm dev --port "$PORT_FRONT" --strictPort) &
|
||||
FRONT_PID=$!
|
||||
|
||||
echo ""
|
||||
echo ">>> PIDs: back=$BACK_PID front=$FRONT_PID"
|
||||
echo ">>> Abrir: http://localhost:$PORT_FRONT"
|
||||
echo ">>> Ctrl+C para parar ambos"
|
||||
|
||||
wait -n
|
||||
@@ -0,0 +1,317 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ToolResult is the uniform shape returned to the chat loop after a tool call.
|
||||
type ToolResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Result any `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func okResult(v any) ToolResult { return ToolResult{OK: true, Result: v} }
|
||||
func errResult(err error) ToolResult { return ToolResult{OK: false, Error: err.Error()} }
|
||||
func errMsg(msg string) ToolResult { return ToolResult{OK: false, Error: msg} }
|
||||
|
||||
// executeTool dispatches a tool by name with raw JSON input and returns a ToolResult.
|
||||
// Tools that mutate the board return ok=true on success; read-only tools include their data in result.
|
||||
func executeTool(db *DB, name string, input json.RawMessage) ToolResult {
|
||||
switch name {
|
||||
case "list_board":
|
||||
return toolListBoard(db)
|
||||
case "create_column":
|
||||
return toolCreateColumn(db, input)
|
||||
case "update_column":
|
||||
return toolUpdateColumn(db, input)
|
||||
case "rename_column": // alias for backwards compat
|
||||
return toolUpdateColumn(db, input)
|
||||
case "delete_column":
|
||||
return toolDeleteColumn(db, input)
|
||||
case "reorder_columns":
|
||||
return toolReorderColumns(db, input)
|
||||
case "create_card":
|
||||
return toolCreateCard(db, input)
|
||||
case "update_card":
|
||||
return toolUpdateCard(db, input)
|
||||
case "delete_card":
|
||||
return toolDeleteCard(db, input)
|
||||
case "move_card":
|
||||
return toolMoveCard(db, input)
|
||||
case "card_history":
|
||||
return toolCardHistory(db, input)
|
||||
case "find_cards":
|
||||
return toolFindCards(db, input)
|
||||
default:
|
||||
return errMsg("unknown tool: " + name)
|
||||
}
|
||||
}
|
||||
|
||||
// toolMutates reports whether a successful invocation modifies the board state.
|
||||
func toolMutates(name string) bool {
|
||||
switch name {
|
||||
case "create_column", "update_column", "rename_column", "delete_column", "reorder_columns",
|
||||
"create_card", "update_card", "delete_card", "move_card":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func toolListBoard(db *DB) ToolResult {
|
||||
cols, err := db.ListColumns()
|
||||
if err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
cards, err := db.ListCardsWithTime()
|
||||
if err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
return okResult(map[string]any{"columns": cols, "cards": cards})
|
||||
}
|
||||
|
||||
func toolCreateColumn(db *DB, input json.RawMessage) ToolResult {
|
||||
var in struct{ Name string `json:"name"` }
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return errMsg("name required")
|
||||
}
|
||||
c, err := db.CreateColumn(in.Name)
|
||||
if err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
return okResult(c)
|
||||
}
|
||||
|
||||
func toolUpdateColumn(db *DB, input json.RawMessage) ToolResult {
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
Location *string `json:"location"`
|
||||
Width *int `json:"width"`
|
||||
}
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
if in.ID == "" {
|
||||
return errMsg("id required")
|
||||
}
|
||||
if in.Name == nil && in.Location == nil && in.Width == nil {
|
||||
return errMsg("at least one of name/location/width required")
|
||||
}
|
||||
if err := db.UpdateColumn(in.ID, ColumnPatch{Name: in.Name, Location: in.Location, Width: in.Width}); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
return okResult(nil)
|
||||
}
|
||||
|
||||
func toolDeleteColumn(db *DB, input json.RawMessage) ToolResult {
|
||||
var in struct{ ID string }
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
if in.ID == "" {
|
||||
return errMsg("id required")
|
||||
}
|
||||
if err := db.DeleteColumn(in.ID); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
return okResult(nil)
|
||||
}
|
||||
|
||||
func toolReorderColumns(db *DB, input json.RawMessage) ToolResult {
|
||||
var in struct{ IDs []string }
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
if len(in.IDs) == 0 {
|
||||
return errMsg("ids required")
|
||||
}
|
||||
if err := db.ReorderColumns(in.IDs); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
return okResult(nil)
|
||||
}
|
||||
|
||||
func toolCreateCard(db *DB, input json.RawMessage) ToolResult {
|
||||
var in struct {
|
||||
ColumnID string `json:"column_id"`
|
||||
Requester string `json:"requester"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
if in.ColumnID == "" || strings.TrimSpace(in.Title) == "" {
|
||||
return errMsg("column_id and title required")
|
||||
}
|
||||
c, err := db.CreateCard(in.ColumnID, in.Requester, in.Title, in.Description)
|
||||
if err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
return okResult(c)
|
||||
}
|
||||
|
||||
func toolUpdateCard(db *DB, input json.RawMessage) ToolResult {
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
Requester *string `json:"requester"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
if in.ID == "" {
|
||||
return errMsg("id required")
|
||||
}
|
||||
if err := db.UpdateCard(in.ID, CardPatch{Requester: in.Requester, Title: in.Title, Description: in.Description, Color: in.Color}); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
return okResult(nil)
|
||||
}
|
||||
|
||||
func toolDeleteCard(db *DB, input json.RawMessage) ToolResult {
|
||||
var in struct{ ID string }
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
if in.ID == "" {
|
||||
return errMsg("id required")
|
||||
}
|
||||
if err := db.DeleteCard(in.ID); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
return okResult(nil)
|
||||
}
|
||||
|
||||
// toolMoveCard accepts {id, column_id, ordered_ids?}. If ordered_ids is missing,
|
||||
// the card is appended to the end of the destination column.
|
||||
func toolMoveCard(db *DB, input json.RawMessage) ToolResult {
|
||||
var in struct {
|
||||
ID string `json:"id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
OrderedIDs []string `json:"ordered_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
if in.ID == "" || in.ColumnID == "" {
|
||||
return errMsg("id and column_id required")
|
||||
}
|
||||
if len(in.OrderedIDs) == 0 {
|
||||
cards, err := db.ListCardsWithTime()
|
||||
if err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
var dest []Card
|
||||
for _, c := range cards {
|
||||
if c.ColumnID == in.ColumnID && c.ID != in.ID {
|
||||
dest = append(dest, c)
|
||||
}
|
||||
}
|
||||
sort.Slice(dest, func(i, j int) bool { return dest[i].Position < dest[j].Position })
|
||||
ids := make([]string, 0, len(dest)+1)
|
||||
for _, c := range dest {
|
||||
ids = append(ids, c.ID)
|
||||
}
|
||||
ids = append(ids, in.ID)
|
||||
in.OrderedIDs = ids
|
||||
}
|
||||
if err := db.MoveCard(in.ID, in.ColumnID, in.OrderedIDs); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
return okResult(nil)
|
||||
}
|
||||
|
||||
func toolCardHistory(db *DB, input json.RawMessage) ToolResult {
|
||||
var in struct{ ID string }
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
if in.ID == "" {
|
||||
return errMsg("id required")
|
||||
}
|
||||
hist, err := db.CardHistory(in.ID)
|
||||
if err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
return okResult(hist)
|
||||
}
|
||||
|
||||
func toolFindCards(db *DB, input json.RawMessage) ToolResult {
|
||||
var in struct {
|
||||
Query string `json:"query"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Requester string `json:"requester"`
|
||||
}
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
cards, err := db.ListCardsWithTime()
|
||||
if err != nil {
|
||||
return errResult(err)
|
||||
}
|
||||
q := strings.ToLower(strings.TrimSpace(in.Query))
|
||||
col := in.ColumnID
|
||||
req := strings.ToLower(strings.TrimSpace(in.Requester))
|
||||
out := make([]Card, 0, len(cards))
|
||||
for _, c := range cards {
|
||||
if col != "" && c.ColumnID != col {
|
||||
continue
|
||||
}
|
||||
if req != "" && !strings.Contains(strings.ToLower(c.Requester), req) {
|
||||
continue
|
||||
}
|
||||
if q != "" {
|
||||
hay := strings.ToLower(c.Title + " " + c.Description + " " + c.Requester)
|
||||
if !strings.Contains(hay, q) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return okResult(out)
|
||||
}
|
||||
|
||||
// chatActionsRegex matches an <actions>...</actions> block (DOTALL mode).
|
||||
// Used by chat.go to extract tool invocations from the assistant's response.
|
||||
var actionsBlockMarker = struct{ Open, Close string }{Open: "<actions>", Close: "</actions>"}
|
||||
|
||||
func extractActions(text string) (jsonBlock string, stripped string, found bool) {
|
||||
openIdx := strings.Index(text, actionsBlockMarker.Open)
|
||||
if openIdx < 0 {
|
||||
return "", text, false
|
||||
}
|
||||
closeIdx := strings.Index(text[openIdx:], actionsBlockMarker.Close)
|
||||
if closeIdx < 0 {
|
||||
return "", text, false
|
||||
}
|
||||
closeIdx += openIdx
|
||||
jsonBlock = strings.TrimSpace(text[openIdx+len(actionsBlockMarker.Open) : closeIdx])
|
||||
before := strings.TrimRight(text[:openIdx], " \n\t")
|
||||
after := strings.TrimLeft(text[closeIdx+len(actionsBlockMarker.Close):], " \n\t")
|
||||
stripped = strings.TrimSpace(before + "\n" + after)
|
||||
return jsonBlock, stripped, true
|
||||
}
|
||||
|
||||
// validateToolName fails fast with clearer error than the dispatch's default.
|
||||
func validateToolName(name string) error {
|
||||
known := map[string]bool{
|
||||
"list_board": true, "create_column": true, "update_column": true, "rename_column": true,
|
||||
"delete_column": true, "reorder_columns": true, "create_card": true,
|
||||
"update_card": true, "delete_card": true, "move_card": true,
|
||||
"card_history": true, "find_cards": true,
|
||||
}
|
||||
if !known[name] {
|
||||
return fmt.Errorf("unknown tool: %s", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+430
@@ -0,0 +1,430 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setupTestDB creates a temporary kanban DB for the duration of the test.
|
||||
func setupTestDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
dbPath := filepath.Join(dir, "test_operations.db")
|
||||
db, err := openDB(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("openDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) json.RawMessage {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func mustOK(t *testing.T, res ToolResult) {
|
||||
t.Helper()
|
||||
if !res.OK {
|
||||
t.Fatalf("expected ok, got error: %s", res.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func mustErr(t *testing.T, res ToolResult, contains string) {
|
||||
t.Helper()
|
||||
if res.OK {
|
||||
t.Fatalf("expected error, got ok with result: %v", res.Result)
|
||||
}
|
||||
if contains != "" && !strings.Contains(res.Error, contains) {
|
||||
t.Fatalf("error %q does not contain %q", res.Error, contains)
|
||||
}
|
||||
}
|
||||
|
||||
// --- list_board ---
|
||||
|
||||
func TestExecuteTool_ListBoard_Empty(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
res := executeTool(db, "list_board", json.RawMessage(`{}`))
|
||||
mustOK(t, res)
|
||||
board, ok := res.Result.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected map[string]any, got %T", res.Result)
|
||||
}
|
||||
cols := board["columns"].([]Column)
|
||||
cards := board["cards"].([]Card)
|
||||
if len(cols) != 0 || len(cards) != 0 {
|
||||
t.Fatalf("expected empty board, got %d cols %d cards", len(cols), len(cards))
|
||||
}
|
||||
}
|
||||
|
||||
// --- create_column / rename_column / delete_column / reorder_columns ---
|
||||
|
||||
func TestExecuteTool_CreateColumn(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
res := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Backlog"}))
|
||||
mustOK(t, res)
|
||||
col := res.Result.(*Column)
|
||||
if col.Name != "Backlog" || col.Position != 0 || col.ID == "" {
|
||||
t.Fatalf("unexpected column: %+v", col)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTool_CreateColumn_EmptyName(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
res := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": " "}))
|
||||
mustErr(t, res, "name required")
|
||||
}
|
||||
|
||||
func TestExecuteTool_UpdateColumn_Name(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
created := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Old"}))
|
||||
col := created.Result.(*Column)
|
||||
|
||||
res := executeTool(db, "update_column", mustJSON(t, map[string]string{"id": col.ID, "name": "New"}))
|
||||
mustOK(t, res)
|
||||
|
||||
cols, _ := db.ListColumns()
|
||||
if cols[0].Name != "New" {
|
||||
t.Fatalf("rename failed: %s", cols[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTool_UpdateColumn_LocationAndWidth(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column)
|
||||
|
||||
loc := "sidebar"
|
||||
width := 450
|
||||
res := executeTool(db, "update_column", mustJSON(t, map[string]any{"id": col.ID, "location": loc, "width": width}))
|
||||
mustOK(t, res)
|
||||
|
||||
cols, _ := db.ListColumns()
|
||||
if cols[0].Location != "sidebar" || cols[0].Width != 450 {
|
||||
t.Fatalf("update failed: %+v", cols[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTool_UpdateColumn_NoFields(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column)
|
||||
res := executeTool(db, "update_column", mustJSON(t, map[string]string{"id": col.ID}))
|
||||
mustErr(t, res, "at least one")
|
||||
}
|
||||
|
||||
func TestExecuteTool_RenameColumn_AliasStillWorks(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Old"})).Result.(*Column)
|
||||
res := executeTool(db, "rename_column", mustJSON(t, map[string]string{"id": col.ID, "name": "New"}))
|
||||
mustOK(t, res)
|
||||
cols, _ := db.ListColumns()
|
||||
if cols[0].Name != "New" {
|
||||
t.Fatalf("alias rename failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTool_DeleteColumn(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
created := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Tmp"}))
|
||||
col := created.Result.(*Column)
|
||||
|
||||
res := executeTool(db, "delete_column", mustJSON(t, map[string]string{"id": col.ID}))
|
||||
mustOK(t, res)
|
||||
|
||||
cols, _ := db.ListColumns()
|
||||
if len(cols) != 0 {
|
||||
t.Fatalf("expected 0 cols after delete, got %d", len(cols))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTool_ReorderColumns(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
a := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "A"})).Result.(*Column)
|
||||
b := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "B"})).Result.(*Column)
|
||||
c := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "C"})).Result.(*Column)
|
||||
|
||||
res := executeTool(db, "reorder_columns", mustJSON(t, map[string][]string{"ids": {c.ID, a.ID, b.ID}}))
|
||||
mustOK(t, res)
|
||||
|
||||
cols, _ := db.ListColumns()
|
||||
got := []string{cols[0].Name, cols[1].Name, cols[2].Name}
|
||||
want := []string{"C", "A", "B"}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("reorder mismatch at %d: want %s got %s", i, want[i], got[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- create_card / update_card / delete_card / move_card ---
|
||||
|
||||
func TestExecuteTool_CreateCard_AndRequester(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Todo"})).Result.(*Column)
|
||||
|
||||
res := executeTool(db, "create_card", mustJSON(t, map[string]string{
|
||||
"column_id": col.ID,
|
||||
"requester": "Lucas",
|
||||
"title": "Buy milk",
|
||||
"description": "Whole milk",
|
||||
}))
|
||||
mustOK(t, res)
|
||||
card := res.Result.(*Card)
|
||||
if card.Requester != "Lucas" || card.Title != "Buy milk" || card.ColumnID != col.ID {
|
||||
t.Fatalf("unexpected card: %+v", card)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTool_CreateCard_MissingTitle(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column)
|
||||
res := executeTool(db, "create_card", mustJSON(t, map[string]string{
|
||||
"column_id": col.ID,
|
||||
"title": "",
|
||||
}))
|
||||
mustErr(t, res, "required")
|
||||
}
|
||||
|
||||
func TestExecuteTool_UpdateCard(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column)
|
||||
card := executeTool(db, "create_card", mustJSON(t, map[string]string{
|
||||
"column_id": col.ID,
|
||||
"requester": "A",
|
||||
"title": "T1",
|
||||
})).Result.(*Card)
|
||||
|
||||
newTitle := "T2"
|
||||
newReq := "B"
|
||||
color := "violet"
|
||||
res := executeTool(db, "update_card", mustJSON(t, map[string]any{
|
||||
"id": card.ID,
|
||||
"title": newTitle,
|
||||
"requester": newReq,
|
||||
"color": color,
|
||||
}))
|
||||
mustOK(t, res)
|
||||
|
||||
cards, _ := db.ListCardsWithTime()
|
||||
if cards[0].Title != "T2" || cards[0].Requester != "B" || cards[0].Color != "violet" {
|
||||
t.Fatalf("unexpected card after update: %+v", cards[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTool_DeleteCard(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column)
|
||||
card := executeTool(db, "create_card", mustJSON(t, map[string]string{
|
||||
"column_id": col.ID,
|
||||
"title": "T",
|
||||
})).Result.(*Card)
|
||||
|
||||
res := executeTool(db, "delete_card", mustJSON(t, map[string]string{"id": card.ID}))
|
||||
mustOK(t, res)
|
||||
|
||||
cards, _ := db.ListCardsWithTime()
|
||||
if len(cards) != 0 {
|
||||
t.Fatalf("expected 0 cards, got %d", len(cards))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTool_MoveCard_BetweenColumns_OpensHistory(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
src := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Src"})).Result.(*Column)
|
||||
dst := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Dst"})).Result.(*Column)
|
||||
card := executeTool(db, "create_card", mustJSON(t, map[string]string{
|
||||
"column_id": src.ID,
|
||||
"title": "Move me",
|
||||
})).Result.(*Card)
|
||||
|
||||
res := executeTool(db, "move_card", mustJSON(t, map[string]any{
|
||||
"id": card.ID,
|
||||
"column_id": dst.ID,
|
||||
}))
|
||||
mustOK(t, res)
|
||||
|
||||
cards, _ := db.ListCardsWithTime()
|
||||
if cards[0].ColumnID != dst.ID {
|
||||
t.Fatalf("card not moved, still in %s", cards[0].ColumnID)
|
||||
}
|
||||
|
||||
histRes := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID}))
|
||||
mustOK(t, histRes)
|
||||
hist := histRes.Result.([]HistoryEntry)
|
||||
if len(hist) != 2 {
|
||||
t.Fatalf("expected 2 history entries, got %d", len(hist))
|
||||
}
|
||||
if hist[0].ExitedAt == nil {
|
||||
t.Fatalf("first entry should be closed")
|
||||
}
|
||||
if hist[1].ExitedAt != nil {
|
||||
t.Fatalf("second entry should be open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTool_MoveCard_RequiresIDAndColumn(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
res := executeTool(db, "move_card", mustJSON(t, map[string]string{"id": ""}))
|
||||
mustErr(t, res, "required")
|
||||
}
|
||||
|
||||
// --- card_history ---
|
||||
|
||||
func TestExecuteTool_CardHistory_Single(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column)
|
||||
card := executeTool(db, "create_card", mustJSON(t, map[string]string{
|
||||
"column_id": col.ID,
|
||||
"title": "T",
|
||||
})).Result.(*Card)
|
||||
|
||||
res := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID}))
|
||||
mustOK(t, res)
|
||||
hist := res.Result.([]HistoryEntry)
|
||||
if len(hist) != 1 || hist[0].ExitedAt != nil {
|
||||
t.Fatalf("expected 1 open history entry, got %+v", hist)
|
||||
}
|
||||
}
|
||||
|
||||
// --- find_cards ---
|
||||
|
||||
func TestExecuteTool_FindCards_FilterByQueryRequesterColumn(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column)
|
||||
col2 := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Y"})).Result.(*Column)
|
||||
executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col.ID, "requester": "Lucas", "title": "Bug fix"}))
|
||||
executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col.ID, "requester": "Ana", "title": "Feature x"}))
|
||||
executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col2.ID, "requester": "Lucas", "title": "Refactor"}))
|
||||
|
||||
// query
|
||||
r := executeTool(db, "find_cards", mustJSON(t, map[string]string{"query": "fix"}))
|
||||
mustOK(t, r)
|
||||
cards := r.Result.([]Card)
|
||||
if len(cards) != 1 || cards[0].Title != "Bug fix" {
|
||||
t.Fatalf("query filter failed: %+v", cards)
|
||||
}
|
||||
|
||||
// requester
|
||||
r = executeTool(db, "find_cards", mustJSON(t, map[string]string{"requester": "Lucas"}))
|
||||
cards = r.Result.([]Card)
|
||||
if len(cards) != 2 {
|
||||
t.Fatalf("requester filter expected 2 got %d", len(cards))
|
||||
}
|
||||
|
||||
// column
|
||||
r = executeTool(db, "find_cards", mustJSON(t, map[string]string{"column_id": col2.ID}))
|
||||
cards = r.Result.([]Card)
|
||||
if len(cards) != 1 || cards[0].ColumnID != col2.ID {
|
||||
t.Fatalf("column filter failed: %+v", cards)
|
||||
}
|
||||
|
||||
// combined
|
||||
r = executeTool(db, "find_cards", mustJSON(t, map[string]any{"requester": "Lucas", "column_id": col.ID}))
|
||||
cards = r.Result.([]Card)
|
||||
if len(cards) != 1 || cards[0].Title != "Bug fix" {
|
||||
t.Fatalf("combined filter failed: %+v", cards)
|
||||
}
|
||||
}
|
||||
|
||||
// --- unknown tool ---
|
||||
|
||||
func TestExecuteTool_Unknown(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
res := executeTool(db, "no_such_tool", json.RawMessage(`{}`))
|
||||
mustErr(t, res, "unknown tool")
|
||||
}
|
||||
|
||||
// --- extractActions ---
|
||||
|
||||
func TestExtractActions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
stripOK string
|
||||
found bool
|
||||
}{
|
||||
{"with block", "Hola\n<actions>[{\"tool\":\"x\"}]</actions>\nHecho", `[{"tool":"x"}]`, "Hola\nHecho", true},
|
||||
{"only block", "<actions>[]</actions>", `[]`, "", true},
|
||||
{"no block", "Solo texto", "", "Solo texto", false},
|
||||
{"unclosed", "<actions>foo", "", "<actions>foo", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, stripped, found := extractActions(c.in)
|
||||
if found != c.found {
|
||||
t.Fatalf("found = %v want %v", found, c.found)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Fatalf("got %q want %q", got, c.want)
|
||||
}
|
||||
if stripped != c.stripOK {
|
||||
t.Fatalf("stripped = %q want %q", stripped, c.stripOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- chat logger ---
|
||||
|
||||
func TestChatLogger_AppendsJSONLines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "chat.log")
|
||||
logger := newChatLogger(path)
|
||||
|
||||
logger.Log("create_column", json.RawMessage(`{"name":"A"}`), ToolResult{OK: true, Result: &Column{ID: "abc", Name: "A"}})
|
||||
logger.Log("delete_card", json.RawMessage(`{"id":"x"}`), ToolResult{OK: false, Error: "card not found"})
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read log: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("expected 2 log lines, got %d", len(lines))
|
||||
}
|
||||
for i, line := range lines {
|
||||
var entry ChatLogEntry
|
||||
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||
t.Fatalf("line %d not valid JSON: %v\n%s", i, err, line)
|
||||
}
|
||||
if entry.TS == "" {
|
||||
t.Fatalf("line %d missing TS", i)
|
||||
}
|
||||
}
|
||||
|
||||
var first, second ChatLogEntry
|
||||
json.Unmarshal([]byte(lines[0]), &first)
|
||||
json.Unmarshal([]byte(lines[1]), &second)
|
||||
if first.Tool != "create_column" || !first.OK {
|
||||
t.Fatalf("unexpected first entry: %+v", first)
|
||||
}
|
||||
if second.Tool != "delete_card" || second.OK || second.Error != "card not found" {
|
||||
t.Fatalf("unexpected second entry: %+v", second)
|
||||
}
|
||||
}
|
||||
|
||||
// --- toolMutates ---
|
||||
|
||||
func TestToolMutates(t *testing.T) {
|
||||
mutating := []string{"create_column", "update_column", "rename_column", "delete_column", "reorder_columns",
|
||||
"create_card", "update_card", "delete_card", "move_card"}
|
||||
readonly := []string{"list_board", "card_history", "find_cards"}
|
||||
|
||||
for _, n := range mutating {
|
||||
if !toolMutates(n) {
|
||||
t.Errorf("expected %s to mutate", n)
|
||||
}
|
||||
}
|
||||
for _, n := range readonly {
|
||||
if toolMutates(n) {
|
||||
t.Errorf("expected %s to be read-only", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user