Files
kanban/backend/daily_summary.go
egutierrez 9c5e76e03f feat(kanban): bocadillo agente + PDF descargable en reporte diario (issue 0094)
Anade tres capas sobre el reporte diario del issue 0093:

1) Bocadillo del agente: cuadro azul encima de "Tareas hechas" con un
   resumen en lenguaje natural (max 4 frases) generado por claude -p
   sobre el JSON del reporte. Botones Regenerar e icono Settings.

2) Settings del prompt: modal con textarea editable para el template
   del agente (key=daily_report_prompt). Compartido por todos los
   usuarios. Boton Restablecer por defecto.

3) PDF descargable: boton que abre ventana nueva con HTML imprimible
   (estilo A4, KPIs filtrados, tabla con enlaces absolutos por card).
   Permite compartir el listado de tareas hechas con los solicitantes.

Backend:
- Migration 013 anade tablas daily_summaries y settings; seed del
  prompt por defecto en castellano.
- daily_summary.go con GetSetting/SetSetting, GetDailySummary/Upsert,
  runClaudePrompt (envuelve claude -p) y GenerateDailySummary que
  orquesta DailyReportFor + plantilla + claude + persist.
- Nuevos endpoints:
  * GET  /api/reports/daily/summary
  * POST /api/reports/daily/summary
  * GET  /api/settings/{key}
  * PUT  /api/settings/{key}

Frontend:
- api.ts: getDailySummary, generateDailySummary, getSetting, setSetting.
- DailyReport.tsx: estado de summary, settingsOpen, promptDraft,
  filterRequester, filterAssignee, filteredDoneCards, exportPDF.
- Bocadillo con IconSparkles + IconRefresh + IconSettings.
- Modal de prompt con Guardar/Cancelar/Reset.
- Filtros Select por solicitante y asignado encima de la tabla.
- exportPDF abre window.open con HTML self-contained que incluye
  enlaces ${origin}/?card=${id} y window.print() automatico.

E2E nuevo (daily-summary-pdf.spec.ts): CRUD del setting, GET summary
shape, presencia del boton PDF/Settings/Regenerar en el modal. No
invoca claude real (binario externo, no disponible en CI).

Suite completa 11/11 pasa.
2026-05-14 18:08:09 +02:00

144 lines
4.0 KiB
Go

package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"os/exec"
"strings"
"time"
)
// DailySummary persisted row.
type DailySummary struct {
Date string `json:"date"`
Summary string `json:"summary"`
Prompt string `json:"prompt"`
Model string `json:"model"`
GeneratedAt string `json:"generated_at"`
GeneratedBy *string `json:"generated_by"`
}
func (db *DB) GetDailySummary(date string) (*DailySummary, error) {
row := db.conn.QueryRow(`SELECT date, summary, prompt, model, generated_at, generated_by FROM daily_summaries WHERE date=?`, date)
var s DailySummary
var by sql.NullString
if err := row.Scan(&s.Date, &s.Summary, &s.Prompt, &s.Model, &s.GeneratedAt, &by); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
if by.Valid {
v := by.String
s.GeneratedBy = &v
}
return &s, nil
}
func (db *DB) UpsertDailySummary(s DailySummary) error {
var by any = nil
if s.GeneratedBy != nil {
by = *s.GeneratedBy
}
_, err := db.conn.Exec(`
INSERT INTO daily_summaries (date, summary, prompt, model, generated_at, generated_by)
VALUES (?,?,?,?,?,?)
ON CONFLICT(date) DO UPDATE SET
summary=excluded.summary,
prompt=excluded.prompt,
model=excluded.model,
generated_at=excluded.generated_at,
generated_by=excluded.generated_by
`, s.Date, s.Summary, s.Prompt, s.Model, s.GeneratedAt, by)
return err
}
func (db *DB) GetSetting(key string) (string, error) {
var v string
err := db.conn.QueryRow(`SELECT value FROM settings WHERE key=?`, key).Scan(&v)
if err == sql.ErrNoRows {
return "", nil
}
return v, err
}
func (db *DB) SetSetting(key, value string, by *string) error {
now := nowRFC3339()
var byArg any = nil
if by != nil {
byArg = *by
}
_, err := db.conn.Exec(`
INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?,?,?,?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at, updated_by=excluded.updated_by
`, key, value, now, byArg)
return err
}
// runClaudePrompt executes `claude -p` with the given user prompt; returns
// stdout trimmed. Times out via claudeTimeout from chat.go.
func runClaudePrompt(ctx context.Context, prompt string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, claudeTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, claudeBinary(), "-p", "--model", claudeModel())
cmd.Stdin = strings.NewReader(prompt)
var out, errb bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("claude -p failed: %v: %s", err, strings.TrimSpace(errb.String()))
}
return strings.TrimSpace(out.String()), nil
}
// BuildDailySummaryPrompt composes the prompt for the LLM by interpolating the
// configurable instruction template with the JSON of the report.
func BuildDailySummaryPrompt(template string, report *DailyReport) (string, error) {
js, err := json.MarshalIndent(report, "", " ")
if err != nil {
return "", err
}
return fmt.Sprintf("%s\n\n<reporte_json>\n%s\n</reporte_json>\n", template, string(js)), nil
}
// GenerateDailySummary builds the prompt, calls Claude, persists and returns
// the resulting summary. actorID is optional (empty = anon/system).
func (db *DB) GenerateDailySummary(ctx context.Context, date, tz, actorID string) (*DailySummary, error) {
rep, err := db.DailyReportFor(date, tz)
if err != nil {
return nil, err
}
tmpl, err := db.GetSetting("daily_report_prompt")
if err != nil {
return nil, err
}
if tmpl == "" {
tmpl = "Resume el reporte diario en 4 frases cortas, en castellano, sin inventar datos."
}
prompt, err := BuildDailySummaryPrompt(tmpl, rep)
if err != nil {
return nil, err
}
summary, err := runClaudePrompt(ctx, prompt)
if err != nil {
return nil, err
}
rec := DailySummary{
Date: date,
Summary: summary,
Prompt: tmpl,
Model: claudeModel(),
GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano),
}
if actorID != "" {
rec.GeneratedBy = &actorID
}
if err := db.UpsertDailySummary(rec); err != nil {
return nil, err
}
return &rec, nil
}