popilot 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.mjs +204 -2
- package/lib/doctor.mjs +38 -1
- package/lib/hydrate.mjs +15 -0
- package/lib/scaffold.mjs +5 -0
- package/lib/setup-wizard.mjs +35 -2
- package/package.json +1 -1
- package/scaffold/.context/project.yaml.example +19 -0
- package/scaffold/mcp-notification-server/package.json +18 -0
- package/scaffold/mcp-notification-server/src/index.ts +275 -0
- package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
- package/scaffold/mcp-notification-server/tsconfig.json +14 -0
- package/scaffold/mcp-pm/package.json +19 -0
- package/scaffold/mcp-pm/src/api-client.ts +69 -0
- package/scaffold/mcp-pm/src/index.ts +660 -0
- package/scaffold/mcp-pm/tsconfig.json +14 -0
- package/scaffold/pm-api/package.json +21 -0
- package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
- package/scaffold/pm-api/sql/002-notifications.sql +18 -0
- package/scaffold/pm-api/sql/003-content.sql +66 -0
- package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
- package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
- package/scaffold/pm-api/sql/schema-core.sql +331 -0
- package/scaffold/pm-api/sql/schema-docs.sql +25 -0
- package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
- package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
- package/scaffold/pm-api/src/auth.ts +28 -0
- package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
- package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
- package/scaffold/pm-api/src/db/adapter.ts +36 -0
- package/scaffold/pm-api/src/db/turso.ts +147 -0
- package/scaffold/pm-api/src/index.ts +114 -0
- package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
- package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
- package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
- package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
- package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
- package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
- package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
- package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
- package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
- package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
- package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
- package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
- package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
- package/scaffold/pm-api/src/mcp.ts +871 -0
- package/scaffold/pm-api/src/nudge.ts +283 -0
- package/scaffold/pm-api/src/routes/auth.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
- package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
- package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
- package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
- package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
- package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
- package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
- package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
- package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
- package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
- package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
- package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
- package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
- package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
- package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
- package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
- package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
- package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
- package/scaffold/pm-api/src/types.ts +11 -0
- package/scaffold/pm-api/src/utils/activity.ts +22 -0
- package/scaffold/pm-api/src/utils/admin.ts +9 -0
- package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
- package/scaffold/pm-api/src/utils/assignee.ts +69 -0
- package/scaffold/pm-api/src/utils/db.ts +45 -0
- package/scaffold/pm-api/src/utils/initiative.ts +23 -0
- package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
- package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
- package/scaffold/pm-api/tsconfig.json +15 -0
- package/scaffold/pm-api/wrangler.toml.hbs +11 -0
- package/scaffold/spec-site/package-lock.json +892 -0
- package/scaffold/spec-site/package.json +15 -1
- package/scaffold/spec-site/src/api/types.ts +6 -0
- package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
- package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
- package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
- package/scaffold/spec-site/src/components/DocComments.vue +137 -0
- package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
- package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
- package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
- package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
- package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
- package/scaffold/spec-site/src/components/Icon.vue +58 -0
- package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
- package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
- package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
- package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
- package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
- package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
- package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
- package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
- package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
- package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
- package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
- package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
- package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
- package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
- package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
- package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
- package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
- package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
- package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
- package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
- package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
- package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
- package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
- package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
- package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
- package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
- package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
- package/scaffold/spec-site/src/composables/useUser.ts +19 -1
- package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
- package/scaffold/spec-site/src/features.ts +108 -0
- package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
- package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
- package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
- package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
- package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
- package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
- package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
- package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
- package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
- package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
- package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
- package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
- package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
- package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
- package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
- package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
- package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
- package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
- package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
- package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
- package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
- package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
- package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
- package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
- package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
- package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
- package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
- package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
- package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
- package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
- package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
- package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
- package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
- package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
- package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
- package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
- package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
- package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
- package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
- package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
- package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
- package/scaffold/spec-site/src/router.ts +141 -0
- package/scaffold/spec-site/src/styles/buttons.css +124 -0
- package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
- package/scaffold/spec-site/src/utils/timezone.ts +18 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import type { AppEnv } from '../types.js'
|
|
3
|
+
import { queryOrThrow, executeOrThrow } from '../utils/db.js'
|
|
4
|
+
|
|
5
|
+
const app = new Hono<AppEnv>()
|
|
6
|
+
|
|
7
|
+
// GET / - document list
|
|
8
|
+
app.get('/', async (c) => {
|
|
9
|
+
const { rows } = await queryOrThrow('SELECT id, title, SUBSTR(content, 1, 100) as summary, created_by, updated_at FROM docs ORDER BY title')
|
|
10
|
+
return c.json({ docs: rows })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
// GET /:id - document detail
|
|
14
|
+
app.get('/:id', async (c) => {
|
|
15
|
+
const id = c.req.param('id')
|
|
16
|
+
const { rows } = await queryOrThrow('SELECT * FROM docs WHERE id = ?', [id])
|
|
17
|
+
if (!rows.length) return c.json({ error: 'Document not found' }, 404)
|
|
18
|
+
return c.json({ doc: rows[0] })
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// PUT /:id - create/update document
|
|
22
|
+
app.put('/:id', async (c) => {
|
|
23
|
+
const id = c.req.param('id')
|
|
24
|
+
const body = await c.req.json<{ title: string; content: string }>()
|
|
25
|
+
const createdBy = c.get('userName') || 'unknown'
|
|
26
|
+
await executeOrThrow(
|
|
27
|
+
`INSERT INTO docs (id, title, content, created_by) VALUES (?, ?, ?, ?)
|
|
28
|
+
ON CONFLICT(id) DO UPDATE SET title = excluded.title, content = excluded.content, updated_at = CURRENT_TIMESTAMP`,
|
|
29
|
+
[id, body.title, body.content, createdBy],
|
|
30
|
+
)
|
|
31
|
+
return c.json({ ok: true })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
export default app
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import type { AppEnv } from '../types.js'
|
|
3
|
+
import { query, execute } from '../db/adapter.js'
|
|
4
|
+
import { queryOrThrow, executeOrThrow } from '../utils/db.js'
|
|
5
|
+
import { canTransition, validateCreate, type InitiativeStatus } from '../utils/initiative.js'
|
|
6
|
+
|
|
7
|
+
const app = new Hono<AppEnv>()
|
|
8
|
+
|
|
9
|
+
// GET / — initiative list
|
|
10
|
+
app.get('/', async (c) => {
|
|
11
|
+
const status = c.req.query('status')
|
|
12
|
+
const limit = Number(c.req.query('limit') ?? '50')
|
|
13
|
+
|
|
14
|
+
let sql = 'SELECT * FROM initiatives'
|
|
15
|
+
const args: (string | number)[] = []
|
|
16
|
+
if (status) {
|
|
17
|
+
sql += ' WHERE status = ?'
|
|
18
|
+
args.push(status)
|
|
19
|
+
}
|
|
20
|
+
sql += ' ORDER BY created_at DESC LIMIT ?'
|
|
21
|
+
args.push(limit)
|
|
22
|
+
|
|
23
|
+
const { rows } = await queryOrThrow(sql, args)
|
|
24
|
+
return c.json({ initiatives: rows })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
// GET /:id — detail
|
|
28
|
+
app.get('/:id', async (c) => {
|
|
29
|
+
const id = Number(c.req.param('id'))
|
|
30
|
+
const { rows } = await queryOrThrow('SELECT * FROM initiatives WHERE id = ?', [id])
|
|
31
|
+
if (!rows.length) return c.json({ error: 'Initiative not found' }, 404)
|
|
32
|
+
return c.json({ initiative: rows[0] })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// POST / — create
|
|
36
|
+
app.post('/', async (c) => {
|
|
37
|
+
const body = await c.req.json<{
|
|
38
|
+
title: string; content: string; decider?: string
|
|
39
|
+
source_context?: string; sourceContext?: string
|
|
40
|
+
}>()
|
|
41
|
+
const author = c.get('userName')
|
|
42
|
+
const err = validateCreate(body.title, body.content, author)
|
|
43
|
+
if (err) return c.json({ error: err }, 400)
|
|
44
|
+
const sourceContext = body.source_context ?? body.sourceContext ?? null
|
|
45
|
+
|
|
46
|
+
const { rowsAffected } = await executeOrThrow(
|
|
47
|
+
`INSERT INTO initiatives (title, content, author, decider, source_context) VALUES (?, ?, ?, ?, ?)`,
|
|
48
|
+
[body.title, body.content, author, body.decider ?? null, sourceContext],
|
|
49
|
+
)
|
|
50
|
+
const { logActivity } = await import('../utils/activity.js')
|
|
51
|
+
await logActivity(c.get('userName') || 'unknown', 'initiative_created', 'initiative', 'new', body.title || '')
|
|
52
|
+
|
|
53
|
+
return c.json({ ok: true }, 201)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// PATCH /:id/status — status transition
|
|
57
|
+
app.patch('/:id/status', async (c) => {
|
|
58
|
+
const id = Number(c.req.param('id'))
|
|
59
|
+
const body = await c.req.json<{ status: InitiativeStatus; decision_note?: string; decisionNote?: string }>()
|
|
60
|
+
|
|
61
|
+
const current = await query<{ status: string }>('SELECT status FROM initiatives WHERE id = ?', [id])
|
|
62
|
+
if (!current.rows.length) return c.json({ error: 'Initiative not found' }, 404)
|
|
63
|
+
|
|
64
|
+
const from = current.rows[0].status as InitiativeStatus
|
|
65
|
+
if (!canTransition(from, body.status)) {
|
|
66
|
+
return c.json({ error: `Cannot transition ${from} → ${body.status}` }, 400)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const decidedAt = ['approved', 'rejected'].includes(body.status) ? 'CURRENT_TIMESTAMP' : 'NULL'
|
|
70
|
+
await execute(
|
|
71
|
+
`UPDATE initiatives SET status = ?, decision_note = ?, decided_at = ${decidedAt}, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
|
72
|
+
[body.status, body.decision_note ?? body.decisionNote ?? null, id],
|
|
73
|
+
)
|
|
74
|
+
return c.json({ ok: true })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// DELETE /:id
|
|
78
|
+
app.delete('/:id', async (c) => {
|
|
79
|
+
const id = Number(c.req.param('id'))
|
|
80
|
+
const { rowsAffected } = await executeOrThrow('DELETE FROM initiatives WHERE id = ?', [id])
|
|
81
|
+
return c.json({ ok: true })
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// POST /:id/convert-to-epic — initiative -> epic conversion
|
|
85
|
+
app.post('/:id/convert-to-epic', async (c) => {
|
|
86
|
+
const id = Number(c.req.param('id'))
|
|
87
|
+
const initiative = await query<{ id: number; title: string; content: string; status: string }>(
|
|
88
|
+
'SELECT id, title, content, status FROM initiatives WHERE id = ?', [id],
|
|
89
|
+
)
|
|
90
|
+
if (!initiative.rows.length) return c.json({ error: 'Initiative not found' }, 404)
|
|
91
|
+
if (initiative.rows[0].status !== 'approved') return c.json({ error: 'Only approved initiatives can be converted' }, 400)
|
|
92
|
+
|
|
93
|
+
const ini = initiative.rows[0]
|
|
94
|
+
const { rowsAffected } = await executeOrThrow(
|
|
95
|
+
'INSERT INTO pm_epics (title, description, status, owner) VALUES (?, ?, ?, ?)',
|
|
96
|
+
[ini.title, `Created from initiative #${ini.id}.\n\n${ini.content}`, 'active', c.get('userName')],
|
|
97
|
+
)
|
|
98
|
+
return c.json({ ok: true, epicTitle: ini.title })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// POST /:id/convert-to-story — initiative -> story conversion
|
|
102
|
+
app.post('/:id/convert-to-story', async (c) => {
|
|
103
|
+
const id = Number(c.req.param('id'))
|
|
104
|
+
const initiative = await query<{ id: number; title: string; content: string; status: string }>(
|
|
105
|
+
'SELECT id, title, content, status FROM initiatives WHERE id = ?', [id],
|
|
106
|
+
)
|
|
107
|
+
if (!initiative.rows.length) return c.json({ error: 'Initiative not found' }, 404)
|
|
108
|
+
if (initiative.rows[0].status !== 'approved') return c.json({ error: 'Only approved initiatives can be converted' }, 400)
|
|
109
|
+
|
|
110
|
+
const ini = initiative.rows[0]
|
|
111
|
+
const { rowsAffected } = await executeOrThrow(
|
|
112
|
+
'INSERT INTO pm_stories (title, description, status, priority) VALUES (?, ?, ?, ?)',
|
|
113
|
+
[`[Initiative] ${ini.title}`, `Created from initiative #${ini.id}.\n\n${ini.content}`, 'backlog', 'medium'],
|
|
114
|
+
)
|
|
115
|
+
return c.json({ ok: true, storyTitle: `[Initiative] ${ini.title}` })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
export default app
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
import type { AppEnv } from '../types.js'
|
|
3
|
+
import { query, execute } from '../db/adapter.js'
|
|
4
|
+
import { queryOrThrow, executeOrThrow } from '../utils/db.js'
|
|
5
|
+
|
|
6
|
+
const app = new Hono<AppEnv>()
|
|
7
|
+
|
|
8
|
+
/** Count working days (excluding Sat/Sun) */
|
|
9
|
+
function countWorkingDays(startDate: string, endDate: string): number {
|
|
10
|
+
const start = new Date(startDate)
|
|
11
|
+
const end = new Date(endDate)
|
|
12
|
+
let count = 0
|
|
13
|
+
const d = new Date(start)
|
|
14
|
+
while (d <= end) {
|
|
15
|
+
const day = d.getDay()
|
|
16
|
+
if (day !== 0 && day !== 6) count++
|
|
17
|
+
d.setDate(d.getDate() + 1)
|
|
18
|
+
}
|
|
19
|
+
return count
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// POST /create — create sprint (planning state)
|
|
23
|
+
app.post('/create', async (c) => {
|
|
24
|
+
const body = await c.req.json<{
|
|
25
|
+
id: string; label: string; theme?: string
|
|
26
|
+
startDate: string; endDate: string
|
|
27
|
+
}>()
|
|
28
|
+
if (!body.id || !body.startDate || !body.endDate) {
|
|
29
|
+
return c.json({ error: 'id, startDate, endDate required' }, 400)
|
|
30
|
+
}
|
|
31
|
+
const { rowsAffected } = await executeOrThrow(
|
|
32
|
+
`INSERT INTO nav_sprints (id, label, theme, start_date, end_date, status, sort_order)
|
|
33
|
+
VALUES (?, ?, ?, ?, ?, 'planning', 0)`,
|
|
34
|
+
[body.id, body.label || body.id.toUpperCase(), body.theme ?? null, body.startDate, body.endDate],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const totalWorkingDays = countWorkingDays(body.startDate, body.endDate)
|
|
38
|
+
return c.json({ ok: true, sprintId: body.id, totalWorkingDays })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// POST /:id/checkin — team member check-in
|
|
42
|
+
app.post('/:id/checkin', async (c) => {
|
|
43
|
+
const sprintId = c.req.param('id')
|
|
44
|
+
const body = await c.req.json<{ memberIds: number[] }>()
|
|
45
|
+
if (!body.memberIds?.length) return c.json({ error: 'memberIds required' }, 400)
|
|
46
|
+
|
|
47
|
+
// Get sprint dates
|
|
48
|
+
const sprint = await query<{ start_date: string; end_date: string; status: string }>(
|
|
49
|
+
'SELECT start_date, end_date, status FROM nav_sprints WHERE id = ?', [sprintId],
|
|
50
|
+
)
|
|
51
|
+
if (!sprint.rows.length) return c.json({ error: 'Sprint not found' }, 404)
|
|
52
|
+
if (sprint.rows[0].status !== 'planning') {
|
|
53
|
+
return c.json({ error: 'Check-in only available in planning state' }, 400)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const totalDays = countWorkingDays(sprint.rows[0].start_date, sprint.rows[0].end_date)
|
|
57
|
+
|
|
58
|
+
// Reset existing check-ins + register new
|
|
59
|
+
await execute('DELETE FROM sprint_members WHERE sprint_id = ?', [sprintId])
|
|
60
|
+
|
|
61
|
+
for (const memberId of body.memberIds) {
|
|
62
|
+
// Get absence count for this member
|
|
63
|
+
const absences = await query<{ cnt: number }>(
|
|
64
|
+
'SELECT COUNT(*) as cnt FROM member_absences WHERE sprint_id = ? AND member_id = ?',
|
|
65
|
+
[sprintId, memberId],
|
|
66
|
+
)
|
|
67
|
+
const absenceCount = absences.rows[0]?.cnt ?? 0
|
|
68
|
+
const workingDays = Math.max(0, totalDays - absenceCount)
|
|
69
|
+
|
|
70
|
+
await execute(
|
|
71
|
+
'INSERT OR REPLACE INTO sprint_members (sprint_id, member_id, working_days) VALUES (?, ?, ?)',
|
|
72
|
+
[sprintId, memberId, workingDays],
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Team total working days = velocity
|
|
77
|
+
const teamResult = await query<{ total: number }>(
|
|
78
|
+
'SELECT SUM(working_days) as total FROM sprint_members WHERE sprint_id = ?', [sprintId],
|
|
79
|
+
)
|
|
80
|
+
const velocity = teamResult.rows[0]?.total ?? 0
|
|
81
|
+
await execute(
|
|
82
|
+
'UPDATE nav_sprints SET velocity = ?, team_size = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
|
83
|
+
[velocity, body.memberIds.length, sprintId],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return c.json({ ok: true, velocity, teamSize: body.memberIds.length, totalWorkingDays: totalDays })
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// POST /:id/absence — register absence
|
|
90
|
+
app.post('/:id/absence', async (c) => {
|
|
91
|
+
const sprintId = c.req.param('id')
|
|
92
|
+
const body = await c.req.json<{
|
|
93
|
+
memberId: number; dates: string[]; reason?: string
|
|
94
|
+
}>()
|
|
95
|
+
if (!body.memberId || !body.dates?.length) return c.json({ error: 'memberId, dates required' }, 400)
|
|
96
|
+
|
|
97
|
+
for (const date of body.dates) {
|
|
98
|
+
await execute(
|
|
99
|
+
'INSERT OR IGNORE INTO member_absences (sprint_id, member_id, absence_date, reason) VALUES (?, ?, ?, ?)',
|
|
100
|
+
[sprintId, body.memberId, date, body.reason ?? null],
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Recalculate working_days
|
|
105
|
+
const sprint = await query<{ start_date: string; end_date: string }>(
|
|
106
|
+
'SELECT start_date, end_date FROM nav_sprints WHERE id = ?', [sprintId],
|
|
107
|
+
)
|
|
108
|
+
if (sprint.rows.length) {
|
|
109
|
+
const totalDays = countWorkingDays(sprint.rows[0].start_date, sprint.rows[0].end_date)
|
|
110
|
+
const absences = await query<{ cnt: number }>(
|
|
111
|
+
'SELECT COUNT(*) as cnt FROM member_absences WHERE sprint_id = ? AND member_id = ?',
|
|
112
|
+
[sprintId, body.memberId],
|
|
113
|
+
)
|
|
114
|
+
const workingDays = Math.max(0, totalDays - (absences.rows[0]?.cnt ?? 0))
|
|
115
|
+
await execute(
|
|
116
|
+
'UPDATE sprint_members SET working_days = ? WHERE sprint_id = ? AND member_id = ?',
|
|
117
|
+
[workingDays, sprintId, body.memberId],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
// Recalculate velocity
|
|
121
|
+
const teamResult = await query<{ total: number }>(
|
|
122
|
+
'SELECT SUM(working_days) as total FROM sprint_members WHERE sprint_id = ?', [sprintId],
|
|
123
|
+
)
|
|
124
|
+
await execute('UPDATE nav_sprints SET velocity = ? WHERE id = ?', [teamResult.rows[0]?.total ?? 0, sprintId])
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return c.json({ ok: true })
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// DELETE /:id/absence — delete absence
|
|
131
|
+
app.delete('/:id/absence', async (c) => {
|
|
132
|
+
const sprintId = c.req.param('id')
|
|
133
|
+
const body = await c.req.json<{ memberId: number; date: string }>()
|
|
134
|
+
await execute(
|
|
135
|
+
'DELETE FROM member_absences WHERE sprint_id = ? AND member_id = ? AND absence_date = ?',
|
|
136
|
+
[sprintId, body.memberId, body.date],
|
|
137
|
+
)
|
|
138
|
+
return c.json({ ok: true })
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// GET /:id/plan — kickoff plan status (checked-in members + absences + velocity)
|
|
142
|
+
app.get('/:id/plan', async (c) => {
|
|
143
|
+
const sprintId = c.req.param('id')
|
|
144
|
+
|
|
145
|
+
const [sprint, members, absences, stories] = await Promise.all([
|
|
146
|
+
query<{ id: string; status: string; start_date: string; end_date: string; velocity: number | null; team_size: number | null }>(
|
|
147
|
+
'SELECT * FROM nav_sprints WHERE id = ?', [sprintId]),
|
|
148
|
+
query<{ member_id: number; working_days: number; display_name: string }>(
|
|
149
|
+
`SELECT sm.member_id, sm.working_days, m.display_name
|
|
150
|
+
FROM sprint_members sm JOIN members m ON sm.member_id = m.id
|
|
151
|
+
WHERE sm.sprint_id = ?`, [sprintId]),
|
|
152
|
+
query<{ member_id: number; absence_date: string; reason: string | null }>(
|
|
153
|
+
'SELECT member_id, absence_date, reason FROM member_absences WHERE sprint_id = ?', [sprintId]),
|
|
154
|
+
query<{ id: number; title: string; story_points: number | null; assignee: string | null }>(
|
|
155
|
+
'SELECT id, title, story_points, assignee FROM pm_stories WHERE sprint = ?', [sprintId]),
|
|
156
|
+
])
|
|
157
|
+
|
|
158
|
+
if (!sprint.rows.length) return c.json({ error: 'Sprint not found' }, 404)
|
|
159
|
+
const s = sprint.rows[0]
|
|
160
|
+
const totalWorkingDays = countWorkingDays(s.start_date, s.end_date)
|
|
161
|
+
const totalSP = (stories.rows as Array<{ story_points: number | null }>).reduce((sum, st) => sum + (st.story_points ?? 0), 0)
|
|
162
|
+
|
|
163
|
+
return c.json({
|
|
164
|
+
sprint: s,
|
|
165
|
+
totalWorkingDays,
|
|
166
|
+
members: members.rows,
|
|
167
|
+
absences: absences.rows,
|
|
168
|
+
stories: stories.rows,
|
|
169
|
+
totalSP,
|
|
170
|
+
velocity: s.velocity ?? 0,
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// GET /:id/close-preview — close preview
|
|
175
|
+
app.get('/:id/close-preview', async (c) => {
|
|
176
|
+
const sprintId = c.req.param('id')
|
|
177
|
+
|
|
178
|
+
const sprint = await query<{ id: string; status: string; start_date: string; end_date: string; velocity: number | null }>(
|
|
179
|
+
'SELECT id, status, start_date, end_date, velocity FROM nav_sprints WHERE id = ?', [sprintId],
|
|
180
|
+
)
|
|
181
|
+
if (!sprint.rows.length) return c.json({ error: 'Sprint not found' }, 404)
|
|
182
|
+
|
|
183
|
+
const stories = await query<{ id: number; title: string; sprint: string | null; status: string; story_points: number | null; assignee: string | null }>(
|
|
184
|
+
'SELECT id, title, sprint, status, story_points, assignee FROM pm_stories WHERE sprint = ?', [sprintId],
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
const { generateCloseSummary, aggregateVelocity, getIncompleteStories } = await import('../utils/sprint-lifecycle.js')
|
|
188
|
+
const summary = generateCloseSummary(sprintId, stories.rows)
|
|
189
|
+
const velocity = aggregateVelocity(sprintId, stories.rows)
|
|
190
|
+
const incomplete = getIncompleteStories(stories.rows)
|
|
191
|
+
|
|
192
|
+
return c.json({
|
|
193
|
+
sprint: sprint.rows[0],
|
|
194
|
+
summary,
|
|
195
|
+
velocity,
|
|
196
|
+
incompleteStories: incomplete.map(s => ({ id: s.id, title: s.title, status: s.status, story_points: s.story_points, assignee: s.assignee })),
|
|
197
|
+
})
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
// POST /:id/close — close sprint
|
|
201
|
+
app.post('/:id/close', async (c) => {
|
|
202
|
+
const sprintId = c.req.param('id')
|
|
203
|
+
|
|
204
|
+
// 1. Check sprint status
|
|
205
|
+
const sprint = await query<{ id: string; status: string }>(
|
|
206
|
+
'SELECT id, status FROM nav_sprints WHERE id = ?', [sprintId],
|
|
207
|
+
)
|
|
208
|
+
if (!sprint.rows.length) return c.json({ error: 'Sprint not found' }, 404)
|
|
209
|
+
if (sprint.rows[0].status !== 'active') {
|
|
210
|
+
return c.json({ error: `Close only available in active state (current: ${sprint.rows[0].status})` }, 400)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 2. Get all sprint stories
|
|
214
|
+
const stories = await query<{
|
|
215
|
+
id: number; title: string; status: string; story_points: number | null; assignee: string | null
|
|
216
|
+
}>('SELECT id, title, status, story_points, assignee FROM pm_stories WHERE sprint = ?', [sprintId])
|
|
217
|
+
if (stories.error) return c.json({ error: stories.error }, 500)
|
|
218
|
+
|
|
219
|
+
const allStories = stories.rows
|
|
220
|
+
const completed = allStories.filter(s => s.status === 'done')
|
|
221
|
+
const incomplete = allStories.filter(s => s.status !== 'done')
|
|
222
|
+
|
|
223
|
+
// 3. Move incomplete stories to backlog
|
|
224
|
+
if (incomplete.length > 0) {
|
|
225
|
+
const ids = incomplete.map(s => s.id)
|
|
226
|
+
const placeholders = ids.map(() => '?').join(', ')
|
|
227
|
+
await execute(
|
|
228
|
+
`UPDATE pm_stories SET sprint = NULL, updated_at = CURRENT_TIMESTAMP WHERE id IN (${placeholders})`,
|
|
229
|
+
ids,
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 4. Save velocity history
|
|
234
|
+
const totalDoneSP = completed.reduce((sum, s) => sum + (s.story_points ?? 0), 0)
|
|
235
|
+
const totalSP = allStories.reduce((sum, s) => sum + (s.story_points ?? 0), 0)
|
|
236
|
+
await execute(
|
|
237
|
+
`UPDATE nav_sprints SET status = 'closed', active = 0, velocity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
|
238
|
+
[totalDoneSP, sprintId],
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
// 5. Auto-create retro session
|
|
242
|
+
try {
|
|
243
|
+
await execute(
|
|
244
|
+
`INSERT OR IGNORE INTO retro_sessions (sprint, phase) VALUES (?, 'collect')`,
|
|
245
|
+
[sprintId],
|
|
246
|
+
)
|
|
247
|
+
} catch (_) { /* Ignore if retro table doesn't exist */ }
|
|
248
|
+
|
|
249
|
+
return c.json({
|
|
250
|
+
ok: true,
|
|
251
|
+
summary: {
|
|
252
|
+
sprintId,
|
|
253
|
+
completedCount: completed.length,
|
|
254
|
+
incompleteCount: incomplete.length,
|
|
255
|
+
totalStories: allStories.length,
|
|
256
|
+
doneSP: totalDoneSP,
|
|
257
|
+
totalSP,
|
|
258
|
+
completionRate: totalSP > 0 ? Math.round((totalDoneSP / totalSP) * 100) : 0,
|
|
259
|
+
movedToBacklog: incomplete.map(s => ({ id: s.id, title: s.title })),
|
|
260
|
+
},
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
export { countWorkingDays }
|
|
265
|
+
export default app
|