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,200 @@
1
+ /**
2
+ * Notification composable — fetch, poll, and manage notifications.
3
+ *
4
+ * Singleton pattern: refs are module-level, shared across all consumers.
5
+ * In static mode, returns empty state gracefully.
6
+ */
7
+
8
+ import { ref, computed } from 'vue'
9
+ import { apiGet, apiPost, apiPatch, apiDelete, isStaticMode } from '@/api/client'
10
+
11
+ export interface NotificationItem {
12
+ id: number
13
+ type: string
14
+ title: string
15
+ body: string | null
16
+ sourceType: string
17
+ sourceId: number
18
+ pageId: string
19
+ actor: string
20
+ isRead: boolean
21
+ createdAt: number
22
+ }
23
+
24
+ // Module-level singletons
25
+ const notifications = ref<NotificationItem[]>([])
26
+ const _userName = ref<string | null>(null)
27
+ let _pollTimer: ReturnType<typeof setInterval> | null = null
28
+
29
+ // Cross-component communication: notification click -> open memo sidebar
30
+ export const shouldOpenMemoSidebar = ref(false)
31
+ export const pendingNotificationPageId = ref<string | null>(null)
32
+
33
+ export function useNotification() {
34
+ const unreadCount = computed(() => notifications.value.filter(n => !n.isRead).length)
35
+
36
+ function setUser(name: string | null) {
37
+ _userName.value = name
38
+ if (name) {
39
+ fetchNotifications()
40
+ } else {
41
+ notifications.value = []
42
+ }
43
+ }
44
+
45
+ async function fetchNotifications(): Promise<void> {
46
+ if (isStaticMode() || !_userName.value) return
47
+ try {
48
+ const { data } = await apiGet<{
49
+ notifications: Array<{
50
+ id: number; type: string; title: string; body: string | null
51
+ source_type: string; source_id: number; page_id: string
52
+ actor: string; is_read: number; created_at: string
53
+ }>
54
+ }>('/api/v2/notifications', { user: _userName.value })
55
+ if (data) {
56
+ notifications.value = data.notifications.map(row => ({
57
+ id: Number(row.id),
58
+ type: String(row.type),
59
+ title: String(row.title),
60
+ body: row.body ? String(row.body) : null,
61
+ sourceType: String(row.source_type),
62
+ sourceId: Number(row.source_id),
63
+ pageId: String(row.page_id),
64
+ actor: String(row.actor),
65
+ isRead: Number(row.is_read) === 1,
66
+ createdAt: new Date(row.created_at + 'Z').getTime(),
67
+ }))
68
+ }
69
+ } catch {
70
+ // offline -- keep existing
71
+ }
72
+ }
73
+
74
+ async function markAsRead(id: number): Promise<void> {
75
+ if (isStaticMode()) return
76
+ try {
77
+ const { error } = await apiPatch(`/api/v2/notifications/${id}/read`, {})
78
+ if (!error) {
79
+ const item = notifications.value.find(n => n.id === id)
80
+ if (item) item.isRead = true
81
+ }
82
+ } catch { /* ignore */ }
83
+ }
84
+
85
+ async function markAllAsRead(): Promise<void> {
86
+ if (isStaticMode() || !_userName.value) return
87
+ try {
88
+ const { error } = await apiPost('/api/v2/notifications/mark-all-read', { user: _userName.value })
89
+ if (!error) {
90
+ notifications.value.forEach(n => { n.isRead = true })
91
+ } else {
92
+ await fetchNotifications()
93
+ }
94
+ } catch { /* ignore */ }
95
+ }
96
+
97
+ function startPolling() {
98
+ stopPolling()
99
+ _pollTimer = setInterval(() => fetchNotifications(), 30_000)
100
+ }
101
+
102
+ function stopPolling() {
103
+ if (_pollTimer) {
104
+ clearInterval(_pollTimer)
105
+ _pollTimer = null
106
+ }
107
+ }
108
+
109
+ // Notification creation (called from useMemo)
110
+
111
+ async function createMemoNotifications(
112
+ memoId: number,
113
+ pageId: string,
114
+ author: string,
115
+ assignedTo: string | null,
116
+ content: string,
117
+ ): Promise<void> {
118
+ if (isStaticMode()) return
119
+ const preview = content.length > 60 ? content.slice(0, 60) + '...' : content
120
+
121
+ if (assignedTo) {
122
+ await _insertNotification(assignedTo, 'memo_assigned', `${author} left a memo`, preview, 'memo', memoId, pageId, author)
123
+ } else {
124
+ const members = await _getActiveMembers()
125
+ for (const m of members) {
126
+ if (m === author) continue
127
+ await _insertNotification(m, 'memo_mention_all', `${author} left a team memo`, preview, 'memo', memoId, pageId, author)
128
+ }
129
+ }
130
+ }
131
+
132
+ async function createReplyNotification(
133
+ memoId: number,
134
+ pageId: string,
135
+ replier: string,
136
+ memoAuthor: string,
137
+ content: string,
138
+ ): Promise<void> {
139
+ if (isStaticMode()) return
140
+ if (replier === memoAuthor) return
141
+ const preview = content.length > 60 ? content.slice(0, 60) + '...' : content
142
+ await _insertNotification(memoAuthor, 'reply_received', `${replier} replied`, preview, 'memo', memoId, pageId, replier)
143
+ }
144
+
145
+ async function deleteNotificationsForMemo(memoId: number): Promise<void> {
146
+ if (isStaticMode()) return
147
+ try {
148
+ await apiDelete('/api/v2/notifications/by-source', { sourceType: 'memo', sourceId: memoId })
149
+ } catch { /* ignore */ }
150
+ }
151
+
152
+ // Internal helpers
153
+
154
+ async function _insertNotification(
155
+ userName: string, type: string, title: string, body: string | null,
156
+ sourceType: string, sourceId: number, pageId: string, actor: string,
157
+ ): Promise<void> {
158
+ try {
159
+ await apiPost('/api/v2/notifications', {
160
+ userName, type, title, body, sourceType, sourceId, pageId, actor,
161
+ })
162
+ } catch { /* ignore */ }
163
+ }
164
+
165
+ async function _getActiveMembers(): Promise<string[]> {
166
+ try {
167
+ const { data } = await apiGet<{ users: string[] }>('/api/v2/notifications/active-users')
168
+ if (data) return data.users
169
+ } catch { /* ignore */ }
170
+ return []
171
+ }
172
+
173
+ function formatTimeAgo(ts: number): string {
174
+ const diff = Date.now() - ts
175
+ const mins = Math.floor(diff / 60_000)
176
+ if (mins < 1) return 'just now'
177
+ if (mins < 60) return `${mins}min ago`
178
+ const hours = Math.floor(mins / 60)
179
+ if (hours < 24) return `${hours}hr ago`
180
+ const days = Math.floor(hours / 24)
181
+ return `${days}d ago`
182
+ }
183
+
184
+ return {
185
+ notifications,
186
+ unreadCount,
187
+ setUser,
188
+ fetchNotifications,
189
+ markAsRead,
190
+ markAllAsRead,
191
+ startPolling,
192
+ stopPolling,
193
+ createMemoNotifications,
194
+ createReplyNotification,
195
+ deleteNotificationsForMemo,
196
+ formatTimeAgo,
197
+ shouldOpenMemoSidebar,
198
+ pendingNotificationPageId,
199
+ }
200
+ }
@@ -142,7 +142,8 @@ export async function updateStory(id: number, data: {
142
142
  area?: string
143
143
  storyPoints?: number | null
144
144
  epicId?: number | null
145
- sprint?: string
145
+ sprint?: string | null
146
+ figmaUrl?: string | null
146
147
  }): Promise<{ error?: string }> {
147
148
  if (isStaticMode()) return { error: 'CRUD not available in static mode' }
148
149
  const { error } = await apiPatch(`/api/v2/pm/stories/${id}`, data as Record<string, unknown>)
@@ -222,3 +223,49 @@ export function getEpicById(id: number): PmEpic | undefined {
222
223
  export function getTasksForStory(storyId: number): PmTask[] {
223
224
  return tasks.value.filter(t => t.storyId === storyId)
224
225
  }
226
+
227
+ export function getBacklogStories(): PmStory[] {
228
+ return stories.value.filter(s => s.status === 'backlog')
229
+ }
230
+
231
+ export function getMyStories(user: string): PmStory[] {
232
+ return stories.value.filter(s => s.assignee === user)
233
+ }
234
+
235
+ export function getMyTasks(user: string): PmTask[] {
236
+ return tasks.value.filter(t => t.assignee === user)
237
+ }
238
+
239
+ export async function updateStoryStatus(id: number, status: StoryStatus): Promise<{ error?: string }> {
240
+ return updateStory(id, { status })
241
+ }
242
+
243
+ export async function updateTaskStatus(id: number, status: TaskStatus): Promise<{ error?: string }> {
244
+ return updateTask(id, { status })
245
+ }
246
+
247
+ export async function moveToSprint(storyId: number, sprint: string | null): Promise<{ error?: string }> {
248
+ return updateStory(storyId, { sprint })
249
+ }
250
+
251
+ export async function loadBacklog(): Promise<void> {
252
+ if (isStaticMode()) return
253
+ const { data, error } = await apiGet<{ stories: PmStoryRow[]; tasks: PmTaskRow[] }>(
254
+ '/api/v2/pm/data', { status: 'backlog' },
255
+ )
256
+ if (!error && data) {
257
+ // Merge backlog stories into existing list (avoid duplicates)
258
+ const existingIds = new Set(stories.value.map(s => s.id))
259
+ for (const row of data.stories) {
260
+ if (!existingIds.has(row.id)) {
261
+ stories.value.push(mapStory(row))
262
+ }
263
+ }
264
+ const existingTaskIds = new Set(tasks.value.map(t => t.id))
265
+ for (const row of data.tasks) {
266
+ if (!existingTaskIds.has(row.id)) {
267
+ tasks.value.push(mapTask(row))
268
+ }
269
+ }
270
+ }
271
+ }
@@ -372,6 +372,11 @@ export function useRetro(sprintId: string) {
372
372
 
373
373
  onUnmounted(stopPolling)
374
374
 
375
+ const participants = computed(() => {
376
+ const authors = new Set(items.value.map(i => i.author))
377
+ return Array.from(authors).sort()
378
+ })
379
+
375
380
  return {
376
381
  session,
377
382
  items,
@@ -383,6 +388,7 @@ export function useRetro(sprintId: string) {
383
388
  tryItems,
384
389
  myVoteCount,
385
390
  votesRemaining,
391
+ participants,
386
392
  loadOrCreateSession,
387
393
  setPhase,
388
394
  addItem,
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Standup composable — fetch and save daily standup entries.
3
+ *
4
+ * Singleton pattern for team entries; instance pattern for polling.
5
+ * In static mode, returns empty state gracefully.
6
+ */
7
+
8
+ import { ref, onUnmounted } from 'vue'
9
+ import { apiGet, apiPut, apiPost, isStaticMode } from '@/api/client'
10
+
11
+ export interface StandupEntry {
12
+ id: number
13
+ sprint: string
14
+ userName: string
15
+ entryDate: string
16
+ doneText: string | null
17
+ planText: string | null
18
+ planStoryIds: number[]
19
+ blockers: string | null
20
+ createdAt: string
21
+ updatedAt: string
22
+ }
23
+
24
+ interface StandupRow {
25
+ id: number
26
+ sprint: string
27
+ user_name: string
28
+ entry_date: string
29
+ done_text: string | null
30
+ plan_text: string | null
31
+ plan_story_ids: string | null
32
+ blockers: string | null
33
+ created_at: string
34
+ updated_at: string
35
+ }
36
+
37
+ export interface StandupFeedback {
38
+ id: number
39
+ standupEntryId: number
40
+ sprint: string
41
+ targetUser: string
42
+ feedbackBy: string
43
+ feedbackText: string
44
+ reviewType: 'comment' | 'approve' | 'request_changes'
45
+ createdAt: string
46
+ }
47
+
48
+ interface StandupFeedbackRow {
49
+ id: number
50
+ standup_entry_id: number
51
+ sprint: string
52
+ target_user: string
53
+ feedback_by: string
54
+ feedback_text: string
55
+ review_type: string
56
+ created_at: string
57
+ }
58
+
59
+ function mapEntry(r: StandupRow): StandupEntry {
60
+ return {
61
+ id: r.id,
62
+ sprint: r.sprint,
63
+ userName: r.user_name,
64
+ entryDate: r.entry_date,
65
+ doneText: r.done_text,
66
+ planText: r.plan_text,
67
+ planStoryIds: r.plan_story_ids ? JSON.parse(r.plan_story_ids) : [],
68
+ blockers: r.blockers,
69
+ createdAt: r.created_at,
70
+ updatedAt: r.updated_at,
71
+ }
72
+ }
73
+
74
+ function mapFeedback(r: StandupFeedbackRow): StandupFeedback {
75
+ return {
76
+ id: r.id,
77
+ standupEntryId: r.standup_entry_id,
78
+ sprint: r.sprint,
79
+ targetUser: r.target_user,
80
+ feedbackBy: r.feedback_by,
81
+ feedbackText: r.feedback_text,
82
+ reviewType: r.review_type as StandupFeedback['reviewType'],
83
+ createdAt: r.created_at,
84
+ }
85
+ }
86
+
87
+ const POLL_INTERVAL_MS = 30_000
88
+
89
+ export function useStandup() {
90
+ const entries = ref<StandupEntry[]>([])
91
+ const loading = ref(false)
92
+ const error = ref<string | null>(null)
93
+
94
+ async function fetchEntries(date: string): Promise<void> {
95
+ if (isStaticMode()) return
96
+ const { data, error: apiError } = await apiGet<{ entries: StandupRow[] }>(
97
+ '/api/v2/standup/entries', { date },
98
+ )
99
+ if (apiError) {
100
+ error.value = apiError
101
+ } else if (data) {
102
+ entries.value = data.entries.map(mapEntry)
103
+ }
104
+ }
105
+
106
+ async function loadEntries(date: string): Promise<void> {
107
+ loading.value = true
108
+ error.value = null
109
+ await fetchEntries(date)
110
+ loading.value = false
111
+ }
112
+
113
+ async function saveEntry(
114
+ userName: string,
115
+ date: string,
116
+ sprint: string,
117
+ data: { doneText?: string | null; planText?: string | null; planStoryIds?: number[]; blockers?: string | null },
118
+ ): Promise<{ error?: string }> {
119
+ if (isStaticMode()) return { error: 'Not available in static mode' }
120
+ const { error: apiError } = await apiPut('/api/v2/standup/entries', {
121
+ sprint,
122
+ userName,
123
+ date,
124
+ doneText: data.doneText ?? null,
125
+ planText: data.planText ?? null,
126
+ planStoryIds: data.planStoryIds ?? null,
127
+ blockers: data.blockers ?? null,
128
+ })
129
+ if (apiError) return { error: apiError }
130
+ await fetchEntries(date)
131
+ return {}
132
+ }
133
+
134
+ function getEntryForUser(userName: string, date: string): StandupEntry | undefined {
135
+ return entries.value.find(e => e.userName === userName && e.entryDate === date)
136
+ }
137
+
138
+ // Polling
139
+ let pollTimer: ReturnType<typeof setInterval> | null = null
140
+ let _pollDate = ''
141
+
142
+ function startPolling(date: string) {
143
+ _pollDate = date
144
+ stopPolling()
145
+ pollTimer = setInterval(() => fetchEntries(_pollDate), POLL_INTERVAL_MS)
146
+ }
147
+
148
+ function stopPolling() {
149
+ if (pollTimer) {
150
+ clearInterval(pollTimer)
151
+ pollTimer = null
152
+ }
153
+ }
154
+
155
+ // Feedback
156
+ const feedback = ref<StandupFeedback[]>([])
157
+
158
+ async function loadFeedback(entryId: number): Promise<void> {
159
+ if (isStaticMode()) return
160
+ const { data } = await apiGet<{ feedback: StandupFeedbackRow[] }>(
161
+ '/api/v2/standup/feedback', { standup_entry_id: String(entryId) },
162
+ )
163
+ if (data) {
164
+ feedback.value = data.feedback.map(mapFeedback)
165
+ }
166
+ }
167
+
168
+ async function submitFeedback(
169
+ entryId: number, sprint: string, targetUser: string,
170
+ feedbackBy: string, feedbackText: string, reviewType: string,
171
+ ): Promise<{ error?: string }> {
172
+ if (isStaticMode()) return { error: 'Not available in static mode' }
173
+ const { error: apiError } = await apiPost('/api/v2/standup/feedback', {
174
+ standupEntryId: entryId,
175
+ sprint,
176
+ targetUser,
177
+ feedbackBy,
178
+ feedbackText,
179
+ reviewType,
180
+ })
181
+ if (apiError) return { error: apiError }
182
+ await loadFeedback(entryId)
183
+ return {}
184
+ }
185
+
186
+ onUnmounted(stopPolling)
187
+
188
+ return {
189
+ entries,
190
+ loading,
191
+ error,
192
+ feedback,
193
+ loadEntries,
194
+ saveEntry,
195
+ getEntryForUser,
196
+ loadFeedback,
197
+ submitFeedback,
198
+ startPolling,
199
+ stopPolling,
200
+ }
201
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Theme composable — light/dark/system theme toggle.
3
+ *
4
+ * Applies data-theme attribute to document root.
5
+ * Persists selection in localStorage.
6
+ */
7
+ import { ref, watchEffect } from 'vue'
8
+
9
+ export type ThemeMode = 'light' | 'dark' | 'system'
10
+
11
+ const theme = ref<ThemeMode>((localStorage.getItem('theme') as ThemeMode) || 'light')
12
+
13
+ const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
14
+
15
+ function applyTheme() {
16
+ let effective: 'light' | 'dark'
17
+ if (theme.value === 'system') {
18
+ effective = mediaQuery.matches ? 'dark' : 'light'
19
+ } else {
20
+ effective = theme.value
21
+ }
22
+ document.documentElement.setAttribute('data-theme', effective)
23
+ localStorage.setItem('theme', theme.value)
24
+ }
25
+
26
+ mediaQuery.addEventListener('change', applyTheme)
27
+ watchEffect(applyTheme)
28
+
29
+ export function useTheme() {
30
+ function toggle() {
31
+ const order: ThemeMode[] = ['light', 'dark', 'system']
32
+ const idx = order.indexOf(theme.value)
33
+ theme.value = order[(idx + 1) % order.length]
34
+ }
35
+
36
+ return { theme, toggle }
37
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { ref } from 'vue'
9
+ import { apiGet, isStaticMode } from '@/api/client'
9
10
 
10
11
  // TODO: Replace with your team members or load dynamically from API
11
12
  export const TEAM_MEMBERS: string[] = []
@@ -16,6 +17,23 @@ const currentUser = ref<string | null>(
16
17
  localStorage.getItem(STORAGE_KEY) ?? null,
17
18
  )
18
19
 
20
+ const dynamicMembers = ref<string[]>([])
21
+
22
+ async function loadMembers(): Promise<void> {
23
+ if (isStaticMode()) {
24
+ dynamicMembers.value = [...TEAM_MEMBERS]
25
+ return
26
+ }
27
+ try {
28
+ const { data, error } = await apiGet<{ members: Array<{ name: string }> }>('/api/v2/admin/members')
29
+ if (!error && data) {
30
+ dynamicMembers.value = data.members.map(m => m.name)
31
+ }
32
+ } catch {
33
+ dynamicMembers.value = [...TEAM_MEMBERS]
34
+ }
35
+ }
36
+
19
37
  export function useUser() {
20
38
  function setUser(name: string) {
21
39
  currentUser.value = name
@@ -27,5 +45,5 @@ export function useUser() {
27
45
  localStorage.removeItem(STORAGE_KEY)
28
46
  }
29
47
 
30
- return { currentUser, setUser, clearUser, TEAM_MEMBERS }
48
+ return { currentUser, setUser, clearUser, TEAM_MEMBERS, dynamicMembers, loadMembers }
31
49
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Feature flags — resolves enabled features from project config.
3
+ *
4
+ * Reads from VITE_FEATURES env variable (comma-separated list)
5
+ * or defaults to all features enabled in API mode, minimal in static mode.
6
+ *
7
+ * Usage:
8
+ * import { isFeatureEnabled, enabledFeatures } from '@/features'
9
+ * if (isFeatureEnabled('board')) { ... }
10
+ */
11
+
12
+ import { isStaticMode } from '@/api/client'
13
+
14
+ export type FeatureId =
15
+ | 'dashboard'
16
+ | 'board'
17
+ | 'standup'
18
+ | 'retro'
19
+ | 'inbox'
20
+ | 'specs'
21
+ | 'admin'
22
+ | 'meetings'
23
+ | 'docs'
24
+ | 'my-page'
25
+ | 'rewards'
26
+
27
+ /** All available features */
28
+ export const ALL_FEATURES: FeatureId[] = [
29
+ 'dashboard',
30
+ 'board',
31
+ 'standup',
32
+ 'retro',
33
+ 'inbox',
34
+ 'specs',
35
+ 'admin',
36
+ 'meetings',
37
+ 'docs',
38
+ 'my-page',
39
+ 'rewards',
40
+ ]
41
+
42
+ /** Features available in static mode (no backend needed) */
43
+ const STATIC_FEATURES: FeatureId[] = ['specs', 'retro']
44
+
45
+ /** Features that require API mode */
46
+ const API_ONLY_FEATURES: FeatureId[] = [
47
+ 'dashboard',
48
+ 'board',
49
+ 'standup',
50
+ 'inbox',
51
+ 'admin',
52
+ 'meetings',
53
+ 'docs',
54
+ 'my-page',
55
+ ]
56
+
57
+ function resolveFeatures(): Set<FeatureId> {
58
+ const envFeatures = import.meta.env.VITE_FEATURES as string | undefined
59
+
60
+ if (envFeatures) {
61
+ // Explicit feature list from env
62
+ const ids = envFeatures.split(',').map(s => s.trim()) as FeatureId[]
63
+ return new Set(ids.filter(id => ALL_FEATURES.includes(id)))
64
+ }
65
+
66
+ if (isStaticMode()) {
67
+ return new Set(STATIC_FEATURES)
68
+ }
69
+
70
+ // API mode: all features enabled by default
71
+ return new Set(ALL_FEATURES)
72
+ }
73
+
74
+ const _enabledFeatures = resolveFeatures()
75
+
76
+ /** Check if a feature is enabled */
77
+ export function isFeatureEnabled(id: FeatureId): boolean {
78
+ return _enabledFeatures.has(id)
79
+ }
80
+
81
+ /** Get all enabled features */
82
+ export function enabledFeatures(): FeatureId[] {
83
+ return ALL_FEATURES.filter(id => _enabledFeatures.has(id))
84
+ }
85
+
86
+ /** Navigation items for enabled features */
87
+ export interface NavItem {
88
+ id: FeatureId
89
+ label: string
90
+ path: string
91
+ icon: string
92
+ }
93
+
94
+ export function getNavItems(): NavItem[] {
95
+ const items: NavItem[] = [
96
+ { id: 'dashboard', label: 'Dashboard', path: '/', icon: '📊' },
97
+ { id: 'board', label: 'Board', path: '/board', icon: '📋' },
98
+ { id: 'standup', label: 'Standup', path: '/standup', icon: '☀️' },
99
+ { id: 'retro', label: 'Retro', path: '/retro', icon: '🔄' },
100
+ { id: 'inbox', label: 'Inbox', path: '/inbox', icon: '📬' },
101
+ { id: 'specs', label: 'Specs', path: '/specs', icon: '📐' },
102
+ { id: 'docs', label: 'Docs', path: '/docs', icon: '📄' },
103
+ { id: 'meetings', label: 'Meetings', path: '/meetings', icon: '🗓️' },
104
+ { id: 'rewards', label: 'Rewards', path: '/rewards', icon: '🏆' },
105
+ { id: 'admin', label: 'Admin', path: '/admin', icon: '⚙️' },
106
+ ]
107
+ return items.filter(item => isFeatureEnabled(item.id))
108
+ }