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.
- package/adapters/codex/.codex/commands/_domain.md.hbs +33 -0
- package/adapters/codex/.codex/commands/analytics.md.hbs +55 -0
- package/adapters/codex/.codex/commands/daily.md.hbs +301 -0
- package/adapters/codex/.codex/commands/dev.md.hbs +62 -0
- package/adapters/codex/.codex/commands/gtm.md +82 -0
- package/adapters/codex/.codex/commands/handoff.md +259 -0
- package/adapters/codex/.codex/commands/market.md +120 -0
- package/adapters/codex/.codex/commands/metrics.md +123 -0
- package/adapters/codex/.codex/commands/oscar-loop.md +436 -0
- package/adapters/codex/.codex/commands/party.md +85 -0
- package/adapters/codex/.codex/commands/plan.md +43 -0
- package/adapters/codex/.codex/commands/research.md +203 -0
- package/adapters/codex/.codex/commands/retro.md +68 -0
- package/adapters/codex/.codex/commands/save.md +440 -0
- package/adapters/codex/.codex/commands/sessions.md +139 -0
- package/adapters/codex/.codex/commands/sprint.md +106 -0
- package/adapters/codex/.codex/commands/start.md +396 -0
- package/adapters/codex/.codex/commands/strategy.md +41 -0
- package/adapters/codex/.codex/commands/task.md +220 -0
- package/adapters/codex/.codex/commands/tracking.md +116 -0
- package/adapters/codex/.codex/commands/validate.md +58 -0
- package/adapters/codex/AGENTS.md.hbs +210 -0
- package/adapters/codex/manifest.yaml +36 -0
- package/adapters/gemini/.gemini/commands/_domain.md.hbs +33 -0
- package/adapters/gemini/.gemini/commands/analytics.md.hbs +55 -0
- package/adapters/gemini/.gemini/commands/daily.md.hbs +301 -0
- package/adapters/gemini/.gemini/commands/dev.md.hbs +62 -0
- package/adapters/gemini/.gemini/commands/gtm.md +82 -0
- package/adapters/gemini/.gemini/commands/handoff.md +259 -0
- package/adapters/gemini/.gemini/commands/market.md +120 -0
- package/adapters/gemini/.gemini/commands/metrics.md +123 -0
- package/adapters/gemini/.gemini/commands/oscar-loop.md +436 -0
- package/adapters/gemini/.gemini/commands/party.md +85 -0
- package/adapters/gemini/.gemini/commands/plan.md +43 -0
- package/adapters/gemini/.gemini/commands/research.md +203 -0
- package/adapters/gemini/.gemini/commands/retro.md +68 -0
- package/adapters/gemini/.gemini/commands/save.md +440 -0
- package/adapters/gemini/.gemini/commands/sessions.md +139 -0
- package/adapters/gemini/.gemini/commands/sprint.md +106 -0
- package/adapters/gemini/.gemini/commands/start.md +396 -0
- package/adapters/gemini/.gemini/commands/strategy.md +41 -0
- package/adapters/gemini/.gemini/commands/task.md +220 -0
- package/adapters/gemini/.gemini/commands/tracking.md +116 -0
- package/adapters/gemini/.gemini/commands/validate.md +58 -0
- package/adapters/gemini/GEMINI.md.hbs +210 -0
- package/adapters/gemini/manifest.yaml +36 -0
- package/bin/cli.mjs +215 -4
- package/lib/doctor.mjs +38 -1
- package/lib/hydrate.mjs +15 -0
- package/lib/industry-presets.mjs +135 -0
- package/lib/scaffold.mjs +5 -0
- package/lib/setup-wizard.mjs +71 -2
- package/package.json +1 -1
- package/scaffold/.context/agents/TEMPLATE.md +14 -0
- package/scaffold/.context/agents/analyst.md.hbs +3 -3
- package/scaffold/.context/agents/developer.md.hbs +5 -5
- package/scaffold/.context/agents/gtm-strategist.md.hbs +3 -3
- package/scaffold/.context/agents/handoff-specialist.md.hbs +18 -18
- package/scaffold/.context/agents/market-researcher.md.hbs +6 -6
- package/scaffold/.context/agents/orchestrator.md.hbs +8 -8
- package/scaffold/.context/agents/planner.md.hbs +6 -6
- package/scaffold/.context/agents/qa.md.hbs +5 -5
- package/scaffold/.context/agents/researcher.md.hbs +33 -6
- package/scaffold/.context/agents/strategist.md.hbs +8 -8
- package/scaffold/.context/agents/tracking-governor.md.hbs +2 -2
- package/scaffold/.context/project.yaml.example +25 -0
- package/scaffold/mcp-pm/package.json +19 -0
- package/scaffold/mcp-pm/src/api-client.ts +69 -0
- package/scaffold/mcp-pm/src/index.ts +660 -0
- package/scaffold/mcp-pm/tsconfig.json +14 -0
- package/scaffold/pm-api/package.json +21 -0
- package/scaffold/pm-api/sql/schema-core.sql +331 -0
- package/scaffold/pm-api/sql/schema-docs.sql +25 -0
- package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
- package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
- package/scaffold/pm-api/src/auth.ts +28 -0
- package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
- package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
- package/scaffold/pm-api/src/db/adapter.ts +36 -0
- package/scaffold/pm-api/src/db/turso.ts +147 -0
- package/scaffold/pm-api/src/index.ts +114 -0
- package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
- package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
- package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
- package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
- package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
- package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
- package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
- package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
- package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
- package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
- package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
- package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
- package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
- package/scaffold/pm-api/src/mcp.ts +871 -0
- package/scaffold/pm-api/src/nudge.ts +283 -0
- package/scaffold/pm-api/src/routes/auth.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
- package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
- package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
- package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
- package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
- package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
- package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
- package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
- package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
- package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
- package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
- package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
- package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
- package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
- package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
- package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
- package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
- package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
- package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
- package/scaffold/pm-api/src/types.ts +11 -0
- package/scaffold/pm-api/src/utils/activity.ts +22 -0
- package/scaffold/pm-api/src/utils/admin.ts +9 -0
- package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
- package/scaffold/pm-api/src/utils/assignee.ts +69 -0
- package/scaffold/pm-api/src/utils/db.ts +45 -0
- package/scaffold/pm-api/src/utils/initiative.ts +23 -0
- package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
- package/scaffold/pm-api/tsconfig.json +15 -0
- package/scaffold/pm-api/wrangler.toml.hbs +11 -0
- package/scaffold/spec-site/package-lock.json +40 -0
- package/scaffold/spec-site/package.json +4 -1
- package/scaffold/spec-site/src/api/types.ts +6 -0
- package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
- package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
- package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
- package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
- package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
- package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
- package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
- package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
- package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
- package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
- package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
- package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
- package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
- package/scaffold/spec-site/src/composables/useUser.ts +19 -1
- package/scaffold/spec-site/src/features.ts +108 -0
- package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
- package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
- package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
- package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
- package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
- package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
- package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
- package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
- package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
- package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
- package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
- package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
- package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
- package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
- package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
- package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
- package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
- package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
- package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
- package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
- package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
- package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
- package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
- package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
- package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
- package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
- package/scaffold/spec-site/src/router.ts +141 -0
|
@@ -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
|
+
}
|