popilot 0.5.0 → 0.7.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 (171) hide show
  1. package/adapters/codex/.codex/commands/_domain.md.hbs +33 -0
  2. package/adapters/codex/.codex/commands/analytics.md.hbs +55 -0
  3. package/adapters/codex/.codex/commands/daily.md.hbs +301 -0
  4. package/adapters/codex/.codex/commands/dev.md.hbs +62 -0
  5. package/adapters/codex/.codex/commands/gtm.md +82 -0
  6. package/adapters/codex/.codex/commands/handoff.md +259 -0
  7. package/adapters/codex/.codex/commands/market.md +120 -0
  8. package/adapters/codex/.codex/commands/metrics.md +123 -0
  9. package/adapters/codex/.codex/commands/oscar-loop.md +436 -0
  10. package/adapters/codex/.codex/commands/party.md +85 -0
  11. package/adapters/codex/.codex/commands/plan.md +43 -0
  12. package/adapters/codex/.codex/commands/research.md +203 -0
  13. package/adapters/codex/.codex/commands/retro.md +68 -0
  14. package/adapters/codex/.codex/commands/save.md +440 -0
  15. package/adapters/codex/.codex/commands/sessions.md +139 -0
  16. package/adapters/codex/.codex/commands/sprint.md +106 -0
  17. package/adapters/codex/.codex/commands/start.md +396 -0
  18. package/adapters/codex/.codex/commands/strategy.md +41 -0
  19. package/adapters/codex/.codex/commands/task.md +220 -0
  20. package/adapters/codex/.codex/commands/tracking.md +116 -0
  21. package/adapters/codex/.codex/commands/validate.md +58 -0
  22. package/adapters/codex/AGENTS.md.hbs +210 -0
  23. package/adapters/codex/manifest.yaml +36 -0
  24. package/adapters/gemini/.gemini/commands/_domain.md.hbs +33 -0
  25. package/adapters/gemini/.gemini/commands/analytics.md.hbs +55 -0
  26. package/adapters/gemini/.gemini/commands/daily.md.hbs +301 -0
  27. package/adapters/gemini/.gemini/commands/dev.md.hbs +62 -0
  28. package/adapters/gemini/.gemini/commands/gtm.md +82 -0
  29. package/adapters/gemini/.gemini/commands/handoff.md +259 -0
  30. package/adapters/gemini/.gemini/commands/market.md +120 -0
  31. package/adapters/gemini/.gemini/commands/metrics.md +123 -0
  32. package/adapters/gemini/.gemini/commands/oscar-loop.md +436 -0
  33. package/adapters/gemini/.gemini/commands/party.md +85 -0
  34. package/adapters/gemini/.gemini/commands/plan.md +43 -0
  35. package/adapters/gemini/.gemini/commands/research.md +203 -0
  36. package/adapters/gemini/.gemini/commands/retro.md +68 -0
  37. package/adapters/gemini/.gemini/commands/save.md +440 -0
  38. package/adapters/gemini/.gemini/commands/sessions.md +139 -0
  39. package/adapters/gemini/.gemini/commands/sprint.md +106 -0
  40. package/adapters/gemini/.gemini/commands/start.md +396 -0
  41. package/adapters/gemini/.gemini/commands/strategy.md +41 -0
  42. package/adapters/gemini/.gemini/commands/task.md +220 -0
  43. package/adapters/gemini/.gemini/commands/tracking.md +116 -0
  44. package/adapters/gemini/.gemini/commands/validate.md +58 -0
  45. package/adapters/gemini/GEMINI.md.hbs +210 -0
  46. package/adapters/gemini/manifest.yaml +36 -0
  47. package/bin/cli.mjs +215 -4
  48. package/lib/doctor.mjs +38 -1
  49. package/lib/hydrate.mjs +15 -0
  50. package/lib/industry-presets.mjs +135 -0
  51. package/lib/scaffold.mjs +5 -0
  52. package/lib/setup-wizard.mjs +71 -2
  53. package/package.json +1 -1
  54. package/scaffold/.context/agents/TEMPLATE.md +14 -0
  55. package/scaffold/.context/agents/analyst.md.hbs +3 -3
  56. package/scaffold/.context/agents/developer.md.hbs +5 -5
  57. package/scaffold/.context/agents/gtm-strategist.md.hbs +3 -3
  58. package/scaffold/.context/agents/handoff-specialist.md.hbs +18 -18
  59. package/scaffold/.context/agents/market-researcher.md.hbs +6 -6
  60. package/scaffold/.context/agents/orchestrator.md.hbs +8 -8
  61. package/scaffold/.context/agents/planner.md.hbs +6 -6
  62. package/scaffold/.context/agents/qa.md.hbs +5 -5
  63. package/scaffold/.context/agents/researcher.md.hbs +33 -6
  64. package/scaffold/.context/agents/strategist.md.hbs +8 -8
  65. package/scaffold/.context/agents/tracking-governor.md.hbs +2 -2
  66. package/scaffold/.context/project.yaml.example +25 -0
  67. package/scaffold/mcp-pm/package.json +19 -0
  68. package/scaffold/mcp-pm/src/api-client.ts +69 -0
  69. package/scaffold/mcp-pm/src/index.ts +660 -0
  70. package/scaffold/mcp-pm/tsconfig.json +14 -0
  71. package/scaffold/pm-api/package.json +21 -0
  72. package/scaffold/pm-api/sql/schema-core.sql +331 -0
  73. package/scaffold/pm-api/sql/schema-docs.sql +25 -0
  74. package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
  75. package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
  76. package/scaffold/pm-api/src/auth.ts +28 -0
  77. package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
  78. package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
  79. package/scaffold/pm-api/src/db/adapter.ts +36 -0
  80. package/scaffold/pm-api/src/db/turso.ts +147 -0
  81. package/scaffold/pm-api/src/index.ts +114 -0
  82. package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
  83. package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
  84. package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
  85. package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
  86. package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
  87. package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
  88. package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
  89. package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
  90. package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
  91. package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
  92. package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
  93. package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
  94. package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
  95. package/scaffold/pm-api/src/mcp.ts +871 -0
  96. package/scaffold/pm-api/src/nudge.ts +283 -0
  97. package/scaffold/pm-api/src/routes/auth.ts +32 -0
  98. package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
  99. package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
  100. package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
  101. package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
  102. package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
  103. package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
  104. package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
  105. package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
  106. package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
  107. package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
  108. package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
  109. package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
  110. package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
  111. package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
  112. package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
  113. package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
  114. package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
  115. package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
  116. package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
  117. package/scaffold/pm-api/src/types.ts +11 -0
  118. package/scaffold/pm-api/src/utils/activity.ts +22 -0
  119. package/scaffold/pm-api/src/utils/admin.ts +9 -0
  120. package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
  121. package/scaffold/pm-api/src/utils/assignee.ts +69 -0
  122. package/scaffold/pm-api/src/utils/db.ts +45 -0
  123. package/scaffold/pm-api/src/utils/initiative.ts +23 -0
  124. package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
  125. package/scaffold/pm-api/tsconfig.json +15 -0
  126. package/scaffold/pm-api/wrangler.toml.hbs +11 -0
  127. package/scaffold/spec-site/package-lock.json +40 -0
  128. package/scaffold/spec-site/package.json +4 -1
  129. package/scaffold/spec-site/src/api/types.ts +6 -0
  130. package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
  131. package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
  132. package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
  133. package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
  134. package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
  135. package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
  136. package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
  137. package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
  138. package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
  139. package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
  140. package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
  141. package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
  142. package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
  143. package/scaffold/spec-site/src/composables/useUser.ts +19 -1
  144. package/scaffold/spec-site/src/features.ts +108 -0
  145. package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
  146. package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
  147. package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
  148. package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
  149. package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
  150. package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
  151. package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
  152. package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
  153. package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
  154. package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
  155. package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
  156. package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
  157. package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
  158. package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
  159. package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
  160. package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
  161. package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
  162. package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
  163. package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
  164. package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
  165. package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
  166. package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
  167. package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
  168. package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
  169. package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
  170. package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
  171. package/scaffold/spec-site/src/router.ts +141 -0
@@ -0,0 +1,884 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import { useRoute, useRouter } from 'vue-router'
4
+ import {
5
+ pmEpics, pmLoaded, loadEpics, loadPmData, stories,
6
+ getStoriesForSprint, getBacklogStories, getEpicById,
7
+ updateStoryStatus, updateStory, moveToSprint, loadBacklog,
8
+ STORY_STATUSES, STORY_STATUS_LABELS,
9
+ type PmStory, type PmEpic, type StoryStatus,
10
+ } from '@/composables/usePmStore'
11
+ import { getActiveSprint } from '@/composables/useNavStore'
12
+ import BoardEpicSection from './BoardEpicSection.vue'
13
+ import BoardStoryCard from './BoardStoryCard.vue'
14
+ import StoryDetailPanel from './StoryDetailPanel.vue'
15
+
16
+ const route = useRoute()
17
+ const router = useRouter()
18
+ const loading = ref(true)
19
+ const loadError = ref('')
20
+ const selectedStory = ref<PmStory | null>(null)
21
+
22
+ const sprint = computed(() => (route.params.sprint as string) || (route.path === '/board/backlog' ? 'backlog' : getActiveSprint().id))
23
+
24
+ // Sync viewMode with URL query
25
+ const viewMode = computed<'epic' | 'kanban' | 'timeline' | 'roadmap'>({
26
+ get: () => {
27
+ const v = route.query.view as string
28
+ if (v === 'kanban') return 'kanban'
29
+ if (v === 'timeline') return 'timeline'
30
+ if (v === 'roadmap') return 'roadmap'
31
+ return 'epic'
32
+ },
33
+ set: (v) => {
34
+ router.replace({ query: v === 'epic' ? {} : { view: v } })
35
+ },
36
+ })
37
+
38
+ // Reload data on sprint change
39
+ watch(() => route.params.sprint, async (newSprint, oldSprint) => {
40
+ if (newSprint && newSprint !== oldSprint) {
41
+ loading.value = true
42
+ loadError.value = ''
43
+ try {
44
+ await refresh()
45
+ } catch (e) {
46
+ loadError.value = 'Failed to load data'
47
+ }
48
+ loading.value = false
49
+ }
50
+ })
51
+
52
+ const isBacklog = computed(() => sprint.value === 'backlog')
53
+
54
+ // Timeline view
55
+ const timelineStories = computed(() =>
56
+ sprintStories.value.filter((s: PmStory) => s.startDate || s.dueDate)
57
+ )
58
+
59
+ const sprintRange = computed(() => {
60
+ const dates = timelineStories.value.flatMap((s: PmStory) => [s.startDate, s.dueDate].filter(Boolean) as string[])
61
+ if (!dates.length) return { start: new Date(), end: new Date() }
62
+ const sorted = dates.sort()
63
+ return { start: new Date(sorted[0]), end: new Date(sorted[sorted.length - 1]) }
64
+ })
65
+
66
+ const tlChartRef = ref<HTMLElement | null>(null)
67
+
68
+ const timelineDates = computed(() => {
69
+ const activeSprint = getActiveSprint()
70
+ const spStart = activeSprint.startDate ? new Date(activeSprint.startDate) : sprintRange.value.start
71
+ const spEnd = activeSprint.endDate ? new Date(activeSprint.endDate) : sprintRange.value.end
72
+ const dates: Array<{ key: string; label: string; isWeekend: boolean; isToday: boolean }> = []
73
+ const d = new Date(spStart)
74
+ d.setDate(d.getDate() - 1)
75
+ const endDate = new Date(spEnd)
76
+ endDate.setDate(endDate.getDate() + 1)
77
+ const todayStr = new Date().toISOString().split('T')[0]
78
+ while (d <= endDate) {
79
+ const key = d.toISOString().split('T')[0]
80
+ const day = d.getDay()
81
+ dates.push({
82
+ key,
83
+ label: `${d.getMonth() + 1}/${d.getDate()}`,
84
+ isWeekend: day === 0 || day === 6,
85
+ isToday: key === todayStr,
86
+ })
87
+ d.setDate(d.getDate() + 1)
88
+ }
89
+ return dates
90
+ })
91
+
92
+ const cellWidth = 40 // px per day
93
+
94
+ function timelineBarStyle(story: PmStory) {
95
+ if (!timelineDates.value.length) return {}
96
+ const firstDate = timelineDates.value[0].key
97
+ const sDate = story.startDate ?? firstDate
98
+ const eDate = story.dueDate ?? sDate
99
+ const startIdx = timelineDates.value.findIndex(d => d.key >= sDate)
100
+ const endIdx = timelineDates.value.findIndex(d => d.key > eDate)
101
+ const left = (startIdx >= 0 ? startIdx : 0) * cellWidth
102
+ const width = Math.max(cellWidth, ((endIdx >= 0 ? endIdx : timelineDates.value.length) - (startIdx >= 0 ? startIdx : 0)) * cellWidth)
103
+ return { left: `${left}px`, width: `${width}px` }
104
+ }
105
+
106
+ const tlGroupBy = ref<'none' | 'assignee' | 'epic'>('none')
107
+
108
+ const tlDisplayStories = computed(() => {
109
+ const items = timelineStories.value
110
+ if (tlGroupBy.value === 'none') return items
111
+
112
+ if (tlGroupBy.value === 'assignee') {
113
+ const groups = new Map<string, PmStory[]>()
114
+ for (const s of items) {
115
+ const key = s.assignee ?? 'Unassigned'
116
+ if (!groups.has(key)) groups.set(key, [])
117
+ groups.get(key)!.push(s)
118
+ }
119
+ return [...groups.values()].flat()
120
+ }
121
+
122
+ if (tlGroupBy.value === 'epic') {
123
+ const groups = new Map<number | null, PmStory[]>()
124
+ for (const s of items) {
125
+ if (!groups.has(s.epicId)) groups.set(s.epicId, [])
126
+ groups.get(s.epicId)!.push(s)
127
+ }
128
+ return [...groups.values()].flat()
129
+ }
130
+
131
+ return items
132
+ })
133
+
134
+ // Roadmap view
135
+ const roadmapStatusFilter = ref('')
136
+ const expandedEpics = ref(new Set<number>())
137
+
138
+ const filteredEpics = computed(() => {
139
+ const epics = pmEpics.value
140
+ if (!roadmapStatusFilter.value) return epics
141
+ return epics.filter(e => e.status === roadmapStatusFilter.value)
142
+ })
143
+
144
+ function toggleEpicExpand(id: number) {
145
+ if (expandedEpics.value.has(id)) expandedEpics.value.delete(id)
146
+ else expandedEpics.value.add(id)
147
+ }
148
+
149
+ function epicStories(epicId: number) {
150
+ return sprintStories.value.filter((s: PmStory) => s.epicId === epicId)
151
+ }
152
+ function epicDoneCount(epicId: number) { return epicStories(epicId).filter((s: PmStory) => s.status === 'done').length }
153
+ function epicTotalCount(epicId: number) { return epicStories(epicId).length }
154
+ function epicDoneSP(epicId: number) { return epicStories(epicId).filter((s: PmStory) => s.status === 'done').reduce((sum: number, s: PmStory) => sum + (s.storyPoints ?? 0), 0) }
155
+ function epicTotalSP(epicId: number) { return epicStories(epicId).reduce((sum: number, s: PmStory) => sum + (s.storyPoints ?? 0), 0) }
156
+ function epicProgress(epicId: number) {
157
+ const total = epicTotalCount(epicId)
158
+ return total ? Math.round((epicDoneCount(epicId) / total) * 100) : 0
159
+ }
160
+
161
+ // Backlog drawer
162
+ const backlogOpen = ref(false)
163
+ const backlogStoryList = computed(() => getBacklogStories())
164
+
165
+ async function assignToCurrentSprint(storyId: number) {
166
+ await updateStory(storyId, { sprint: getActiveSprint().id } as any)
167
+ await refresh()
168
+ await loadBacklog()
169
+ }
170
+
171
+ // Sprint stories grouped by epicId
172
+ const sprintStories = computed(() =>
173
+ isBacklog.value ? getBacklogStories() : getStoriesForSprint(sprint.value),
174
+ )
175
+
176
+ const epicGroups = computed(() => {
177
+ const groups = new Map<number | null, PmStory[]>()
178
+ for (const s of sprintStories.value) {
179
+ const key = s.epicId
180
+ if (!groups.has(key)) groups.set(key, [])
181
+ groups.get(key)!.push(s)
182
+ }
183
+ return groups
184
+ })
185
+
186
+ const sortedEpicKeys = computed(() => {
187
+ const keys = [...epicGroups.value.keys()]
188
+ return keys.sort((a, b) => {
189
+ if (a === null) return 1
190
+ if (b === null) return -1
191
+ return a - b
192
+ })
193
+ })
194
+
195
+ // Kanban: group by status
196
+ const kanbanColumns = computed(() => {
197
+ const cols: { status: StoryStatus; label: string; stories: PmStory[] }[] = []
198
+ for (const status of STORY_STATUSES) {
199
+ cols.push({
200
+ status,
201
+ label: STORY_STATUS_LABELS[status],
202
+ stories: sprintStories.value.filter((s: PmStory) => s.status === status),
203
+ })
204
+ }
205
+ return cols
206
+ })
207
+
208
+ // Summary stats
209
+ const statsSummary = computed(() => {
210
+ const all = sprintStories.value
211
+ return {
212
+ total: all.length,
213
+ done: all.filter((s: PmStory) => s.status === 'done').length,
214
+ inProgress: all.filter((s: PmStory) => s.status === 'in-progress').length,
215
+ review: all.filter((s: PmStory) => s.status === 'review').length,
216
+ }
217
+ })
218
+
219
+ async function refresh() {
220
+ await loadPmData(isBacklog.value ? 'backlog' : sprint.value)
221
+ if (selectedStory.value) {
222
+ const fresh = stories.value.find((s: PmStory) => s.id === selectedStory.value!.id)
223
+ if (fresh) selectedStory.value = fresh
224
+ }
225
+ }
226
+
227
+ async function handleMoveToSprint(story: PmStory) {
228
+ const active = getActiveSprint()
229
+ if (!active) return
230
+ await moveToSprint(story.id, active.id)
231
+ await refresh()
232
+ }
233
+
234
+ async function handleMoveToBacklog(story: PmStory) {
235
+ await moveToSprint(story.id, null)
236
+ await refresh()
237
+ }
238
+
239
+ function openDetail(story: PmStory) {
240
+ selectedStory.value = story
241
+ }
242
+
243
+ function onPanelUpdated() {
244
+ refresh()
245
+ }
246
+
247
+ // Drag and drop
248
+ const dragStoryId = ref<number | null>(null)
249
+ const dragOverCol = ref<string | null>(null)
250
+
251
+ function onDragStart(e: DragEvent, story: PmStory) {
252
+ dragStoryId.value = story.id
253
+ if (e.dataTransfer) {
254
+ e.dataTransfer.effectAllowed = 'move'
255
+ e.dataTransfer.setData('text/plain', String(story.id))
256
+ }
257
+ }
258
+
259
+ function onDragOver(e: DragEvent, status: string) {
260
+ e.preventDefault()
261
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
262
+ dragOverCol.value = status
263
+ }
264
+
265
+ function onDragLeave(status: string) {
266
+ if (dragOverCol.value === status) dragOverCol.value = null
267
+ }
268
+
269
+ async function onDrop(e: DragEvent, targetStatus: StoryStatus) {
270
+ e.preventDefault()
271
+ dragOverCol.value = null
272
+ const storyId = dragStoryId.value
273
+ dragStoryId.value = null
274
+ if (!storyId) return
275
+
276
+ const story = stories.value.find((s: PmStory) => s.id === storyId)
277
+ if (!story || story.status === targetStatus) return
278
+
279
+ // optimistic update
280
+ const prevStatus = story.status
281
+ story.status = targetStatus
282
+
283
+ try {
284
+ await updateStoryStatus(storyId, targetStatus)
285
+ } catch {
286
+ // rollback
287
+ story.status = prevStatus
288
+ alert('Status update failed. Reverted to previous status.')
289
+ }
290
+ }
291
+
292
+ function onDragEnd() {
293
+ dragStoryId.value = null
294
+ dragOverCol.value = null
295
+ }
296
+
297
+ onMounted(async () => {
298
+ await loadEpics()
299
+ await refresh()
300
+ loading.value = false
301
+ })
302
+ </script>
303
+
304
+ <template>
305
+ <div class="board-scroll">
306
+ <div class="board-page" :class="{ 'board-page--kanban': viewMode === 'kanban' }">
307
+ <div class="board-header">
308
+ <div class="board-title-row">
309
+ <h1>{{ isBacklog ? 'Backlog' : sprint.toUpperCase() + ' Board' }}</h1>
310
+ <div class="board-title-actions">
311
+ <div class="view-toggle">
312
+ <button
313
+ class="view-btn"
314
+ :class="{ active: viewMode === 'epic' }"
315
+ @click="viewMode = 'epic'"
316
+ >Epic</button>
317
+ <button
318
+ class="view-btn"
319
+ :class="{ active: viewMode === 'kanban' }"
320
+ @click="viewMode = 'kanban'"
321
+ >Kanban</button>
322
+ <button
323
+ class="view-btn"
324
+ :class="{ active: viewMode === 'timeline' }"
325
+ @click="viewMode = 'timeline'"
326
+ >Timeline</button>
327
+ <button
328
+ class="view-btn"
329
+ :class="{ active: viewMode === 'roadmap' }"
330
+ @click="viewMode = 'roadmap'"
331
+ >Roadmap</button>
332
+ </div>
333
+ <button class="btn btn--sm" @click="router.push('/admin/board')">Admin</button>
334
+ </div>
335
+ </div>
336
+ <div class="board-stats">
337
+ <span class="stat">
338
+ <span class="stat-num">{{ statsSummary.total }}</span> stories
339
+ </span>
340
+ <span class="stat stat--done">
341
+ <span class="stat-num">{{ statsSummary.done }}</span> done
342
+ </span>
343
+ <span class="stat stat--progress">
344
+ <span class="stat-num">{{ statsSummary.inProgress }}</span> in progress
345
+ </span>
346
+ <span class="stat stat--review">
347
+ <span class="stat-num">{{ statsSummary.review }}</span> review
348
+ </span>
349
+ </div>
350
+ <!-- Progress bar -->
351
+ <div v-if="statsSummary.total > 0" class="progress-bar-wrap">
352
+ <div class="progress-bar">
353
+ <div
354
+ class="progress-seg progress-seg--done"
355
+ :style="{ width: (statsSummary.done / statsSummary.total * 100) + '%' }"
356
+ ></div>
357
+ <div
358
+ class="progress-seg progress-seg--review"
359
+ :style="{ width: (statsSummary.review / statsSummary.total * 100) + '%' }"
360
+ ></div>
361
+ <div
362
+ class="progress-seg progress-seg--progress"
363
+ :style="{ width: (statsSummary.inProgress / statsSummary.total * 100) + '%' }"
364
+ ></div>
365
+ </div>
366
+ <span class="progress-pct">{{ Math.round(statsSummary.done / statsSummary.total * 100) }}%</span>
367
+ </div>
368
+ </div>
369
+
370
+ <div v-if="loading" class="loading">Loading...</div>
371
+
372
+ <div v-else-if="loadError" class="error-banner">
373
+ <p>{{ loadError }}</p>
374
+ <button class="btn btn--sm" @click="refresh">Retry</button>
375
+ </div>
376
+
377
+ <div
378
+ v-else-if="sprintStories.length === 0"
379
+ class="empty"
380
+ >
381
+ <h2>{{ isBacklog ? 'Backlog is empty' : 'No stories' }}</h2>
382
+ <p>{{ isBacklog ? 'Add new stories to the backlog' : 'No stories registered for this sprint' }}</p>
383
+ </div>
384
+
385
+ <!-- Epic View -->
386
+ <div v-else-if="viewMode === 'epic'" class="epic-list">
387
+ <BoardEpicSection
388
+ v-for="epicId in sortedEpicKeys"
389
+ :key="epicId ?? 'unassigned'"
390
+ :epic="epicId !== null ? getEpicById(epicId) ?? null : null"
391
+ :stories="epicGroups.get(epicId) ?? []"
392
+ @updated="refresh"
393
+ @select-story="openDetail"
394
+ />
395
+
396
+ <!-- Backlog view hint -->
397
+ <div v-if="isBacklog && sprintStories.length > 0" class="backlog-hint-bar">
398
+ Click a story to open the detail panel and assign it to a sprint.
399
+ </div>
400
+ </div>
401
+
402
+ <!-- Kanban View -->
403
+ <div v-else-if="viewMode === 'kanban'" class="kanban-board">
404
+ <div
405
+ v-for="col in kanbanColumns"
406
+ :key="col.status"
407
+ class="kanban-col"
408
+ :class="{ 'kanban-col--dragover': dragOverCol === col.status }"
409
+ @dragover="onDragOver($event, col.status)"
410
+ @dragleave="onDragLeave(col.status)"
411
+ @drop="onDrop($event, col.status)"
412
+ >
413
+ <div class="kanban-col-header">
414
+ <span class="kanban-col-dot" :data-status="col.status"></span>
415
+ <span class="kanban-col-label">{{ col.label }}</span>
416
+ <span class="kanban-col-count">{{ col.stories.length }}</span>
417
+ </div>
418
+ <div class="kanban-col-body">
419
+ <div
420
+ v-for="story in col.stories"
421
+ :key="story.id"
422
+ class="kanban-card-wrap"
423
+ :class="{ 'kanban-card--dragging': dragStoryId === story.id }"
424
+ draggable="true"
425
+ @dragstart="onDragStart($event, story)"
426
+ @dragend="onDragEnd"
427
+ >
428
+ <BoardStoryCard
429
+ :story="story"
430
+ @select="openDetail"
431
+ @updated="refresh"
432
+ />
433
+ </div>
434
+ <div v-if="col.stories.length === 0" class="kanban-empty">Drag to move</div>
435
+ </div>
436
+ </div>
437
+ </div>
438
+
439
+ <!-- Timeline View -->
440
+ <div v-else-if="viewMode === 'timeline'" class="timeline-view">
441
+ <div class="tl-filter">
442
+ <button class="tl-filter-btn" :class="{ active: tlGroupBy === 'none' }" @click="tlGroupBy = 'none'">All</button>
443
+ <button class="tl-filter-btn" :class="{ active: tlGroupBy === 'assignee' }" @click="tlGroupBy = 'assignee'">By Assignee</button>
444
+ <button class="tl-filter-btn" :class="{ active: tlGroupBy === 'epic' }" @click="tlGroupBy = 'epic'">By Epic</button>
445
+ </div>
446
+ <div v-if="tlDisplayStories.length === 0" class="timeline-empty">No stories with dates set</div>
447
+ <template v-else>
448
+ <div class="tl-container">
449
+ <div class="tl-labels">
450
+ <div class="tl-label-header">Story</div>
451
+ <div v-for="s in tlDisplayStories" :key="'l'+s.id" class="tl-label" @click="openDetail(s)">
452
+ <span class="tl-label-title">{{ s.title }}</span>
453
+ <span v-if="s.assignee" class="tl-label-assignee">{{ s.assignee }}</span>
454
+ </div>
455
+ </div>
456
+ <div class="tl-chart" ref="tlChartRef">
457
+ <!-- Date header -->
458
+ <div class="tl-date-header">
459
+ <div v-for="d in timelineDates" :key="d.key" class="tl-date-cell" :class="{ 'tl-weekend': d.isWeekend, 'tl-today': d.isToday }">
460
+ {{ d.label }}
461
+ </div>
462
+ </div>
463
+ <!-- Story bars -->
464
+ <div v-for="s in tlDisplayStories" :key="'b'+s.id" class="tl-bar-row">
465
+ <div class="tl-grid">
466
+ <div v-for="d in timelineDates" :key="d.key" class="tl-grid-cell" :class="{ 'tl-weekend': d.isWeekend, 'tl-today': d.isToday }" />
467
+ </div>
468
+ <div class="tl-bar" :style="timelineBarStyle(s)" :title="`${s.title} (${s.storyPoints ?? 0} SP)`">
469
+ {{ s.storyPoints ?? '' }} SP
470
+ </div>
471
+ </div>
472
+ </div>
473
+ </div>
474
+ </template>
475
+ </div>
476
+ </div>
477
+
478
+ <!-- Backlog drawer -->
479
+ <div v-if="!isBacklog" class="backlog-drawer" :class="{ 'backlog-drawer--open': backlogOpen }">
480
+ <button class="backlog-toggle" @click="backlogOpen = !backlogOpen">
481
+ {{ backlogOpen ? '&#9660;' : '&#9654;' }} Backlog ({{ backlogStoryList.length }})
482
+ </button>
483
+ <div v-if="backlogOpen" class="backlog-content">
484
+ <div v-if="backlogStoryList.length === 0" class="backlog-empty">No stories in backlog</div>
485
+ <div v-for="s in backlogStoryList" :key="s.id" class="backlog-story-row" @click="openDetail(s)">
486
+ <span class="backlog-story-title">{{ s.title }}</span>
487
+ <span class="backlog-story-sp">{{ s.storyPoints ?? '-' }} SP</span>
488
+ <button class="backlog-assign-btn" @click.stop="assignToCurrentSprint(s.id)" title="Assign to current sprint">+</button>
489
+ </div>
490
+ </div>
491
+ </div>
492
+
493
+ <!-- Roadmap View -->
494
+ <div v-if="viewMode === 'roadmap'" class="roadmap-view">
495
+ <div class="roadmap-filters">
496
+ <select v-model="roadmapStatusFilter" class="filter-select">
497
+ <option value="">All</option>
498
+ <option value="active">Active</option>
499
+ <option value="completed">Completed</option>
500
+ <option value="archived">Archived</option>
501
+ </select>
502
+ </div>
503
+ <div v-for="epic in filteredEpics" :key="epic.id" class="roadmap-card" @click="toggleEpicExpand(epic.id)">
504
+ <div class="roadmap-card-header">
505
+ <div class="roadmap-epic-info">
506
+ <span class="roadmap-epic-title">{{ epic.title }}</span>
507
+ <span class="roadmap-epic-status" :class="'es--' + epic.status">{{ epic.status }}</span>
508
+ </div>
509
+ <div class="roadmap-progress">
510
+ <div class="roadmap-progress-bar">
511
+ <div class="roadmap-progress-fill" :style="{ width: epicProgress(epic.id) + '%' }" />
512
+ </div>
513
+ <span class="roadmap-progress-text">{{ epicDoneCount(epic.id) }}/{{ epicTotalCount(epic.id) }} ({{ epicProgress(epic.id) }}%)</span>
514
+ </div>
515
+ <span class="roadmap-sp">{{ epicDoneSP(epic.id) }}/{{ epicTotalSP(epic.id) }} SP</span>
516
+ </div>
517
+ <!-- Story accordion -->
518
+ <div v-if="expandedEpics.has(epic.id)" class="roadmap-stories">
519
+ <div v-for="s in epicStories(epic.id)" :key="s.id" class="roadmap-story" :class="'rs--' + s.status" @click.stop="openDetail(s)">
520
+ <span class="roadmap-story-title">S{{ s.id }}: {{ s.title }}</span>
521
+ <span class="roadmap-story-sp">{{ s.storyPoints ?? '-' }}SP</span>
522
+ </div>
523
+ </div>
524
+ </div>
525
+ </div>
526
+
527
+ <!-- Story detail slide panel -->
528
+ <Teleport to="body">
529
+ <StoryDetailPanel
530
+ v-if="selectedStory"
531
+ :story="selectedStory"
532
+ @close="selectedStory = null"
533
+ @updated="onPanelUpdated"
534
+ />
535
+ </Teleport>
536
+ </div>
537
+ </template>
538
+
539
+ <style scoped>
540
+ .board-scroll {
541
+ height: 100%;
542
+ overflow-y: auto;
543
+ }
544
+
545
+ .board-page {
546
+ max-width: 1100px;
547
+ margin: 0 auto;
548
+ padding: 24px;
549
+ background: var(--bg);
550
+ min-height: 100vh;
551
+ }
552
+ .board-page--kanban {
553
+ max-width: 100%;
554
+ }
555
+
556
+ .board-header { margin-bottom: 20px; }
557
+
558
+ .board-title-row {
559
+ display: flex;
560
+ align-items: center;
561
+ justify-content: space-between;
562
+ margin-bottom: 8px;
563
+ }
564
+ .board-title-row h1 {
565
+ font-size: 22px;
566
+ font-weight: 700;
567
+ color: var(--text-primary);
568
+ }
569
+
570
+ .board-title-actions {
571
+ display: flex;
572
+ align-items: center;
573
+ gap: 8px;
574
+ }
575
+
576
+ .view-toggle {
577
+ display: flex;
578
+ border: 1px solid rgba(0,0,0,0.06);
579
+ border-radius: 6px;
580
+ overflow: hidden;
581
+ }
582
+ .view-btn {
583
+ padding: 4px 12px;
584
+ font-size: 11px;
585
+ font-weight: 600;
586
+ border: none;
587
+ background: rgba(255,255,255,0.25);
588
+ color: var(--text-secondary);
589
+ cursor: pointer;
590
+ transition: all 0.15s;
591
+ }
592
+ .view-btn:not(:last-child) { border-right: 1px solid rgba(0,0,0,0.06); }
593
+ .view-btn.active {
594
+ background: var(--text-primary);
595
+ color: var(--text-on-primary);
596
+ }
597
+ .view-btn:hover:not(.active) { background: rgba(0,0,0,0.04); }
598
+
599
+ .board-stats {
600
+ display: flex;
601
+ gap: 16px;
602
+ font-size: 13px;
603
+ color: var(--text-secondary);
604
+ }
605
+ .stat-num { font-weight: 700; }
606
+ .stat--done .stat-num { color: #22c55e; }
607
+ .stat--progress .stat-num { color: #f59e0b; }
608
+ .stat--review .stat-num { color: #8b5cf6; }
609
+
610
+ /* Progress bar */
611
+ .progress-bar-wrap {
612
+ display: flex;
613
+ align-items: center;
614
+ gap: 10px;
615
+ margin-top: 8px;
616
+ }
617
+ .progress-bar {
618
+ flex: 1;
619
+ height: 6px;
620
+ background: rgba(0,0,0,0.06);
621
+ border-radius: 3px;
622
+ overflow: hidden;
623
+ display: flex;
624
+ }
625
+ .progress-seg {
626
+ height: 100%;
627
+ transition: width 0.3s ease;
628
+ }
629
+ .progress-seg--done { background: #22c55e; }
630
+ .progress-seg--review { background: #8b5cf6; }
631
+ .progress-seg--progress { background: #f59e0b; }
632
+ .progress-pct {
633
+ font-size: 12px;
634
+ font-weight: 700;
635
+ color: #22c55e;
636
+ min-width: 36px;
637
+ text-align: right;
638
+ }
639
+
640
+ .epic-list {
641
+ display: flex;
642
+ flex-direction: column;
643
+ gap: 12px;
644
+ }
645
+ .epic-list > * {
646
+ background: rgba(255, 255, 255, 0.25);
647
+ backdrop-filter: blur(40px) saturate(1.8);
648
+ -webkit-backdrop-filter: blur(40px) saturate(1.8);
649
+ border: 1px solid rgba(255, 255, 255, 0.45);
650
+ border-radius: 16px;
651
+ box-shadow:
652
+ 0 2px 12px rgba(0, 0, 0, 0.03),
653
+ inset 0 1px 0 rgba(255, 255, 255, 0.5),
654
+ inset 0 -0.5px 0 rgba(0, 0, 0, 0.03);
655
+ overflow: hidden;
656
+ }
657
+
658
+ /* Kanban board */
659
+ .kanban-board {
660
+ display: flex;
661
+ gap: 12px;
662
+ overflow-x: auto;
663
+ padding-bottom: 16px;
664
+ }
665
+
666
+ .kanban-col {
667
+ min-width: 200px;
668
+ flex: 1;
669
+ display: flex;
670
+ flex-direction: column;
671
+ }
672
+
673
+ .kanban-col-header {
674
+ display: flex;
675
+ align-items: center;
676
+ gap: 6px;
677
+ padding: 8px 10px;
678
+ font-size: 12px;
679
+ font-weight: 700;
680
+ color: var(--text-secondary);
681
+ background: rgba(255, 255, 255, 0.30);
682
+ backdrop-filter: blur(20px);
683
+ border: 1px solid rgba(255, 255, 255, 0.40);
684
+ border-radius: 12px 12px 0 0;
685
+ border-bottom: none;
686
+ }
687
+
688
+ .kanban-col-dot {
689
+ width: 8px;
690
+ height: 8px;
691
+ border-radius: 50%;
692
+ background: #94a3b8;
693
+ flex-shrink: 0;
694
+ }
695
+ .kanban-col-dot[data-status="draft"] { background: #94a3b8; }
696
+ .kanban-col-dot[data-status="backlog"] { background: #a78bfa; }
697
+ .kanban-col-dot[data-status="ready"] { background: #3b82f6; }
698
+ .kanban-col-dot[data-status="ready-for-dev"] { background: #3b82f6; }
699
+ .kanban-col-dot[data-status="in-progress"] { background: #f59e0b; }
700
+ .kanban-col-dot[data-status="review"] { background: #8b5cf6; }
701
+ .kanban-col-dot[data-status="qa"] { background: #ec4899; }
702
+ .kanban-col-dot[data-status="done"] { background: #22c55e; }
703
+
704
+ .kanban-col-count {
705
+ margin-left: auto;
706
+ background: rgba(0,0,0,0.06);
707
+ color: var(--text-secondary);
708
+ font-size: 10px;
709
+ font-weight: 700;
710
+ min-width: 18px;
711
+ height: 18px;
712
+ border-radius: 9px;
713
+ display: flex;
714
+ align-items: center;
715
+ justify-content: center;
716
+ }
717
+
718
+ .kanban-col-body {
719
+ flex: 1;
720
+ display: flex;
721
+ flex-direction: column;
722
+ gap: 8px;
723
+ padding: 10px;
724
+ border: 1px solid rgba(0,0,0,0.06);
725
+ border-radius: 0 0 8px 8px;
726
+ background: rgba(0,0,0,0.02);
727
+ min-height: 100px;
728
+ }
729
+
730
+ .kanban-empty {
731
+ text-align: center;
732
+ padding: 20px 0;
733
+ color: var(--text-muted);
734
+ font-size: 13px;
735
+ }
736
+
737
+ /* Drag and drop */
738
+ .kanban-card-wrap {
739
+ cursor: grab;
740
+ transition: opacity 0.15s, transform 0.15s;
741
+ }
742
+ .kanban-card-wrap:active { cursor: grabbing; }
743
+ .kanban-card--dragging {
744
+ opacity: 0.3;
745
+ transform: scale(0.95);
746
+ }
747
+ .kanban-col--dragover .kanban-col-body {
748
+ background: rgba(59,130,246,0.06);
749
+ border-color: rgba(59,130,246,0.3);
750
+ border-style: dashed;
751
+ }
752
+
753
+ /* Timeline view */
754
+ .timeline-view { margin-top: 12px; }
755
+ .tl-filter { display: flex; gap: 4px; margin-bottom: 12px; }
756
+ .tl-filter-btn { border: none; background: rgba(0,0,0,0.04); border-radius: 6px; padding: 4px 12px; font-size: 12px; cursor: pointer; color: var(--text-secondary); }
757
+ .tl-filter-btn.active { background: var(--primary); color: #fff; }
758
+ .timeline-empty { color: var(--text-muted); font-size: 13px; padding: 20px; text-align: center; }
759
+ .tl-container { display: flex; overflow: hidden; }
760
+ .tl-labels { width: 200px; flex-shrink: 0; border-right: 1px solid rgba(0,0,0,0.06); }
761
+ .tl-label-header { height: 32px; font-size: 11px; font-weight: 600; color: var(--text-muted); padding: 8px; }
762
+ .tl-label { height: 32px; display: flex; flex-direction: column; justify-content: center; padding: 0 8px; border-top: 1px solid rgba(0,0,0,0.03); cursor: pointer; }
763
+ .tl-label:hover { background: rgba(0,0,0,0.02); }
764
+ .tl-label-title { font-size: 11px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
765
+ .tl-label-assignee { font-size: 9px; color: var(--text-muted); }
766
+ .tl-chart { flex: 1; overflow-x: auto; position: relative; }
767
+ .tl-date-header { display: flex; height: 32px; }
768
+ .tl-date-cell { width: 40px; flex-shrink: 0; font-size: 9px; text-align: center; color: var(--text-muted); padding-top: 8px; border-left: 1px solid rgba(0,0,0,0.03); }
769
+ .tl-date-cell.tl-weekend { background: rgba(239,68,68,0.06); color: #ef4444; }
770
+ .tl-date-cell.tl-today { background: rgba(59,130,246,0.12); color: #2563EB; font-weight: 700; }
771
+ .tl-bar-row { position: relative; height: 32px; border-top: 1px solid rgba(0,0,0,0.03); }
772
+ .tl-grid { display: flex; position: absolute; inset: 0; }
773
+ .tl-grid-cell { width: 40px; flex-shrink: 0; border-left: 1px solid rgba(0,0,0,0.03); }
774
+ .tl-grid-cell.tl-weekend { background: rgba(239,68,68,0.04); }
775
+ .tl-grid-cell.tl-today { background: rgba(59,130,246,0.08); }
776
+ .tl-bar {
777
+ position: absolute; top: 4px; height: 24px; border-radius: 6px;
778
+ background: #3B82F6; color: #fff; font-size: 10px; font-weight: 600;
779
+ display: flex; align-items: center; padding: 0 8px; white-space: nowrap;
780
+ z-index: 1; opacity: 0.9; overflow: hidden; text-overflow: ellipsis; min-width: 32px;
781
+ }
782
+ .tl-bar:hover { opacity: 1; }
783
+
784
+ /* Backlog drawer */
785
+ .backlog-drawer { margin-top: 24px; border-top: 1px solid rgba(0,0,0,0.06); padding-top: 8px; }
786
+ .backlog-toggle { background: none; border: none; font-size: 14px; font-weight: 600; color: var(--text-secondary); cursor: pointer; padding: 8px 0; }
787
+ .backlog-toggle:hover { color: var(--text-primary); }
788
+ .backlog-content { margin-top: 8px; }
789
+ .backlog-empty { color: var(--text-muted); font-size: 13px; padding: 12px 0; }
790
+ .backlog-story-row {
791
+ display: flex; align-items: center; gap: 8px; padding: 8px 12px;
792
+ background: rgba(255,255,255,0.25); backdrop-filter: blur(20px);
793
+ border: 1px solid rgba(255,255,255,0.35); border-radius: 10px;
794
+ margin-bottom: 4px; cursor: pointer; font-size: 13px;
795
+ }
796
+ .backlog-story-row:hover { background: rgba(255,255,255,0.40); }
797
+ .backlog-story-title { flex: 1; }
798
+ .backlog-story-sp { color: var(--text-muted); font-size: 11px; }
799
+ .backlog-assign-btn {
800
+ border: none; background: rgba(59,130,246,0.12); color: #2563EB;
801
+ border-radius: 6px; width: 24px; height: 24px; font-size: 16px;
802
+ cursor: pointer; display: flex; align-items: center; justify-content: center;
803
+ }
804
+ .backlog-assign-btn:hover { background: rgba(59,130,246,0.25); }
805
+
806
+ .backlog-hint-bar {
807
+ text-align: center;
808
+ padding: 10px 16px;
809
+ background: rgba(59,130,246,0.06);
810
+ border: 1px dashed rgba(59,130,246,0.3);
811
+ border-radius: 8px;
812
+ color: #3b82f6;
813
+ font-size: 13px;
814
+ }
815
+
816
+ /* Roadmap */
817
+ .roadmap-view { padding: 16px 0; }
818
+ .roadmap-filters { margin-bottom: 16px; }
819
+ .filter-select { padding: 4px 8px; border: 1px solid rgba(0,0,0,0.06); border-radius: 6px; font-size: 13px; background: rgba(255,255,255,0.25); }
820
+ .roadmap-card { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.06); cursor: pointer; transition: all 0.2s; }
821
+ .roadmap-card:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
822
+ .roadmap-card-header { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
823
+ .roadmap-epic-info { flex: 1; min-width: 0; }
824
+ .roadmap-epic-title { font-size: 15px; font-weight: 600; }
825
+ .roadmap-epic-status { font-size: 10px; font-weight: 600; padding: 2px 6px; border-radius: 4px; margin-left: 8px; }
826
+ .es--active { background: #dcfce7; color: #16a34a; }
827
+ .es--completed { background: #e0e7ff; color: #4338ca; }
828
+ .es--archived { background: #f3f4f6; color: #6b7280; }
829
+ .roadmap-progress { width: 120px; }
830
+ .roadmap-progress-bar { height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; }
831
+ .roadmap-progress-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #22c55e); border-radius: 3px; }
832
+ .roadmap-progress-text { font-size: 11px; color: #888; }
833
+ .roadmap-sp { font-size: 12px; color: #6b7280; white-space: nowrap; }
834
+ .roadmap-stories { margin-top: 12px; padding-top: 12px; border-top: 1px solid #f0f0f0; }
835
+ .roadmap-story { display: flex; justify-content: space-between; padding: 4px 8px; font-size: 13px; border-radius: 4px; cursor: pointer; }
836
+ .roadmap-story:hover { background: #f8f9fa; }
837
+ .rs--done { color: #16a34a; } .rs--in-progress { color: #3b82f6; } .rs--backlog { color: #9ca3af; }
838
+ .roadmap-story-sp { font-size: 11px; color: #888; }
839
+
840
+ .error-banner {
841
+ text-align: center;
842
+ padding: 20px;
843
+ background: rgba(239,68,68,0.08);
844
+ border: 1px solid rgba(239,68,68,0.2);
845
+ border-radius: 8px;
846
+ color: #dc2626;
847
+ }
848
+
849
+ .loading, .empty {
850
+ text-align: center;
851
+ padding: 60px 20px;
852
+ color: var(--text-muted);
853
+ font-size: 14px;
854
+ }
855
+ .empty h2 { font-size: 18px; color: var(--text-primary); margin-bottom: 8px; }
856
+
857
+ .btn {
858
+ padding: 6px 14px;
859
+ border: 1px solid rgba(0,0,0,0.06);
860
+ border-radius: 6px;
861
+ font-size: 13px;
862
+ font-weight: 500;
863
+ cursor: pointer;
864
+ background: rgba(255,255,255,0.25);
865
+ color: var(--text-secondary);
866
+ white-space: nowrap;
867
+ transition: all 0.15s;
868
+ backdrop-filter: blur(20px);
869
+ -webkit-backdrop-filter: blur(20px);
870
+ }
871
+ .btn:hover { background: rgba(255,255,255,0.4); }
872
+ .btn--sm { padding: 4px 10px; font-size: 11px; }
873
+
874
+ @media (max-width: 767px) {
875
+ .board-page { padding: 12px; max-width: 100%; }
876
+ .board-header { flex-direction: column; align-items: flex-start; gap: 8px; }
877
+ .board-header h1 { font-size: 18px; }
878
+ .board-stats { flex-wrap: wrap; gap: 6px; }
879
+ .board-stats .stat-item { font-size: 11px; padding: 3px 8px; }
880
+ .kanban-board { gap: 6px; overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: 8px; }
881
+ .kanban-col { min-width: 200px; flex-shrink: 0; }
882
+ .kanban-col-header { font-size: 12px; padding: 6px 8px; }
883
+ }
884
+ </style>