Files
DashDescomplicar/api/services/sessions/db.ts
T
ealmeida 810401e4e7 feat(pipeline): F4.1 — rotas GET /patterns e GET /stats (contrato C4-análise) #2415
- routes/sessions.ts: createSessionsAnalysisRouter com GET /patterns (filtros
  week/severity/key/days, paginado, Zod) e GET /stats (agregados: sessões
  totais e por dia, by_source, by_outcome, tool_top, skill_top,
  patterns_by_type, gate_blocks, avg_duration_sec, last_indexed_at,
  latest_pattern_week); montado em /api/sessions (C4) e /api (alias F4.1)
- services/sessions/db.ts: listPatterns/countPatterns/getStats +
  rowToPattern/buildPatternsWhere; gate_blocks devolve null enquanto a
  tabela gate_events não existir (F1 arco C3-gates)
- tests/sessions-analysis-route.test.ts: 15 testes (filtros, paginação,
  validação, gate_blocks condicional, não-colisão com /:id)
- inclui camada session_events pendente de v2.7.3 (db.ts/indexer.ts —
  storeSessionEvents no indexAll; rota /:id lê da BD) já em execução, para
  a árvore commitada compilar coerente

Validado contra BD real (11459 sessões, 160 padrões): curl 200 com JSON
não-vazio em /api/patterns, /api/stats, /api/sessions/patterns?week=2026-W26,
/api/sessions/stats?days=7.
2026-07-03 06:01:22 +01:00

677 lines
26 KiB
TypeScript

import Database from 'better-sqlite3'
import { mkdirSync } from 'fs'
import { dirname } from 'path'
import type { SessionMeta } from '../../types/session.js'
export interface ListFilters {
days?: number
source?: string
project?: string
tool?: string
skill?: string
q?: string
limit?: number
offset?: number
}
export interface PatternRecord {
id?: number
detected_at: string
week_iso: string
pattern_key: string
title: string
description: string
severity: 'info' | 'warning' | 'action'
metric_value: number | null
sample_session_ids: string[]
affected_count: number
consecutive_weeks: number
}
export interface WorklogCommentRecord {
id: number
discussion_id: number
created_at: string
staff_id: number | null
title: string | null
task_ref: string | null
duration_sec: number | null
work_items: string[]
files_modified: string[]
problems: { problema: string; solucao: string }[]
patterns_text: string[]
actions: { tipo: string; descricao: string; prioridade: string | null }[]
raw_html: string
imported_at: string
}
export interface WorklogFilters {
discussion_id?: number
task_ref?: string
sinceIso?: string
limit?: number
offset?: number
}
export interface PatternFilters {
week?: string
severity?: 'info' | 'warning' | 'action'
pattern_key?: string
sinceIso?: string
limit?: number
offset?: number
}
export interface SessionsStats {
days: number
sessions: number
by_day: { day: string; sessions: number }[]
by_source: Record<string, number>
by_outcome: Record<string, number>
tool_top: { tool: string; count: number }[]
skill_top: { skill: string; count: number }[]
patterns_by_type: { pattern_type: string; count: number }[]
gate_blocks: number | null
avg_duration_sec: number | null
last_indexed_at: string | null
latest_pattern_week: string | null
}
export interface SessionsDb {
upsertSession(meta: SessionMeta): void
upsertMany(metas: SessionMeta[]): void
listSessions(filters: ListFilters): SessionMeta[]
countSessions(filters: ListFilters): number
getSession(id: string): SessionMeta | null
deleteByJsonlPath(path: string): void
storeSessionEvents(sessionId: string, events: import('../../types/session.js').SessionEvent[]): void
getSessionEvents(sessionId: string): import('../../types/session.js').SessionEvent[]
upsertPattern(p: PatternRecord): void
getPatternsByWeek(week: string): PatternRecord[]
getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number
listPatterns(filters: PatternFilters): PatternRecord[]
countPatterns(filters: PatternFilters): number
getStats(days: number): SessionsStats
upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean }
hasWorklogComment(id: number): boolean
listWorklogComments(filters: WorklogFilters): WorklogCommentRecord[]
countWorklogComments(filters?: WorklogFilters): number
rawDb(): Database.Database
close(): void
}
const SCHEMA = `
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'claude',
project_path TEXT,
project_slug TEXT,
jsonl_path TEXT,
model TEXT,
started_at TEXT NOT NULL,
ended_at TEXT,
duration_sec INTEGER,
event_count INTEGER NOT NULL,
user_messages INTEGER NOT NULL,
assistant_msgs INTEGER NOT NULL,
tool_calls INTEGER NOT NULL,
input_tokens INTEGER,
output_tokens INTEGER,
estimated_cost REAL,
first_prompt TEXT,
tools_used TEXT NOT NULL DEFAULT '[]',
skills_invoked TEXT NOT NULL DEFAULT '[]',
outcome TEXT NOT NULL,
permission_mode TEXT,
file_size INTEGER NOT NULL DEFAULT 0,
indexed_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_started ON sessions(started_at DESC);
CREATE INDEX IF NOT EXISTS idx_project ON sessions(project_slug, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_source ON sessions(source);
CREATE TABLE IF NOT EXISTS session_events (
session_id TEXT NOT NULL,
event_index INTEGER NOT NULL,
type TEXT NOT NULL,
timestamp TEXT,
text TEXT,
tool_name TEXT,
tool_input TEXT,
tool_result TEXT,
skill_invoked TEXT,
hook_name TEXT,
PRIMARY KEY (session_id, event_index)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS patterns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
detected_at TEXT NOT NULL,
week_iso TEXT NOT NULL,
pattern_key TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
severity TEXT NOT NULL,
metric_value REAL,
sample_session_ids TEXT NOT NULL,
affected_count INTEGER NOT NULL,
consecutive_weeks INTEGER NOT NULL DEFAULT 1,
UNIQUE(week_iso, pattern_key)
);
CREATE INDEX IF NOT EXISTS idx_patterns_week ON patterns(week_iso);
CREATE INDEX IF NOT EXISTS idx_patterns_key ON patterns(pattern_key);
CREATE TABLE IF NOT EXISTS worklog_comments (
id INTEGER PRIMARY KEY,
discussion_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
staff_id INTEGER,
title TEXT,
task_ref TEXT,
duration_sec INTEGER,
work_items TEXT NOT NULL,
files_modified TEXT NOT NULL,
problems_json TEXT NOT NULL,
patterns_text TEXT NOT NULL,
actions_json TEXT NOT NULL,
raw_html TEXT NOT NULL,
imported_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_wc_discussion ON worklog_comments(discussion_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_wc_task ON worklog_comments(task_ref);
`
function rowToMeta(row: Record<string, unknown>): SessionMeta {
return {
session_id: row.session_id as string,
source: (row.source as SessionMeta['source']) ?? 'claude',
project_path: (row.project_path as string | null) ?? null,
project_slug: (row.project_slug as string | null) ?? null,
jsonl_path: (row.jsonl_path as string | null) ?? null,
model: (row.model as string | null) ?? null,
started_at: row.started_at as string,
ended_at: (row.ended_at as string | null) ?? null,
duration_sec: (row.duration_sec as number | null) ?? null,
event_count: row.event_count as number,
user_messages: row.user_messages as number,
assistant_msgs: row.assistant_msgs as number,
tool_calls: row.tool_calls as number,
input_tokens: (row.input_tokens as number | null) ?? null,
output_tokens: (row.output_tokens as number | null) ?? null,
estimated_cost: (row.estimated_cost as number | null) ?? null,
first_prompt: (row.first_prompt as string | null) ?? null,
tools_used: row.tools_used ? JSON.parse(row.tools_used as string) : [],
skills_invoked: row.skills_invoked ? JSON.parse(row.skills_invoked as string) : [],
outcome: row.outcome as SessionMeta['outcome'],
permission_mode: (row.permission_mode as string | null) ?? null,
file_size: row.file_size as number,
indexed_at: row.indexed_at as string,
}
}
function rowToPattern(r: Record<string, unknown>): PatternRecord {
return {
id: r.id as number,
detected_at: r.detected_at as string,
week_iso: r.week_iso as string,
pattern_key: r.pattern_key as string,
title: r.title as string,
description: r.description as string,
severity: r.severity as PatternRecord['severity'],
metric_value: (r.metric_value as number | null) ?? null,
sample_session_ids: JSON.parse(r.sample_session_ids as string),
affected_count: r.affected_count as number,
consecutive_weeks: r.consecutive_weeks as number,
}
}
function buildPatternsWhere(f: PatternFilters): { sql: string; params: Record<string, unknown> } {
const parts: string[] = []
const params: Record<string, unknown> = {}
if (f.week) {
parts.push('week_iso = @week')
params.week = f.week
}
if (f.severity) {
parts.push('severity = @severity')
params.severity = f.severity
}
if (f.pattern_key) {
parts.push('pattern_key = @pattern_key')
params.pattern_key = f.pattern_key
}
if (f.sinceIso) {
parts.push('detected_at >= @since')
params.since = f.sinceIso
}
return {
sql: parts.length ? 'WHERE ' + parts.join(' AND ') : '',
params,
}
}
function buildWhere(f: ListFilters): { sql: string; params: Record<string, unknown> } {
const parts: string[] = []
const params: Record<string, unknown> = {}
if (f.days) {
const cutoff = new Date(Date.now() - f.days * 86400_000).toISOString()
parts.push('started_at >= @cutoff')
params.cutoff = cutoff
}
if (f.source) {
parts.push('source = @source')
params.source = f.source
}
if (f.project) {
parts.push('project_slug = @project')
params.project = f.project
}
if (f.tool) {
parts.push("tools_used LIKE @toolLike")
params.toolLike = `%"${f.tool}"%`
}
if (f.skill) {
parts.push('skills_invoked LIKE @skillLike')
params.skillLike = `%"${f.skill}"%`
}
if (f.q) {
parts.push('first_prompt LIKE @q')
params.q = `%${f.q}%`
}
return {
sql: parts.length ? 'WHERE ' + parts.join(' AND ') : '',
params,
}
}
export function openSessionsDb(dbPath: string): SessionsDb {
mkdirSync(dirname(dbPath), { recursive: true })
const db = new Database(dbPath)
db.pragma('journal_mode = WAL')
db.pragma('synchronous = NORMAL')
db.exec(SCHEMA)
// Migração: adicionar colunas novas se não existirem
const existingCols = db.prepare("PRAGMA table_info(sessions)").all() as { name: string }[]
const colNames = new Set(existingCols.map(c => c.name))
const migrations: [string, string][] = [
['source', "TEXT NOT NULL DEFAULT 'claude'"],
['model', 'TEXT'],
['input_tokens', 'INTEGER'],
['output_tokens', 'INTEGER'],
['estimated_cost', 'REAL'],
]
for (const [col, type] of migrations) {
if (!colNames.has(col)) {
db.exec(`ALTER TABLE sessions ADD COLUMN ${col} ${type}`)
}
}
if (!colNames.has('source')) {
db.exec('CREATE INDEX IF NOT EXISTS idx_source ON sessions(source)')
}
const upsertStmt = db.prepare(`
INSERT INTO sessions (session_id, source, project_path, project_slug, jsonl_path, model,
started_at, ended_at, duration_sec, event_count, user_messages, assistant_msgs, tool_calls,
input_tokens, output_tokens, estimated_cost, first_prompt,
tools_used, skills_invoked, outcome, permission_mode, file_size, indexed_at)
VALUES (@session_id, @source, @project_path, @project_slug, @jsonl_path, @model,
@started_at, @ended_at, @duration_sec, @event_count, @user_messages, @assistant_msgs, @tool_calls,
@input_tokens, @output_tokens, @estimated_cost, @first_prompt,
@tools_used, @skills_invoked, @outcome, @permission_mode, @file_size, @indexed_at)
ON CONFLICT(session_id) DO UPDATE SET
source = excluded.source,
project_path = excluded.project_path,
project_slug = excluded.project_slug,
jsonl_path = excluded.jsonl_path,
model = excluded.model,
started_at = excluded.started_at,
ended_at = excluded.ended_at,
duration_sec = excluded.duration_sec,
event_count = excluded.event_count,
user_messages = excluded.user_messages,
assistant_msgs = excluded.assistant_msgs,
tool_calls = excluded.tool_calls,
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
estimated_cost = excluded.estimated_cost,
first_prompt = excluded.first_prompt,
tools_used = excluded.tools_used,
skills_invoked = excluded.skills_invoked,
outcome = excluded.outcome,
permission_mode = excluded.permission_mode,
file_size = excluded.file_size,
indexed_at = excluded.indexed_at
`)
const upsertManyTxn = db.transaction((metas: SessionMeta[]) => {
for (const meta of metas) {
upsertStmt.run({
...meta,
tools_used: JSON.stringify(meta.tools_used),
skills_invoked: JSON.stringify(meta.skills_invoked),
})
}
})
return {
upsertSession(meta) {
upsertStmt.run({
...meta,
tools_used: JSON.stringify(meta.tools_used),
skills_invoked: JSON.stringify(meta.skills_invoked),
})
},
upsertMany(metas) {
upsertManyTxn(metas)
},
listSessions(filters) {
const { sql, params } = buildWhere(filters)
const limit = filters.limit ?? 50
const offset = filters.offset ?? 0
const rows = db
.prepare(`SELECT * FROM sessions ${sql} ORDER BY started_at DESC LIMIT @limit OFFSET @offset`)
.all({ ...params, limit, offset }) as Record<string, unknown>[]
return rows.map(rowToMeta)
},
countSessions(filters) {
const { sql, params } = buildWhere(filters)
const row = db.prepare(`SELECT COUNT(*) as c FROM sessions ${sql}`).get(params) as { c: number }
return row.c
},
getSession(id) {
const row = db.prepare('SELECT * FROM sessions WHERE session_id = ?').get(id) as Record<string, unknown> | undefined
return row ? rowToMeta(row) : null
},
deleteByJsonlPath(path) {
db.prepare('DELETE FROM sessions WHERE jsonl_path = ?').run(path)
},
upsertPattern(p: PatternRecord) {
db.prepare(`
INSERT INTO patterns (detected_at, week_iso, pattern_key, title, description,
severity, metric_value, sample_session_ids, affected_count, consecutive_weeks)
VALUES (@detected_at, @week_iso, @pattern_key, @title, @description,
@severity, @metric_value, @sample_session_ids, @affected_count, @consecutive_weeks)
ON CONFLICT(week_iso, pattern_key) DO UPDATE SET
detected_at = excluded.detected_at,
title = excluded.title,
description = excluded.description,
severity = excluded.severity,
metric_value = excluded.metric_value,
sample_session_ids = excluded.sample_session_ids,
affected_count = excluded.affected_count,
consecutive_weeks = excluded.consecutive_weeks
`).run({
detected_at: p.detected_at,
week_iso: p.week_iso,
pattern_key: p.pattern_key,
title: p.title,
description: p.description,
severity: p.severity,
metric_value: p.metric_value,
sample_session_ids: JSON.stringify(p.sample_session_ids),
affected_count: p.affected_count,
consecutive_weeks: p.consecutive_weeks,
})
},
storeSessionEvents(sessionId, events) {
const del = db.prepare('DELETE FROM session_events WHERE session_id = ?')
const ins = db.prepare(`
INSERT INTO session_events (session_id, event_index, type, timestamp, text,
tool_name, tool_input, tool_result, skill_invoked, hook_name)
VALUES (@session_id, @index, @type, @timestamp, @text,
@tool_name, @tool_input, @tool_result, @skill_invoked, @hook_name)
`)
db.transaction(() => {
del.run(sessionId)
for (const e of events) {
ins.run({
session_id: sessionId,
index: e.index,
type: e.type,
timestamp: e.timestamp ?? null,
text: e.text,
tool_name: e.tool_name,
tool_input: e.tool_input ? JSON.stringify(e.tool_input) : null,
tool_result: typeof e.tool_result === 'string' ? e.tool_result.slice(0, 50000) : e.tool_result != null ? JSON.stringify(e.tool_result).slice(0, 50000) : null,
skill_invoked: e.skill_invoked,
hook_name: e.hook_name,
})
}
})()
},
getSessionEvents(sessionId) {
const rows = db.prepare('SELECT * FROM session_events WHERE session_id = ? ORDER BY event_index').all(sessionId) as Record<string, unknown>[]
return rows.map((r) => ({
index: r.event_index as number,
type: r.type as import('../../types/session.js').SessionEventType,
timestamp: (r.timestamp as string | null) ?? null,
raw: {}, // not stored
text: (r.text as string | null) ?? null,
tool_name: (r.tool_name as string | null) ?? null,
tool_input: r.tool_input ? JSON.parse(r.tool_input as string) as Record<string, unknown> : null,
tool_result: r.tool_result ?? null,
skill_invoked: (r.skill_invoked as string | null) ?? null,
hook_name: (r.hook_name as string | null) ?? null,
}))
},
getPatternsByWeek(week: string): PatternRecord[] {
const rows = db.prepare('SELECT * FROM patterns WHERE week_iso = ? ORDER BY severity DESC, affected_count DESC').all(week) as Record<string, unknown>[]
return rows.map(rowToPattern)
},
getConsecutiveWeeks(pattern_key: string, uptoWeek: string): number {
// Conta semanas consecutivas até uptoWeek (inclusive) em que pattern_key apareceu
const rows = db.prepare('SELECT DISTINCT week_iso FROM patterns WHERE pattern_key = ? AND week_iso <= ? ORDER BY week_iso DESC').all(pattern_key, uptoWeek) as { week_iso: string }[]
if (rows.length === 0) return 0
let count = 0
let cursor = uptoWeek
for (const row of rows) {
if (row.week_iso === cursor) {
count++
cursor = prevWeekIso(cursor)
} else {
break
}
}
return count
},
listPatterns(filters: PatternFilters): PatternRecord[] {
const { sql, params } = buildPatternsWhere(filters)
const limit = filters.limit ?? 50
const offset = filters.offset ?? 0
const rows = db
.prepare(`SELECT * FROM patterns ${sql} ORDER BY week_iso DESC, severity DESC, affected_count DESC LIMIT @limit OFFSET @offset`)
.all({ ...params, limit, offset }) as Record<string, unknown>[]
return rows.map(rowToPattern)
},
countPatterns(filters: PatternFilters): number {
const { sql, params } = buildPatternsWhere(filters)
const row = db.prepare(`SELECT COUNT(*) as c FROM patterns ${sql}`).get(params) as { c: number }
return row.c
},
getStats(days: number): SessionsStats {
const cutoff = new Date(Date.now() - days * 86400_000).toISOString()
const totals = db.prepare(`
SELECT COUNT(*) as sessions, AVG(duration_sec) as avg_duration
FROM sessions WHERE started_at >= ?
`).get(cutoff) as { sessions: number; avg_duration: number | null }
const byDay = db.prepare(`
SELECT substr(started_at, 1, 10) as day, COUNT(*) as sessions
FROM sessions WHERE started_at >= ?
GROUP BY day ORDER BY day
`).all(cutoff) as { day: string; sessions: number }[]
const bySource: Record<string, number> = {}
for (const r of db.prepare('SELECT source, COUNT(*) as c FROM sessions WHERE started_at >= ? GROUP BY source').all(cutoff) as { source: string; c: number }[]) {
bySource[r.source] = r.c
}
const byOutcome: Record<string, number> = {}
for (const r of db.prepare('SELECT outcome, COUNT(*) as c FROM sessions WHERE started_at >= ? GROUP BY outcome').all(cutoff) as { outcome: string; c: number }[]) {
byOutcome[r.outcome] = r.c
}
const toolTop = db.prepare(`
SELECT je.value as tool, COUNT(*) as count
FROM sessions s, json_each(s.tools_used) je
WHERE s.started_at >= ?
GROUP BY je.value ORDER BY count DESC LIMIT 10
`).all(cutoff) as { tool: string; count: number }[]
const skillTop = db.prepare(`
SELECT je.value as skill, COUNT(*) as count
FROM sessions s, json_each(s.skills_invoked) je
WHERE s.started_at >= ?
GROUP BY je.value ORDER BY count DESC LIMIT 10
`).all(cutoff) as { skill: string; count: number }[]
// Tipo do padrão = prefixo de pattern_key antes de ':' (ex.: 'skill_error_rate:X' → 'skill_error_rate')
const patternsByType = db.prepare(`
SELECT CASE WHEN instr(pattern_key, ':') > 0
THEN substr(pattern_key, 1, instr(pattern_key, ':') - 1)
ELSE pattern_key END as pattern_type,
COUNT(*) as count
FROM patterns WHERE detected_at >= ?
GROUP BY pattern_type ORDER BY count DESC
`).all(cutoff) as { pattern_type: string; count: number }[]
// gate_events só existe quando F1 (arco C3-gates) entregar — null até lá
let gateBlocks: number | null = null
const hasGateEvents = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='gate_events'").get()
if (hasGateEvents) {
const r = db.prepare("SELECT COUNT(*) as c FROM gate_events WHERE decision = 'block' AND ts >= ?").get(cutoff) as { c: number }
gateBlocks = r.c
}
const lastIndexed = db.prepare('SELECT MAX(indexed_at) as m FROM sessions').get() as { m: string | null }
const latestWeek = db.prepare('SELECT MAX(week_iso) as m FROM patterns').get() as { m: string | null }
return {
days,
sessions: totals.sessions,
by_day: byDay,
by_source: bySource,
by_outcome: byOutcome,
tool_top: toolTop,
skill_top: skillTop,
patterns_by_type: patternsByType,
gate_blocks: gateBlocks,
avg_duration_sec: totals.avg_duration != null ? Math.round(totals.avg_duration) : null,
last_indexed_at: lastIndexed.m,
latest_pattern_week: latestWeek.m,
}
},
upsertWorklogComment(c: WorklogCommentRecord): { inserted: boolean } {
const existing = db.prepare('SELECT 1 FROM worklog_comments WHERE id = ?').get(c.id)
const inserted = !existing
db.prepare(`
INSERT INTO worklog_comments (id, discussion_id, created_at, staff_id, title, task_ref,
duration_sec, work_items, files_modified, problems_json, patterns_text, actions_json,
raw_html, imported_at)
VALUES (@id, @discussion_id, @created_at, @staff_id, @title, @task_ref,
@duration_sec, @work_items, @files_modified, @problems_json, @patterns_text, @actions_json,
@raw_html, @imported_at)
ON CONFLICT(id) DO UPDATE SET
discussion_id = excluded.discussion_id,
created_at = excluded.created_at,
staff_id = excluded.staff_id,
title = excluded.title,
task_ref = excluded.task_ref,
duration_sec = excluded.duration_sec,
work_items = excluded.work_items,
files_modified = excluded.files_modified,
problems_json = excluded.problems_json,
patterns_text = excluded.patterns_text,
actions_json = excluded.actions_json,
raw_html = excluded.raw_html,
imported_at = excluded.imported_at
`).run({
id: c.id,
discussion_id: c.discussion_id,
created_at: c.created_at,
staff_id: c.staff_id,
title: c.title,
task_ref: c.task_ref,
duration_sec: c.duration_sec,
work_items: JSON.stringify(c.work_items),
files_modified: JSON.stringify(c.files_modified),
problems_json: JSON.stringify(c.problems),
patterns_text: JSON.stringify(c.patterns_text),
actions_json: JSON.stringify(c.actions),
raw_html: c.raw_html,
imported_at: c.imported_at,
})
return { inserted }
},
hasWorklogComment(id: number): boolean {
return !!db.prepare('SELECT 1 FROM worklog_comments WHERE id = ?').get(id)
},
listWorklogComments(filters: WorklogFilters): WorklogCommentRecord[] {
const parts: string[] = []
const params: Record<string, unknown> = {}
if (filters.discussion_id) { parts.push('discussion_id = @discussion_id'); params.discussion_id = filters.discussion_id }
if (filters.task_ref) { parts.push('task_ref = @task_ref'); params.task_ref = filters.task_ref }
if (filters.sinceIso) { parts.push('created_at >= @since'); params.since = filters.sinceIso }
const where = parts.length ? 'WHERE ' + parts.join(' AND ') : ''
const limit = filters.limit ?? 1000
const offset = filters.offset ?? 0
const rows = db.prepare(`SELECT * FROM worklog_comments ${where} ORDER BY created_at DESC LIMIT @limit OFFSET @offset`)
.all({ ...params, limit, offset }) as Record<string, unknown>[]
return rows.map((r) => ({
id: r.id as number,
discussion_id: r.discussion_id as number,
created_at: r.created_at as string,
staff_id: (r.staff_id as number | null) ?? null,
title: (r.title as string | null) ?? null,
task_ref: (r.task_ref as string | null) ?? null,
duration_sec: (r.duration_sec as number | null) ?? null,
work_items: JSON.parse(r.work_items as string),
files_modified: JSON.parse(r.files_modified as string),
problems: JSON.parse(r.problems_json as string),
patterns_text: JSON.parse(r.patterns_text as string),
actions: JSON.parse(r.actions_json as string),
raw_html: r.raw_html as string,
imported_at: r.imported_at as string,
}))
},
countWorklogComments(filters?: WorklogFilters): number {
const parts: string[] = []
const params: Record<string, unknown> = {}
if (filters?.discussion_id) { parts.push('discussion_id = @discussion_id'); params.discussion_id = filters.discussion_id }
if (filters?.task_ref) { parts.push('task_ref = @task_ref'); params.task_ref = filters.task_ref }
if (filters?.sinceIso) { parts.push('created_at >= @since'); params.since = filters.sinceIso }
const where = parts.length ? 'WHERE ' + parts.join(' AND ') : ''
const row = db.prepare(`SELECT COUNT(*) as c FROM worklog_comments ${where}`).get(params) as { c: number }
return row.c
},
rawDb(): Database.Database {
return db
},
close() {
db.close()
},
}
}
/** Calcula semana ISO anterior (YYYY-Www). */
export function prevWeekIso(week: string): string {
const m = week.match(/^(\d{4})-W(\d{2})$/)
if (!m) return week
const year = parseInt(m[1], 10)
const w = parseInt(m[2], 10)
if (w > 1) return `${year}-W${String(w - 1).padStart(2, '0')}`
// Semana 1 → última semana do ano anterior (52 ou 53)
const prevYear = year - 1
const last = weeksInYear(prevYear)
return `${prevYear}-W${String(last).padStart(2, '0')}`
}
function weeksInYear(year: number): number {
// ISO: ano tem 53 semanas se 1 Jan é quinta ou (ano bissexto e 1 Jan é quarta)
const jan1 = new Date(Date.UTC(year, 0, 1)).getUTCDay()
const isLeap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
if (jan1 === 4 || (isLeap && jan1 === 3)) return 53
return 52
}