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.
Files changed (165) hide show
  1. package/bin/cli.mjs +204 -2
  2. package/lib/doctor.mjs +38 -1
  3. package/lib/hydrate.mjs +15 -0
  4. package/lib/scaffold.mjs +5 -0
  5. package/lib/setup-wizard.mjs +35 -2
  6. package/package.json +1 -1
  7. package/scaffold/.context/project.yaml.example +19 -0
  8. package/scaffold/mcp-notification-server/package.json +18 -0
  9. package/scaffold/mcp-notification-server/src/index.ts +275 -0
  10. package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
  11. package/scaffold/mcp-notification-server/tsconfig.json +14 -0
  12. package/scaffold/mcp-pm/package.json +19 -0
  13. package/scaffold/mcp-pm/src/api-client.ts +69 -0
  14. package/scaffold/mcp-pm/src/index.ts +660 -0
  15. package/scaffold/mcp-pm/tsconfig.json +14 -0
  16. package/scaffold/pm-api/package.json +21 -0
  17. package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
  18. package/scaffold/pm-api/sql/002-notifications.sql +18 -0
  19. package/scaffold/pm-api/sql/003-content.sql +66 -0
  20. package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
  21. package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
  22. package/scaffold/pm-api/sql/schema-core.sql +331 -0
  23. package/scaffold/pm-api/sql/schema-docs.sql +25 -0
  24. package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
  25. package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
  26. package/scaffold/pm-api/src/auth.ts +28 -0
  27. package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
  28. package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
  29. package/scaffold/pm-api/src/db/adapter.ts +36 -0
  30. package/scaffold/pm-api/src/db/turso.ts +147 -0
  31. package/scaffold/pm-api/src/index.ts +114 -0
  32. package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
  33. package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
  34. package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
  35. package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
  36. package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
  37. package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
  38. package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
  39. package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
  40. package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
  41. package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
  42. package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
  43. package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
  44. package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
  45. package/scaffold/pm-api/src/mcp.ts +871 -0
  46. package/scaffold/pm-api/src/nudge.ts +283 -0
  47. package/scaffold/pm-api/src/routes/auth.ts +32 -0
  48. package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
  49. package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
  50. package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
  51. package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
  52. package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
  53. package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
  54. package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
  55. package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
  56. package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
  57. package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
  58. package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
  59. package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
  60. package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
  61. package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
  62. package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
  63. package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
  64. package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
  65. package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
  66. package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
  67. package/scaffold/pm-api/src/types.ts +11 -0
  68. package/scaffold/pm-api/src/utils/activity.ts +22 -0
  69. package/scaffold/pm-api/src/utils/admin.ts +9 -0
  70. package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
  71. package/scaffold/pm-api/src/utils/assignee.ts +69 -0
  72. package/scaffold/pm-api/src/utils/db.ts +45 -0
  73. package/scaffold/pm-api/src/utils/initiative.ts +23 -0
  74. package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
  75. package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
  76. package/scaffold/pm-api/tsconfig.json +15 -0
  77. package/scaffold/pm-api/wrangler.toml.hbs +11 -0
  78. package/scaffold/spec-site/package-lock.json +892 -0
  79. package/scaffold/spec-site/package.json +15 -1
  80. package/scaffold/spec-site/src/api/types.ts +6 -0
  81. package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
  82. package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
  83. package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
  84. package/scaffold/spec-site/src/components/DocComments.vue +137 -0
  85. package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
  86. package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
  87. package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
  88. package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
  89. package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
  90. package/scaffold/spec-site/src/components/Icon.vue +58 -0
  91. package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
  92. package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
  93. package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
  94. package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
  95. package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
  96. package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
  97. package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
  98. package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
  99. package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
  100. package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
  101. package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
  102. package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
  103. package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
  104. package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
  105. package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
  106. package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
  107. package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
  108. package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
  109. package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
  110. package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
  111. package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
  112. package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
  113. package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
  114. package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
  115. package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
  116. package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
  117. package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
  118. package/scaffold/spec-site/src/composables/useUser.ts +19 -1
  119. package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
  120. package/scaffold/spec-site/src/features.ts +108 -0
  121. package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
  122. package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
  123. package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
  124. package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
  125. package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
  126. package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
  127. package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
  128. package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
  129. package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
  130. package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
  131. package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
  132. package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
  133. package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
  134. package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
  135. package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
  136. package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
  137. package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
  138. package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
  139. package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
  140. package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
  141. package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
  142. package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
  143. package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
  144. package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
  145. package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
  146. package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
  147. package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
  148. package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
  149. package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
  150. package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
  151. package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
  152. package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
  153. package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
  154. package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
  155. package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
  156. package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
  157. package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
  158. package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
  159. package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
  160. package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
  161. package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
  162. package/scaffold/spec-site/src/router.ts +141 -0
  163. package/scaffold/spec-site/src/styles/buttons.css +124 -0
  164. package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
  165. package/scaffold/spec-site/src/utils/timezone.ts +18 -0
@@ -0,0 +1,521 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import {
5
+ sprints, epics, loaded, loadNavData,
6
+ addSprint, updateSprint, deleteSprint, setActiveSprint,
7
+ addEpic, updateEpic, deleteEpic, carryOverEpic,
8
+ type SprintConfig, type PageConfig,
9
+ } from '@/composables/useNavStore'
10
+
11
+ const router = useRouter()
12
+ const statusMsg = ref('')
13
+ const loading = ref(true)
14
+
15
+ // -- Sprint form --
16
+ const showSprintForm = ref(false)
17
+ const sprintForm = ref({ id: '', label: '', theme: '', startDate: '', endDate: '' })
18
+
19
+ // -- Epic form (per sprint) --
20
+ const epicFormSprint = ref<string | null>(null)
21
+ const epicForm = ref({ epicId: '', label: '', badge: '', category: 'policy', description: '' })
22
+
23
+ // -- Inline edit --
24
+ const editingSprint = ref<string | null>(null)
25
+ const editSprintData = ref({ label: '', theme: '', startDate: '', endDate: '' })
26
+
27
+ const editingEpic = ref<string | null>(null) // "sprint:epicId"
28
+ const editEpicData = ref({ label: '', badge: '', category: 'policy', description: '' })
29
+
30
+ // -- Carryover form --
31
+ const carryoverEpic = ref<string | null>(null) // uid
32
+ const carryoverForm = ref({ targetSprint: '', newEpicId: '', newLabel: '', newBadge: '' })
33
+
34
+ function startCarryover(e: PageConfig) {
35
+ const uid = e.uid ?? `${e.sprint}:${e.id}`
36
+ carryoverEpic.value = uid
37
+ carryoverForm.value = { targetSprint: '', newEpicId: '', newLabel: e.label, newBadge: e.badge ?? '' }
38
+ }
39
+
40
+ async function handleCarryover() {
41
+ if (!carryoverEpic.value || !carryoverForm.value.targetSprint || !carryoverForm.value.newEpicId) return
42
+ const f = carryoverForm.value
43
+ const r = await carryOverEpic(
44
+ carryoverEpic.value,
45
+ f.targetSprint,
46
+ f.newEpicId,
47
+ f.newLabel || undefined,
48
+ f.newBadge || undefined,
49
+ )
50
+ if (r.error) {
51
+ statusMsg.value = `Error: ${r.error}`
52
+ } else {
53
+ statusMsg.value = `Carryover complete -> ${f.targetSprint}`
54
+ carryoverEpic.value = null
55
+ }
56
+ clearStatus()
57
+ }
58
+
59
+ function clearStatus() {
60
+ setTimeout(() => { statusMsg.value = '' }, 3000)
61
+ }
62
+
63
+ function epicsForSprint(sprintId: string): PageConfig[] {
64
+ return epics.value
65
+ .filter(e => e.sprint === sprintId)
66
+ .sort((a, b) => a.sortOrder - b.sortOrder)
67
+ }
68
+
69
+ // -- Sprint CRUD --
70
+
71
+ async function handleAddSprint() {
72
+ const { id, label, theme, startDate, endDate } = sprintForm.value
73
+ if (!id.trim() || !label.trim() || !theme.trim()) return
74
+ const r = await addSprint({
75
+ id: id.trim(),
76
+ label: label.trim(),
77
+ theme: theme.trim(),
78
+ startDate: startDate || null,
79
+ endDate: endDate || null,
80
+ })
81
+ if (r.error) {
82
+ statusMsg.value = `Error: ${r.error}`
83
+ } else {
84
+ statusMsg.value = `${label} added`
85
+ sprintForm.value = { id: '', label: '', theme: '', startDate: '', endDate: '' }
86
+ showSprintForm.value = false
87
+ }
88
+ clearStatus()
89
+ }
90
+
91
+ function startEditSprint(s: SprintConfig) {
92
+ editingSprint.value = s.id
93
+ editSprintData.value = {
94
+ label: s.label,
95
+ theme: s.theme,
96
+ startDate: s.startDate ?? '',
97
+ endDate: s.endDate ?? '',
98
+ }
99
+ }
100
+
101
+ async function saveEditSprint(id: string) {
102
+ const d = editSprintData.value
103
+ const r = await updateSprint(id, {
104
+ label: d.label,
105
+ theme: d.theme,
106
+ startDate: d.startDate || null,
107
+ endDate: d.endDate || null,
108
+ })
109
+ if (r.error) {
110
+ statusMsg.value = `Error: ${r.error}`
111
+ } else {
112
+ statusMsg.value = 'Sprint updated'
113
+ editingSprint.value = null
114
+ }
115
+ clearStatus()
116
+ }
117
+
118
+ async function handleSetActive(id: string) {
119
+ const r = await setActiveSprint(id)
120
+ if (!r.error) statusMsg.value = `${id} activated`
121
+ clearStatus()
122
+ }
123
+
124
+ async function handleDeleteSprint(id: string, label: string) {
125
+ if (!confirm(`Delete sprint "${label}" and all its epics? This cannot be undone.`)) return
126
+ const r = await deleteSprint(id)
127
+ if (r.error) {
128
+ statusMsg.value = `Error: ${r.error}`
129
+ } else {
130
+ statusMsg.value = `${label} deleted`
131
+ }
132
+ clearStatus()
133
+ }
134
+
135
+ // -- Epic CRUD --
136
+
137
+ function showEpicForm(sprintId: string) {
138
+ epicFormSprint.value = sprintId
139
+ epicForm.value = { epicId: '', label: '', badge: '', category: 'policy', description: '' }
140
+ }
141
+
142
+ async function handleAddEpic() {
143
+ if (!epicFormSprint.value) return
144
+ const { epicId, label, badge, category, description } = epicForm.value
145
+ if (!epicId.trim() || !label.trim()) return
146
+ const r = await addEpic(epicFormSprint.value, {
147
+ epicId: epicId.trim(),
148
+ label: label.trim(),
149
+ badge: badge.trim() || null,
150
+ category,
151
+ description: description.trim() || null,
152
+ })
153
+ if (r.error) {
154
+ statusMsg.value = `Error: ${r.error}`
155
+ } else {
156
+ statusMsg.value = `${epicId} added`
157
+ epicFormSprint.value = null
158
+ }
159
+ clearStatus()
160
+ }
161
+
162
+ function startEditEpic(e: PageConfig) {
163
+ editingEpic.value = `${e.sprint}:${e.id}`
164
+ editEpicData.value = {
165
+ label: e.label,
166
+ badge: e.badge ?? '',
167
+ category: e.category,
168
+ description: e.description ?? '',
169
+ }
170
+ }
171
+
172
+ async function saveEditEpic(sprint: string, epicId: string) {
173
+ const d = editEpicData.value
174
+ const r = await updateEpic(sprint, epicId, {
175
+ label: d.label,
176
+ badge: d.badge || null,
177
+ category: d.category,
178
+ description: d.description || null,
179
+ })
180
+ if (r.error) {
181
+ statusMsg.value = `Error: ${r.error}`
182
+ } else {
183
+ statusMsg.value = `${epicId} updated`
184
+ editingEpic.value = null
185
+ }
186
+ clearStatus()
187
+ }
188
+
189
+ async function handleDeleteEpic(sprint: string, epicId: string) {
190
+ if (!confirm(`Delete ${epicId}? This cannot be undone.`)) return
191
+ const r = await deleteEpic(sprint, epicId)
192
+ if (r.error) {
193
+ statusMsg.value = `Error: ${r.error}`
194
+ } else {
195
+ statusMsg.value = `${epicId} deleted`
196
+ }
197
+ clearStatus()
198
+ }
199
+
200
+ onMounted(async () => {
201
+ if (!loaded.value) await loadNavData()
202
+ loading.value = false
203
+ })
204
+ </script>
205
+
206
+ <template>
207
+ <div class="admin">
208
+ <div class="admin-header">
209
+ <h1>Sprint & Epic Management</h1>
210
+ <p class="admin-subtitle">Admin page — accessible only with the URL</p>
211
+ </div>
212
+
213
+ <!-- Status -->
214
+ <Transition name="fade">
215
+ <div v-if="statusMsg" class="admin-status">{{ statusMsg }}</div>
216
+ </Transition>
217
+
218
+ <!-- Top actions -->
219
+ <div class="top-actions">
220
+ <button class="btn btn--primary" @click="showSprintForm = !showSprintForm">
221
+ {{ showSprintForm ? 'Cancel' : '+ New Sprint' }}
222
+ </button>
223
+ <button class="btn" @click="router.push('/admin/board')">Story Management</button>
224
+ <button class="btn" @click="router.push('/admin')">Token Management</button>
225
+ </div>
226
+
227
+ <!-- Sprint add form -->
228
+ <div v-if="showSprintForm" class="admin-card">
229
+ <h2>Add New Sprint</h2>
230
+ <div class="form-grid">
231
+ <input v-model="sprintForm.id" class="input" placeholder="ID (e.g. s55)" />
232
+ <input v-model="sprintForm.label" class="input" placeholder="Label (e.g. S55)" />
233
+ <input v-model="sprintForm.theme" class="input input--wide" placeholder="Theme (e.g. Impact Feature Launch)" />
234
+ <input v-model="sprintForm.startDate" class="input" type="date" placeholder="Start date" />
235
+ <input v-model="sprintForm.endDate" class="input" type="date" placeholder="End date" />
236
+ <button class="btn btn--primary" @click="handleAddSprint" :disabled="!sprintForm.id.trim() || !sprintForm.label.trim()">Add</button>
237
+ </div>
238
+ </div>
239
+
240
+ <!-- Loading -->
241
+ <div v-if="loading" class="admin-loading">Loading...</div>
242
+
243
+ <!-- Sprint cards -->
244
+ <div v-else class="sprint-list">
245
+ <div v-for="s in sprints" :key="s.id" class="sprint-card">
246
+ <!-- Sprint header -->
247
+ <div class="sprint-card-header">
248
+ <template v-if="editingSprint === s.id">
249
+ <div class="edit-row">
250
+ <input v-model="editSprintData.label" class="input input--sm" placeholder="Label" />
251
+ <input v-model="editSprintData.theme" class="input" placeholder="Theme" />
252
+ <input v-model="editSprintData.startDate" class="input input--sm" type="date" />
253
+ <input v-model="editSprintData.endDate" class="input input--sm" type="date" />
254
+ <button class="btn btn--sm btn--primary" @click="saveEditSprint(s.id)">Save</button>
255
+ <button class="btn btn--sm" @click="editingSprint = null">Cancel</button>
256
+ </div>
257
+ </template>
258
+ <template v-else>
259
+ <div class="sprint-info">
260
+ <span class="sprint-label">{{ s.label }}</span>
261
+ <span class="sprint-theme-text">{{ s.theme }}</span>
262
+ <span class="lifecycle-badge" :class="'lc--' + (s.status || (s.active ? 'active' : 'planning'))">
263
+ {{ s.status === 'closed' ? 'Closed' : s.status === 'active' || s.active ? 'Active' : 'Planning' }}
264
+ </span>
265
+ </div>
266
+ <div v-if="s.startDate || s.endDate" class="sprint-dates">
267
+ {{ s.startDate ?? '?' }} ~ {{ s.endDate ?? '?' }}
268
+ </div>
269
+ <div class="sprint-actions">
270
+ <button class="btn btn--sm" @click="startEditSprint(s)">Edit</button>
271
+ <button v-if="!s.active" class="btn btn--sm btn--ok" @click="handleSetActive(s.id)">Activate</button>
272
+ <button v-else class="btn btn--sm" disabled>Currently Active</button>
273
+ <button class="btn btn--sm btn--danger" @click="handleDeleteSprint(s.id, s.label)">Delete</button>
274
+ </div>
275
+ <!-- Lifecycle actions -->
276
+ <div class="sprint-lifecycle">
277
+ <button
278
+ v-if="s.status === 'planning' || (!s.status && !s.active)"
279
+ class="btn btn--sm btn--lifecycle"
280
+ @click="router.push(`/kickoff/${s.id}`)"
281
+ >Kickoff</button>
282
+ <button
283
+ v-if="s.status === 'active' || s.active"
284
+ class="btn btn--sm btn--lifecycle"
285
+ @click="router.push(`/close/${s.id}`)"
286
+ >Close</button>
287
+ <button
288
+ v-if="s.status === 'closed'"
289
+ class="btn btn--sm btn--lifecycle"
290
+ @click="router.push(`/retro/${s.id}`)"
291
+ >Retro</button>
292
+ </div>
293
+ </template>
294
+ </div>
295
+
296
+ <!-- Epics table -->
297
+ <div class="epic-section">
298
+ <table class="epic-table">
299
+ <thead>
300
+ <tr>
301
+ <th>ID</th>
302
+ <th>Label</th>
303
+ <th>Badge</th>
304
+ <th>Category</th>
305
+ <th>Description</th>
306
+ <th>Actions</th>
307
+ </tr>
308
+ </thead>
309
+ <tbody>
310
+ <tr v-for="e in epicsForSprint(s.id)" :key="e.id">
311
+ <template v-if="editingEpic === `${s.id}:${e.id}`">
312
+ <td class="td-id">{{ e.id }}</td>
313
+ <td><input v-model="editEpicData.label" class="input input--sm" /></td>
314
+ <td><input v-model="editEpicData.badge" class="input input--xs" placeholder="-" /></td>
315
+ <td>
316
+ <select v-model="editEpicData.category" class="input input--xs">
317
+ <option value="policy">policy</option>
318
+ <option value="wireframe">wireframe</option>
319
+ </select>
320
+ </td>
321
+ <td><input v-model="editEpicData.description" class="input input--sm" /></td>
322
+ <td class="td-actions">
323
+ <button class="btn btn--sm btn--primary" @click="saveEditEpic(s.id, e.id)">Save</button>
324
+ <button class="btn btn--sm" @click="editingEpic = null">Cancel</button>
325
+ </td>
326
+ </template>
327
+ <template v-else>
328
+ <td class="td-id">{{ e.id }}</td>
329
+ <td class="td-label">{{ e.label }}</td>
330
+ <td><span v-if="e.badge" class="epic-badge">{{ e.badge }}</span></td>
331
+ <td class="td-cat">{{ e.category }}</td>
332
+ <td class="td-desc">{{ e.description }}</td>
333
+ <td class="td-actions">
334
+ <button class="btn btn--sm" @click="startEditEpic(e)">Edit</button>
335
+ <button class="btn btn--sm btn--ok" @click="startCarryover(e)">Carryover</button>
336
+ <button class="btn btn--sm btn--danger" @click="handleDeleteEpic(s.id, e.id)">Delete</button>
337
+ </td>
338
+ </template>
339
+ </tr>
340
+ </tbody>
341
+ </table>
342
+
343
+ <!-- Carryover form -->
344
+ <div v-if="carryoverEpic && epicsForSprint(s.id).some(e => (e.uid ?? `${e.sprint}:${e.id}`) === carryoverEpic)" class="add-epic-form">
345
+ <span class="carryover-label">Carryover:</span>
346
+ <select v-model="carryoverForm.targetSprint" class="input input--sm">
347
+ <option value="" disabled>Target sprint</option>
348
+ <option v-for="sp in sprints.filter(x => x.id !== s.id)" :key="sp.id" :value="sp.id">{{ sp.label }}</option>
349
+ </select>
350
+ <input v-model="carryoverForm.newEpicId" class="input input--xs" placeholder="New ID (E-01)" />
351
+ <input v-model="carryoverForm.newLabel" class="input input--sm" placeholder="Label" />
352
+ <input v-model="carryoverForm.newBadge" class="input input--xs" placeholder="Badge" />
353
+ <button class="btn btn--sm btn--primary" @click="handleCarryover" :disabled="!carryoverForm.targetSprint || !carryoverForm.newEpicId">Move</button>
354
+ <button class="btn btn--sm" @click="carryoverEpic = null">Cancel</button>
355
+ </div>
356
+
357
+ <!-- Add epic form (inline) -->
358
+ <div v-if="epicFormSprint === s.id" class="add-epic-form">
359
+ <input v-model="epicForm.epicId" class="input input--xs" placeholder="ID (E-07)" />
360
+ <input v-model="epicForm.label" class="input input--sm" placeholder="Label" />
361
+ <input v-model="epicForm.badge" class="input input--xs" placeholder="Badge" />
362
+ <select v-model="epicForm.category" class="input input--xs">
363
+ <option value="policy">policy</option>
364
+ <option value="wireframe">wireframe</option>
365
+ </select>
366
+ <input v-model="epicForm.description" class="input" placeholder="Description" />
367
+ <button class="btn btn--sm btn--primary" @click="handleAddEpic" :disabled="!epicForm.epicId.trim() || !epicForm.label.trim()">Add</button>
368
+ <button class="btn btn--sm" @click="epicFormSprint = null">Cancel</button>
369
+ </div>
370
+ <button v-else class="btn btn--sm add-epic-btn" @click="showEpicForm(s.id)">+ Add Epic</button>
371
+ </div>
372
+ </div>
373
+ </div>
374
+ </div>
375
+ </template>
376
+
377
+ <style scoped>
378
+ .admin {
379
+ max-width: 1100px;
380
+ margin: 0 auto;
381
+ padding: 32px 24px;
382
+ font-family: system-ui, -apple-system, sans-serif;
383
+ }
384
+ .admin-header { margin-bottom: 24px; }
385
+ .admin-header h1 { font-size: 24px; font-weight: 700; color: #1e293b; margin-bottom: 4px; }
386
+ .admin-subtitle { font-size: 13px; color: #94a3b8; }
387
+
388
+ .admin-status {
389
+ background: #ecfdf5; border: 1px solid #a7f3d0; color: #065f46;
390
+ padding: 10px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
391
+ margin-bottom: 16px;
392
+ }
393
+
394
+ .top-actions {
395
+ display: flex; gap: 8px; margin-bottom: 20px;
396
+ }
397
+
398
+ .admin-card {
399
+ background: #fff; border: 1px solid #e2e8f0; border-radius: 12px;
400
+ padding: 20px 24px; margin-bottom: 20px;
401
+ }
402
+ .admin-card h2 { font-size: 16px; font-weight: 600; color: #1e293b; margin-bottom: 16px; }
403
+
404
+ .form-grid {
405
+ display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
406
+ }
407
+
408
+ .sprint-list { display: flex; flex-direction: column; gap: 16px; }
409
+
410
+ .sprint-card {
411
+ background: #fff; border: 1px solid #e2e8f0; border-radius: 12px;
412
+ overflow: hidden;
413
+ }
414
+
415
+ .sprint-card-header {
416
+ padding: 16px 20px;
417
+ border-bottom: 1px solid #f1f5f9;
418
+ background: #f8fafc;
419
+ }
420
+
421
+ .sprint-info {
422
+ display: flex; align-items: center; gap: 10px; margin-bottom: 4px;
423
+ }
424
+ .sprint-label { font-size: 16px; font-weight: 700; color: #1e293b; }
425
+ .sprint-theme-text { font-size: 13px; color: #64748b; }
426
+ .sprint-dates { font-size: 12px; color: #94a3b8; margin-bottom: 8px; }
427
+ .sprint-actions { display: flex; gap: 4px; }
428
+
429
+ .edit-row {
430
+ display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
431
+ }
432
+
433
+ .epic-section { padding: 12px 20px 16px; }
434
+
435
+ .epic-table { width: 100%; border-collapse: collapse; font-size: 13px; }
436
+ .epic-table th {
437
+ text-align: left; padding: 6px 8px; font-size: 11px; font-weight: 600;
438
+ color: #64748b; text-transform: uppercase; letter-spacing: 0.5px;
439
+ border-bottom: 2px solid #e2e8f0;
440
+ }
441
+ .epic-table td {
442
+ padding: 8px; border-bottom: 1px solid #f1f5f9; color: #475569;
443
+ vertical-align: middle;
444
+ }
445
+ .epic-table tr:hover td { background: #f8fafc; }
446
+
447
+ .td-id { font-weight: 700; color: #3b82f6; font-size: 12px; white-space: nowrap; }
448
+ .td-label { font-weight: 600; color: #1e293b; }
449
+ .td-cat { font-size: 11px; color: #94a3b8; }
450
+ .td-desc { font-size: 12px; color: #64748b; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
451
+ .td-actions { display: flex; gap: 4px; flex-wrap: nowrap; }
452
+
453
+ .epic-badge {
454
+ display: inline-block; padding: 1px 6px; border-radius: 3px;
455
+ font-size: 10px; font-weight: 600; text-transform: uppercase;
456
+ background: #eff6ff; color: #3b82f6;
457
+ }
458
+
459
+ .add-epic-form {
460
+ display: flex; gap: 6px; flex-wrap: wrap; align-items: center;
461
+ margin-top: 8px; padding-top: 8px; border-top: 1px solid #f1f5f9;
462
+ }
463
+
464
+ .add-epic-btn { margin-top: 8px; }
465
+ .carryover-label { font-size: 12px; font-weight: 600; color: #f59e0b; white-space: nowrap; }
466
+
467
+ /* -- Shared -- */
468
+ .input {
469
+ padding: 6px 10px; border: 1px solid #e2e8f0; border-radius: 6px;
470
+ font-size: 13px; flex: 1; min-width: 80px;
471
+ }
472
+ .input--wide { flex: 2; }
473
+ .input--sm { max-width: 160px; flex: none; }
474
+ .input--xs { max-width: 80px; flex: none; }
475
+ .input:focus { outline: none; border-color: #3b82f6; }
476
+
477
+ select.input { cursor: pointer; }
478
+
479
+ .btn {
480
+ padding: 6px 14px; border: 1px solid #e2e8f0; border-radius: 6px;
481
+ font-size: 13px; font-weight: 500; cursor: pointer; background: #fff; color: #475569;
482
+ white-space: nowrap; transition: all 0.15s;
483
+ }
484
+ .btn:hover { background: #f1f5f9; }
485
+ .btn:disabled { opacity: 0.5; cursor: not-allowed; }
486
+ .btn--primary { background: #1e293b; color: #fff; border-color: #1e293b; }
487
+ .btn--primary:hover { background: #334155; }
488
+ .btn--sm { padding: 4px 10px; font-size: 11px; }
489
+ .btn--ok { color: #22c55e; border-color: #86efac; }
490
+ .btn--ok:hover { background: #f0fdf4; }
491
+ .btn--danger { color: #ef4444; border-color: #fca5a5; }
492
+ .btn--danger:hover { background: #fef2f2; }
493
+
494
+ .badge {
495
+ display: inline-block; padding: 2px 8px; border-radius: 10px;
496
+ font-size: 10px; font-weight: 600; text-transform: uppercase;
497
+ }
498
+ .badge--active { background: #ecfdf5; color: #059669; }
499
+
500
+ .admin-loading {
501
+ padding: 40px; text-align: center; color: #94a3b8; font-size: 14px;
502
+ }
503
+
504
+ .fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
505
+ .fade-enter-from, .fade-leave-to { opacity: 0; }
506
+
507
+ @media (max-width: 767px) {
508
+ .admin { padding: 16px; }
509
+ .form-grid { flex-direction: column; }
510
+ .input--sm, .input--xs { max-width: none; }
511
+ .td-actions { flex-wrap: wrap; }
512
+ .epic-table { display: block; overflow-x: auto; }
513
+ }
514
+ .sprint-lifecycle { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(0,0,0,0.06); }
515
+ .lifecycle-badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 6px; }
516
+ .lc--planning { background: #e0e7ff; color: #4338ca; }
517
+ .lc--active { background: #dcfce7; color: #16a34a; }
518
+ .lc--closed { background: #f3f4f6; color: #6b7280; }
519
+ .btn--lifecycle { background: #eff6ff; color: #2563eb; border: 1px solid #bfdbfe; }
520
+ .btn--lifecycle:hover { background: #dbeafe; }
521
+ </style>
@@ -0,0 +1,159 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, onMounted } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import { apiGet } from '@/composables/useTurso'
5
+ import VelocityChart from '@/components/VelocityChart.vue'
6
+
7
+ const router = useRouter()
8
+
9
+ interface TimelineItem {
10
+ id: string; label: string; theme: string
11
+ status: 'planning' | 'active' | 'closed'
12
+ startDate: string | null; endDate: string | null
13
+ velocity: number | null; teamSize: number | null
14
+ storyCount: number; doneCount: number
15
+ totalSP: number; doneSP: number; completionRate: number
16
+ }
17
+
18
+ const timeline = ref<TimelineItem[]>([])
19
+
20
+ const chartData = computed(() => {
21
+ return timeline.value
22
+ .filter(s => s.status === 'closed' || s.status === 'active')
23
+ .reverse()
24
+ .map(s => ({
25
+ label: s.label,
26
+ planned: s.totalSP,
27
+ actual: s.doneSP,
28
+ }))
29
+ })
30
+
31
+ function statusLabel(s: string) {
32
+ return { planning: 'Planning', active: 'Active', closed: 'Closed' }[s] ?? s
33
+ }
34
+
35
+ function statusClass(s: string) {
36
+ return `status--${s}`
37
+ }
38
+
39
+ function duration(start: string | null, end: string | null): string {
40
+ if (!start || !end) return '-'
41
+ const days = Math.ceil((new Date(end).getTime() - new Date(start).getTime()) / (1000 * 60 * 60 * 24))
42
+ return `${days} days`
43
+ }
44
+
45
+ onMounted(async () => {
46
+ const { data } = await apiGet<{ timeline: TimelineItem[] }>('/api/v2/nav/sprints/timeline')
47
+ if (data?.timeline) {
48
+ // active -> planning -> closed
49
+ const order = { active: 0, planning: 1, closed: 2 } as Record<string, number>
50
+ timeline.value = data.timeline.sort((a: TimelineItem, b: TimelineItem) => (order[a.status] ?? 9) - (order[b.status] ?? 9))
51
+ }
52
+ })
53
+ </script>
54
+
55
+ <template>
56
+ <div class="timeline-page">
57
+ <h1>Sprint Timeline</h1>
58
+
59
+ <!-- Velocity Trend Chart -->
60
+ <div v-if="chartData.length >= 2" class="chart-card">
61
+ <h2>Velocity Trend</h2>
62
+ <VelocityChart :data="chartData" />
63
+ </div>
64
+
65
+ <div class="timeline">
66
+ <div v-for="s in timeline" :key="s.id"
67
+ class="timeline-card" :class="statusClass(s.status)"
68
+ @click="router.push(`/board/${s.id}`)">
69
+
70
+ <div class="card-header">
71
+ <span class="card-label">{{ s.label }}</span>
72
+ <span class="card-status">{{ statusLabel(s.status) }}</span>
73
+ </div>
74
+
75
+ <div class="card-theme">{{ s.theme || '-' }}</div>
76
+
77
+ <div class="card-stats">
78
+ <div class="stat">
79
+ <span class="stat-value">{{ s.doneCount }}/{{ s.storyCount }}</span>
80
+ <span class="stat-label">Stories</span>
81
+ </div>
82
+ <div class="stat">
83
+ <span class="stat-value">{{ s.doneSP }}/{{ s.totalSP }}</span>
84
+ <span class="stat-label">SP</span>
85
+ </div>
86
+ <div class="stat">
87
+ <span class="stat-value">{{ s.completionRate }}%</span>
88
+ <span class="stat-label">Completion</span>
89
+ </div>
90
+ </div>
91
+
92
+ <div class="card-bar">
93
+ <div class="bar-fill" :style="{ width: s.completionRate + '%' }" />
94
+ </div>
95
+
96
+ <div class="card-meta">
97
+ <span v-if="s.startDate">{{ s.startDate }} ~ {{ s.endDate }} ({{ duration(s.startDate, s.endDate) }})</span>
98
+ <span v-if="s.velocity">velocity: {{ s.velocity }}</span>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ </template>
104
+
105
+ <style scoped>
106
+ .timeline-page { max-width: 800px; margin: 0 auto; padding: 24px; background: var(--bg); min-height: 100vh; }
107
+ .timeline-page h1 { font-size: 22px; font-weight: 700; margin-bottom: 20px; }
108
+ .chart-card {
109
+ background: rgba(255,255,255,0.25);
110
+ backdrop-filter: blur(40px) saturate(1.8);
111
+ border: 1px solid rgba(255,255,255,0.45);
112
+ border-radius: 16px; padding: 20px; margin-bottom: 24px;
113
+ box-shadow: 0 2px 12px rgba(0,0,0,0.03), inset 0 1px 0 rgba(255,255,255,0.5);
114
+ }
115
+ .chart-card h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; }
116
+
117
+ .timeline { display: flex; flex-direction: column; gap: 12px; }
118
+
119
+ .timeline-card {
120
+ background: rgba(255,255,255,0.25);
121
+ backdrop-filter: blur(40px) saturate(1.8);
122
+ -webkit-backdrop-filter: blur(40px) saturate(1.8);
123
+ border: 1px solid rgba(255,255,255,0.45);
124
+ border-radius: 16px;
125
+ padding: 20px;
126
+ cursor: pointer;
127
+ transition: all 0.2s;
128
+ box-shadow: 0 2px 12px rgba(0,0,0,0.03), inset 0 1px 0 rgba(255,255,255,0.5);
129
+ }
130
+ .timeline-card:hover { transform: translateY(-2px); box-shadow: 0 4px 16px rgba(0,0,0,0.05); }
131
+
132
+ .timeline-card.status--active { border-left: 4px solid #3B82F6; }
133
+ .timeline-card.status--planning { border-left: 4px solid #F59E0B; }
134
+ .timeline-card.status--closed { border-left: 4px solid #94A3B8; opacity: 0.8; }
135
+
136
+ .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
137
+ .card-label { font-size: 18px; font-weight: 700; }
138
+ .card-status { font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 6px; }
139
+ .status--active .card-status { background: rgba(59,130,246,0.12); color: #2563EB; }
140
+ .status--planning .card-status { background: rgba(245,158,11,0.12); color: #D97706; }
141
+ .status--closed .card-status { background: rgba(148,163,184,0.12); color: #64748B; }
142
+
143
+ .card-theme { font-size: 14px; color: var(--text-secondary); margin-bottom: 12px; }
144
+
145
+ .card-stats { display: flex; gap: 24px; margin-bottom: 8px; }
146
+ .stat { display: flex; flex-direction: column; }
147
+ .stat-value { font-size: 16px; font-weight: 700; }
148
+ .stat-label { font-size: 11px; color: var(--text-muted); }
149
+
150
+ .card-bar { height: 6px; background: rgba(0,0,0,0.06); border-radius: 3px; overflow: hidden; margin-bottom: 8px; }
151
+ .bar-fill { height: 100%; background: #3B82F6; border-radius: 3px; transition: width 0.3s; }
152
+
153
+ .card-meta { font-size: 11px; color: var(--text-muted); display: flex; gap: 12px; }
154
+ @media (max-width: 640px) {
155
+ .timeline-page { padding: 16px; }
156
+ .card-stats { gap: 12px; flex-wrap: wrap; }
157
+ .card-meta { flex-direction: column; }
158
+ }
159
+ </style>