popilot 0.6.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 (112) hide show
  1. package/bin/cli.mjs +204 -2
  2. package/lib/doctor.mjs +38 -1
  3. package/lib/hydrate.mjs +15 -0
  4. package/lib/scaffold.mjs +5 -0
  5. package/lib/setup-wizard.mjs +35 -2
  6. package/package.json +1 -1
  7. package/scaffold/.context/project.yaml.example +19 -0
  8. package/scaffold/mcp-pm/package.json +19 -0
  9. package/scaffold/mcp-pm/src/api-client.ts +69 -0
  10. package/scaffold/mcp-pm/src/index.ts +660 -0
  11. package/scaffold/mcp-pm/tsconfig.json +14 -0
  12. package/scaffold/pm-api/package.json +21 -0
  13. package/scaffold/pm-api/sql/schema-core.sql +331 -0
  14. package/scaffold/pm-api/sql/schema-docs.sql +25 -0
  15. package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
  16. package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
  17. package/scaffold/pm-api/src/auth.ts +28 -0
  18. package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
  19. package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
  20. package/scaffold/pm-api/src/db/adapter.ts +36 -0
  21. package/scaffold/pm-api/src/db/turso.ts +147 -0
  22. package/scaffold/pm-api/src/index.ts +114 -0
  23. package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
  24. package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
  25. package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
  26. package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
  27. package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
  28. package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
  29. package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
  30. package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
  31. package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
  32. package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
  33. package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
  34. package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
  35. package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
  36. package/scaffold/pm-api/src/mcp.ts +871 -0
  37. package/scaffold/pm-api/src/nudge.ts +283 -0
  38. package/scaffold/pm-api/src/routes/auth.ts +32 -0
  39. package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
  40. package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
  41. package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
  42. package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
  43. package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
  44. package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
  45. package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
  46. package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
  47. package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
  48. package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
  49. package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
  50. package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
  51. package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
  52. package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
  53. package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
  54. package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
  55. package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
  56. package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
  57. package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
  58. package/scaffold/pm-api/src/types.ts +11 -0
  59. package/scaffold/pm-api/src/utils/activity.ts +22 -0
  60. package/scaffold/pm-api/src/utils/admin.ts +9 -0
  61. package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
  62. package/scaffold/pm-api/src/utils/assignee.ts +69 -0
  63. package/scaffold/pm-api/src/utils/db.ts +45 -0
  64. package/scaffold/pm-api/src/utils/initiative.ts +23 -0
  65. package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
  66. package/scaffold/pm-api/tsconfig.json +15 -0
  67. package/scaffold/pm-api/wrangler.toml.hbs +11 -0
  68. package/scaffold/spec-site/package-lock.json +40 -0
  69. package/scaffold/spec-site/package.json +4 -1
  70. package/scaffold/spec-site/src/api/types.ts +6 -0
  71. package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
  72. package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
  73. package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
  74. package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
  75. package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
  76. package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
  77. package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
  78. package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
  79. package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
  80. package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
  81. package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
  82. package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
  83. package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
  84. package/scaffold/spec-site/src/composables/useUser.ts +19 -1
  85. package/scaffold/spec-site/src/features.ts +108 -0
  86. package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
  87. package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
  88. package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
  89. package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
  90. package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
  91. package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
  92. package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
  93. package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
  94. package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
  95. package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
  96. package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
  97. package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
  98. package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
  99. package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
  100. package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
  101. package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
  102. package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
  103. package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
  104. package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
  105. package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
  106. package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
  107. package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
  108. package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
  109. package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
  110. package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
  111. package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
  112. package/scaffold/spec-site/src/router.ts +141 -0
@@ -0,0 +1,116 @@
1
+ <script setup lang="ts">
2
+ import { type NotificationItem } from '@/composables/useNotification'
3
+
4
+ defineProps<{
5
+ notifications: NotificationItem[]
6
+ unreadCount: number
7
+ }>()
8
+
9
+ const emit = defineEmits<{
10
+ toggle: []
11
+ click: [n: NotificationItem]
12
+ markAllRead: []
13
+ }>()
14
+
15
+ function getNotifIcon(type: string): string {
16
+ if (type === 'memo_assigned') return '📩'
17
+ if (type === 'memo_mention_all') return '📢'
18
+ if (type === 'reply_received') return '💬'
19
+ return '🔔'
20
+ }
21
+
22
+ function formatTimeAgo(ts: number): string {
23
+ const diff = Date.now() - ts
24
+ const mins = Math.floor(diff / 60_000)
25
+ if (mins < 1) return 'just now'
26
+ if (mins < 60) return `${mins}min ago`
27
+ const hours = Math.floor(mins / 60)
28
+ if (hours < 24) return `${hours}hr ago`
29
+ const days = Math.floor(hours / 24)
30
+ return `${days}d ago`
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <div class="notification-bell">
36
+ <button class="bell-trigger" @click.stop="emit('toggle')">
37
+ <span class="bell-icon">&#128276;</span>
38
+ <span v-if="unreadCount > 0" class="bell-badge">{{ unreadCount > 9 ? '9+' : unreadCount }}</span>
39
+ </button>
40
+ <div class="notif-dropdown">
41
+ <div class="notif-header">
42
+ <span class="notif-header-title">Notifications</span>
43
+ <button v-if="unreadCount > 0" class="notif-mark-all" @click="emit('markAllRead')">Mark all read</button>
44
+ </div>
45
+ <div class="notif-list">
46
+ <div
47
+ v-for="n in notifications"
48
+ :key="n.id"
49
+ class="notif-item"
50
+ :class="{ unread: !n.isRead }"
51
+ @click="emit('click', n)"
52
+ >
53
+ <span class="notif-item-icon">{{ getNotifIcon(n.type) }}</span>
54
+ <div class="notif-item-content">
55
+ <div class="notif-item-title">{{ n.title }}</div>
56
+ <div v-if="n.body" class="notif-item-body">{{ n.body }}</div>
57
+ <div class="notif-item-meta">
58
+ <span class="notif-item-page">{{ n.pageId }}</span>
59
+ <span class="notif-item-time">{{ formatTimeAgo(n.createdAt) }}</span>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ <div v-if="notifications.length === 0" class="notif-empty">No notifications</div>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </template>
68
+
69
+ <style scoped>
70
+ .notification-bell { position: relative; }
71
+ .bell-trigger {
72
+ position: relative; display: flex; align-items: center; justify-content: center;
73
+ width: 36px; height: 36px; border-radius: 6px; border: none; background: none;
74
+ cursor: pointer; transition: background 0.15s;
75
+ }
76
+ .bell-trigger:hover { background: var(--bg); }
77
+ .bell-icon { font-size: 18px; }
78
+ .bell-badge {
79
+ position: absolute; top: 2px; right: 2px; min-width: 16px; height: 16px;
80
+ padding: 0 4px; border-radius: 8px; background: #ef4444; color: #fff;
81
+ font-size: 10px; font-weight: 700; display: flex; align-items: center;
82
+ justify-content: center; line-height: 1; animation: pulse-notif 2s infinite;
83
+ }
84
+ @keyframes pulse-notif { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.15); } }
85
+ .notif-dropdown {
86
+ position: absolute; top: calc(100% + 4px); right: 0; width: 340px; max-height: 440px;
87
+ background: #fff; border: 1px solid var(--border); border-radius: 8px;
88
+ box-shadow: var(--shadow-md); z-index: 1000; display: flex; flex-direction: column; overflow: hidden;
89
+ }
90
+ .notif-header {
91
+ display: flex; align-items: center; justify-content: space-between;
92
+ padding: 12px 16px; border-bottom: 1px solid var(--border); flex-shrink: 0;
93
+ }
94
+ .notif-header-title { font-size: 14px; font-weight: 700; color: var(--text-primary); }
95
+ .notif-mark-all {
96
+ background: none; border: none; color: #3b82f6; font-size: 12px; font-weight: 500;
97
+ cursor: pointer; padding: 2px 6px; border-radius: 4px;
98
+ }
99
+ .notif-mark-all:hover { background: #eff6ff; }
100
+ .notif-list { flex: 1; overflow-y: auto; }
101
+ .notif-item {
102
+ display: flex; gap: 10px; padding: 10px 16px; cursor: pointer;
103
+ transition: background 0.1s; border-bottom: 1px solid #f1f5f9;
104
+ }
105
+ .notif-item:hover { background: #f8fafc; }
106
+ .notif-item.unread { background: #eff6ff; }
107
+ .notif-item.unread:hover { background: #dbeafe; }
108
+ .notif-item-icon { font-size: 16px; flex-shrink: 0; padding-top: 2px; }
109
+ .notif-item-content { flex: 1; min-width: 0; }
110
+ .notif-item-title { font-size: 13px; font-weight: 500; color: var(--text-primary); line-height: 1.3; }
111
+ .notif-item-body { font-size: 12px; color: var(--text-secondary); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
112
+ .notif-item-meta { display: flex; align-items: center; gap: 8px; margin-top: 4px; font-size: 11px; color: var(--text-muted); }
113
+ .notif-item-page { background: #f1f5f9; padding: 1px 6px; border-radius: 3px; font-size: 10px; }
114
+ .notif-empty { padding: 32px 16px; text-align: center; color: var(--text-muted); font-size: 13px; }
115
+ @media (max-width: 767px) { .notif-dropdown { width: 300px; right: -8px; } }
116
+ </style>
@@ -0,0 +1,102 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch, onMounted, onUnmounted } from 'vue'
3
+ import { useRouter } from 'vue-router'
4
+ import { apiGet } from '@/api/client'
5
+
6
+ const props = defineProps<{ visible: boolean }>()
7
+ const emit = defineEmits<{ close: [] }>()
8
+ const router = useRouter()
9
+
10
+ const query = ref('')
11
+ const results = ref<any[]>([])
12
+ const loading = ref(false)
13
+ let debounceTimer: ReturnType<typeof setTimeout>
14
+
15
+ watch(query, (q) => {
16
+ clearTimeout(debounceTimer)
17
+ if (q.length < 2) { results.value = []; return }
18
+ debounceTimer = setTimeout(() => search(q), 300)
19
+ })
20
+
21
+ async function search(q: string) {
22
+ loading.value = true
23
+ const { data } = await apiGet(`/api/v2/search?q=${encodeURIComponent(q)}`)
24
+ if (data?.results) results.value = data.results as any[]
25
+ loading.value = false
26
+ }
27
+
28
+ function navigate(r: any) {
29
+ router.push(r.url)
30
+ emit('close')
31
+ }
32
+
33
+ function typeIcon(t: string) {
34
+ return { story: '📋', memo: '💬', doc: '📄', meeting: '🎙️' }[t] || '📌'
35
+ }
36
+
37
+ function typeLabel(t: string) {
38
+ return { story: 'Story', memo: 'Memo', doc: 'Document', meeting: 'Meeting' }[t] || t
39
+ }
40
+
41
+ const grouped = ref<Record<string, any[]>>({})
42
+ watch(results, (r) => {
43
+ const g: Record<string, any[]> = {}
44
+ for (const item of r) {
45
+ if (!g[item.type]) g[item.type] = []
46
+ g[item.type].push(item)
47
+ }
48
+ grouped.value = g
49
+ })
50
+
51
+ function onKeydown(e: KeyboardEvent) {
52
+ if (e.key === 'Escape') emit('close')
53
+ }
54
+
55
+ onMounted(() => window.addEventListener('keydown', onKeydown))
56
+ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
57
+ </script>
58
+
59
+ <template>
60
+ <Teleport to="body">
61
+ <div v-if="visible" class="search-overlay" @click.self="emit('close')">
62
+ <div class="search-modal">
63
+ <input
64
+ v-model="query"
65
+ class="search-input"
66
+ placeholder="Search stories, memos, documents... (Esc to close)"
67
+ autofocus
68
+ />
69
+ <div v-if="loading" class="search-loading">Searching...</div>
70
+ <div v-else-if="query.length >= 2 && !results.length" class="search-empty">No results</div>
71
+ <div v-else class="search-results">
72
+ <template v-for="(items, type) in grouped" :key="type">
73
+ <div class="search-group-title">{{ typeIcon(type) }} {{ typeLabel(type) }}</div>
74
+ <div
75
+ v-for="r in items"
76
+ :key="r.type + r.id"
77
+ class="search-item"
78
+ @click="navigate(r)"
79
+ >
80
+ <div class="search-item-title">{{ r.title || r.id }}</div>
81
+ <div v-if="r.preview" class="search-item-preview">{{ r.preview }}</div>
82
+ </div>
83
+ </template>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </Teleport>
88
+ </template>
89
+
90
+ <style scoped>
91
+ .search-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); z-index: 9999; display: flex; justify-content: center; padding-top: 120px; }
92
+ .search-modal { background: #fff; border-radius: 12px; width: 560px; max-height: 480px; overflow: hidden; box-shadow: 0 16px 48px rgba(0,0,0,0.2); display: flex; flex-direction: column; }
93
+ .search-input { border: none; padding: 16px 20px; font-size: 16px; outline: none; border-bottom: 1px solid #e5e7eb; }
94
+ .search-results { overflow-y: auto; padding: 8px; flex: 1; }
95
+ .search-group-title { font-size: 12px; font-weight: 600; color: #9ca3af; padding: 8px 12px 4px; }
96
+ .search-item { padding: 8px 12px; border-radius: 6px; cursor: pointer; }
97
+ .search-item:hover { background: #f3f4f6; }
98
+ .search-item-title { font-size: 14px; font-weight: 500; }
99
+ .search-item-preview { font-size: 12px; color: #6b7280; margin-top: 2px; }
100
+ .search-loading, .search-empty { padding: 24px; text-align: center; color: #9ca3af; font-size: 14px; }
101
+ @media (max-width: 640px) { .search-modal { width: 95%; margin: 0 auto; } }
102
+ </style>
@@ -0,0 +1,77 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ const props = defineProps<{
5
+ data: Array<{ label: string; planned: number; actual: number }>
6
+ }>()
7
+
8
+ const maxValue = computed(() => {
9
+ const vals = props.data.flatMap(d => [d.planned, d.actual])
10
+ return Math.max(...vals, 1)
11
+ })
12
+
13
+ const chartWidth = 600
14
+ const chartHeight = 200
15
+ const padding = { top: 20, right: 20, bottom: 40, left: 50 }
16
+ const innerW = chartWidth - padding.left - padding.right
17
+ const innerH = chartHeight - padding.top - padding.bottom
18
+
19
+ function x(i: number): number {
20
+ return padding.left + (i / Math.max(props.data.length - 1, 1)) * innerW
21
+ }
22
+
23
+ function y(val: number): number {
24
+ return padding.top + innerH - (val / maxValue.value) * innerH
25
+ }
26
+
27
+ const plannedPath = computed(() => {
28
+ return props.data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i)},${y(d.planned)}`).join(' ')
29
+ })
30
+
31
+ const actualPath = computed(() => {
32
+ return props.data.map((d, i) => `${i === 0 ? 'M' : 'L'}${x(i)},${y(d.actual)}`).join(' ')
33
+ })
34
+
35
+ const yTicks = computed(() => {
36
+ const max = maxValue.value
37
+ const step = Math.ceil(max / 4)
38
+ return [0, step, step * 2, step * 3, step * 4].filter(v => v <= max + step)
39
+ })
40
+ </script>
41
+
42
+ <template>
43
+ <div class="velocity-chart">
44
+ <svg :viewBox="`0 0 ${chartWidth} ${chartHeight}`" preserveAspectRatio="xMidYMid meet">
45
+ <line v-for="tick in yTicks" :key="tick"
46
+ :x1="padding.left" :x2="chartWidth - padding.right"
47
+ :y1="y(tick)" :y2="y(tick)"
48
+ stroke="rgba(0,0,0,0.06)" stroke-dasharray="4,4" />
49
+ <text v-for="tick in yTicks" :key="'t'+tick"
50
+ :x="padding.left - 8" :y="y(tick) + 4"
51
+ text-anchor="end" font-size="10" fill="var(--text-muted)">{{ tick }}</text>
52
+ <text v-for="(d, i) in data" :key="'x'+i"
53
+ :x="x(i)" :y="chartHeight - 8"
54
+ text-anchor="middle" font-size="10" fill="var(--text-muted)">{{ d.label }}</text>
55
+ <path :d="plannedPath" fill="none" stroke="rgba(148,163,184,0.5)" stroke-width="2" stroke-dasharray="6,4" />
56
+ <path :d="actualPath" fill="none" stroke="#3B82F6" stroke-width="2.5" />
57
+ <circle v-for="(d, i) in data" :key="'d'+i"
58
+ :cx="x(i)" :cy="y(d.actual)" r="4" fill="#3B82F6" stroke="#fff" stroke-width="2" />
59
+ <circle v-for="(d, i) in data" :key="'p'+i"
60
+ :cx="x(i)" :cy="y(d.planned)" r="3" fill="rgba(148,163,184,0.6)" />
61
+ </svg>
62
+ <div class="chart-legend">
63
+ <span class="legend-item"><span class="legend-line legend--actual"></span> Actual</span>
64
+ <span class="legend-item"><span class="legend-line legend--planned"></span> Planned</span>
65
+ </div>
66
+ </div>
67
+ </template>
68
+
69
+ <style scoped>
70
+ .velocity-chart { width: 100%; }
71
+ .velocity-chart svg { width: 100%; height: auto; }
72
+ .chart-legend { display: flex; gap: 16px; justify-content: center; margin-top: 8px; font-size: 12px; color: var(--text-secondary); }
73
+ .legend-item { display: flex; align-items: center; gap: 4px; }
74
+ .legend-line { width: 16px; height: 2px; display: inline-block; }
75
+ .legend--actual { background: #3B82F6; }
76
+ .legend--planned { background: rgba(148,163,184,0.5); border-top: 2px dashed rgba(148,163,184,0.5); height: 0; }
77
+ </style>
@@ -4,7 +4,7 @@
4
4
 
5
5
  // ── Domain types ──
6
6
 
7
- export type StoryStatus = 'draft' | 'backlog' | 'ready' | 'in-progress' | 'review' | 'done'
7
+ export type StoryStatus = 'draft' | 'backlog' | 'ready' | 'in-progress' | 'review' | 'qa' | 'done'
8
8
  export type TaskStatus = 'todo' | 'in-progress' | 'done'
9
9
  export type Priority = 'low' | 'medium' | 'high' | 'critical'
10
10
  export type EpicStatus = 'active' | 'completed' | 'archived'
@@ -31,6 +31,10 @@ export interface PmStory {
31
31
  priority: Priority
32
32
  area: string
33
33
  storyPoints: number | null
34
+ startDate: string | null
35
+ dueDate: string | null
36
+ figmaUrl: string | null
37
+ relatedPrs: Array<{ prNumber: number; prUrl: string; prTitle: string; status: string }>
34
38
  sortOrder: number
35
39
  createdAt: string
36
40
  updatedAt: string
@@ -43,6 +47,8 @@ export interface PmTask {
43
47
  assignee: string | null
44
48
  status: TaskStatus
45
49
  description: string | null
50
+ storyPoints: number | null
51
+ dueDate: string | null
46
52
  sortOrder: number
47
53
  createdAt: string
48
54
  updatedAt: string
@@ -77,6 +83,10 @@ export function mapStory(r: PmStoryRow): PmStory {
77
83
  priority: (r.priority ?? 'medium') as Priority,
78
84
  area: r.area ?? 'FE',
79
85
  storyPoints: r.story_points,
86
+ startDate: r.start_date ?? null,
87
+ dueDate: r.due_date ?? null,
88
+ figmaUrl: r.figma_url ?? null,
89
+ relatedPrs: r.related_prs ? JSON.parse(r.related_prs) : [],
80
90
  sortOrder: r.sort_order,
81
91
  createdAt: r.created_at,
82
92
  updatedAt: r.updated_at,
@@ -91,6 +101,8 @@ export function mapTask(r: PmTaskRow): PmTask {
91
101
  assignee: r.assignee,
92
102
  status: r.status as TaskStatus,
93
103
  description: r.description,
104
+ storyPoints: r.story_points ?? null,
105
+ dueDate: r.due_date ?? null,
94
106
  sortOrder: r.sort_order,
95
107
  createdAt: r.created_at,
96
108
  updatedAt: r.updated_at,
@@ -99,7 +111,7 @@ export function mapTask(r: PmTaskRow): PmTask {
99
111
 
100
112
  // ── Status constants ──
101
113
 
102
- export const STORY_STATUSES: StoryStatus[] = ['draft', 'backlog', 'ready', 'in-progress', 'review', 'done']
114
+ export const STORY_STATUSES: StoryStatus[] = ['draft', 'backlog', 'ready', 'in-progress', 'review', 'qa', 'done']
103
115
  export const TASK_STATUSES: TaskStatus[] = ['todo', 'in-progress', 'done']
104
116
  export const PRIORITIES: Priority[] = ['low', 'medium', 'high', 'critical']
105
117
  export const AREAS = ['FE', 'BE', 'Design', 'Data', 'Infra', 'PO'] as const
@@ -111,6 +123,7 @@ export const STORY_STATUS_LABELS: Record<StoryStatus, string> = {
111
123
  'ready': 'Ready',
112
124
  'in-progress': 'In Progress',
113
125
  'review': 'Review',
126
+ 'qa': 'QA',
114
127
  'done': 'Done',
115
128
  }
116
129
 
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Dashboard composable — aggregates data from multiple dashboard APIs.
3
+ *
4
+ * In static mode, returns empty state gracefully.
5
+ */
6
+ import { ref } from 'vue'
7
+ import { apiGet, isStaticMode } from '@/api/client'
8
+
9
+ export interface UnreadMemo {
10
+ id: number
11
+ content: string
12
+ memoType: string
13
+ createdBy: string
14
+ createdAt: string
15
+ reviewRequired: number
16
+ pageId: string
17
+ replyCount: number
18
+ title: string | null
19
+ supersedesId: number | null
20
+ }
21
+
22
+ export interface SprintProgress {
23
+ sprint: string
24
+ total: number
25
+ done: number
26
+ progressPercent: number
27
+ byStatus: Record<string, number>
28
+ }
29
+
30
+ export interface StandupStatus {
31
+ date: string
32
+ written: string[]
33
+ count: number
34
+ }
35
+
36
+ export interface MyRequest {
37
+ id: number
38
+ title: string | null
39
+ content: string
40
+ memoType: string
41
+ assignedTo: string | null
42
+ status: string
43
+ createdAt: string
44
+ supersedesId: number | null
45
+ }
46
+
47
+ export interface Decision {
48
+ id: number
49
+ title: string | null
50
+ content: string
51
+ createdBy: string
52
+ assignedTo: string | null
53
+ createdAt: string
54
+ supersedesId: number | null
55
+ }
56
+
57
+ export interface NudgeLogItem {
58
+ id: number
59
+ ruleId: string
60
+ title: string
61
+ body: string
62
+ createdAt: string
63
+ }
64
+
65
+ export interface TeamInitiative {
66
+ id: number
67
+ title: string | null
68
+ content: string
69
+ memoType: string
70
+ createdBy: string
71
+ createdAt: string
72
+ }
73
+
74
+ export function useDashboard() {
75
+ const unreadMemos = ref<UnreadMemo[]>([])
76
+ const pendingReviews = ref<UnreadMemo[]>([])
77
+ const myRequests = ref<MyRequest[]>([])
78
+ const activeDecisions = ref<Decision[]>([])
79
+ const sprintProgress = ref<SprintProgress | null>(null)
80
+ const mySprintProgress = ref<SprintProgress | null>(null)
81
+ const standupStatus = ref<StandupStatus | null>(null)
82
+ const loading = ref(false)
83
+ const errors = ref<string[]>([])
84
+ const nudgeLog = ref<NudgeLogItem[]>([])
85
+ const teamInitiatives = ref<TeamInitiative[]>([])
86
+
87
+ function todayStr(): string {
88
+ return new Date().toISOString().split('T')[0]
89
+ }
90
+
91
+ async function loadAll(sprint: string, userName?: string) {
92
+ if (isStaticMode()) { loading.value = false; return }
93
+ loading.value = true
94
+ errors.value = []
95
+
96
+ const fetches = [
97
+ apiGet<{ unreadMemos: Array<Record<string, unknown>> }>('/api/v2/dashboard/unread-memos'),
98
+ apiGet<{ unreadMemos: Array<Record<string, unknown>> }>('/api/v2/dashboard/unread-memos', { review_required: '1' }),
99
+ apiGet<{ sprint: string; total: number; done: number; progressPercent: number; byStatus: Record<string, number> }>('/api/v2/dashboard/sprint-progress', { sprint }),
100
+ apiGet<{ date: string; written: string[]; count: number }>('/api/v2/dashboard/standup-status', { sprint, date: todayStr() }),
101
+ apiGet<{ myRequests: Array<Record<string, unknown>> }>('/api/v2/dashboard/my-requests'),
102
+ apiGet<{ decisions: Array<Record<string, unknown>> }>('/api/v2/dashboard/active-decisions'),
103
+ ]
104
+
105
+ if (userName) {
106
+ fetches.push(
107
+ apiGet<SprintProgress>('/api/v2/dashboard/sprint-progress', { sprint, user: userName }),
108
+ )
109
+ }
110
+
111
+ const results = await Promise.all(fetches)
112
+
113
+ if (results[0].error) errors.value.push(results[0].error)
114
+ else if (results[0].data) unreadMemos.value = ((results[0].data as any).unreadMemos ?? []).map(mapMemo)
115
+
116
+ if (results[1].error) errors.value.push(results[1].error)
117
+ else if (results[1].data) pendingReviews.value = ((results[1].data as any).unreadMemos ?? []).map(mapMemo)
118
+
119
+ if (results[2].error) errors.value.push(results[2].error)
120
+ else if (results[2].data) sprintProgress.value = results[2].data as SprintProgress
121
+
122
+ if (results[3].error) errors.value.push(results[3].error)
123
+ else if (results[3].data) standupStatus.value = results[3].data as StandupStatus
124
+
125
+ if (results[4].error) errors.value.push(results[4].error)
126
+ else if (results[4].data) myRequests.value = ((results[4].data as any).myRequests ?? []).map(mapRequest)
127
+
128
+ if (results[5].error) errors.value.push(results[5].error)
129
+ else if (results[5].data) activeDecisions.value = ((results[5].data as any).decisions ?? []).map(mapDecision)
130
+
131
+ if (userName && results[6]) {
132
+ if (results[6].error) errors.value.push(results[6].error)
133
+ else if (results[6].data) mySprintProgress.value = results[6].data as SprintProgress
134
+ }
135
+
136
+ loading.value = false
137
+ }
138
+
139
+ async function loadNudgeLog() {
140
+ if (isStaticMode()) return
141
+ const { data } = await apiGet<{ nudges: Array<Record<string, unknown>> }>(
142
+ '/api/v2/dashboard/nudge-log', { limit: '10' },
143
+ )
144
+ if (data?.nudges) {
145
+ nudgeLog.value = (data.nudges as Array<Record<string, unknown>>).map(r => ({
146
+ id: r.id as number,
147
+ ruleId: (r.rule_id as string) ?? '',
148
+ title: (r.title as string) ?? '',
149
+ body: (r.body as string) ?? '',
150
+ createdAt: (r.created_at as string) ?? '',
151
+ }))
152
+ }
153
+ }
154
+
155
+ async function loadTeamInitiatives() {
156
+ if (isStaticMode()) return
157
+ const { data } = await apiGet<{ initiatives: Array<Record<string, unknown>> }>(
158
+ '/api/v2/initiatives', { limit: '20' },
159
+ )
160
+ if (data?.initiatives) {
161
+ teamInitiatives.value = (data.initiatives as Array<Record<string, unknown>>).map(r => ({
162
+ id: r.id as number,
163
+ title: (r.title as string) ?? null,
164
+ content: (r.content as string) ?? '',
165
+ memoType: (r.status as string) ?? 'pending',
166
+ createdBy: (r.author as string) ?? '',
167
+ createdAt: (r.created_at as string) ?? '',
168
+ }))
169
+ } else {
170
+ // Fallback: memo-based (when initiatives table is unavailable)
171
+ const { data: memoData } = await apiGet<{ memos: Array<Record<string, unknown>> }>(
172
+ '/api/v2/memos/all', { limit: '10', status: 'open' },
173
+ )
174
+ if (memoData?.memos) {
175
+ teamInitiatives.value = (memoData.memos as Array<Record<string, unknown>>)
176
+ .filter(r => r.memo_type === 'feature_request')
177
+ .map(r => ({
178
+ id: r.id as number,
179
+ title: (r.title as string) ?? null,
180
+ content: (r.content as string) ?? '',
181
+ memoType: (r.memo_type as string) ?? '',
182
+ createdBy: (r.created_by as string) ?? '',
183
+ createdAt: (r.created_at as string) ?? '',
184
+ }))
185
+ }
186
+ }
187
+ }
188
+
189
+ return {
190
+ unreadMemos, pendingReviews, myRequests, activeDecisions,
191
+ sprintProgress, mySprintProgress, standupStatus, nudgeLog, teamInitiatives,
192
+ loading, errors, loadAll, loadNudgeLog, loadTeamInitiatives,
193
+ }
194
+ }
195
+
196
+ function mapMemo(r: Record<string, unknown>): UnreadMemo {
197
+ return {
198
+ id: r.id as number, content: (r.content as string) ?? '', memoType: (r.memo_type as string) ?? 'memo',
199
+ createdBy: (r.created_by as string) ?? '', createdAt: (r.created_at as string) ?? '',
200
+ reviewRequired: (r.review_required as number) ?? 0, pageId: (r.page_id as string) ?? '',
201
+ replyCount: (r.reply_count as number) ?? 0, title: (r.title as string) ?? null,
202
+ supersedesId: (r.supersedes_id as number) ?? null,
203
+ }
204
+ }
205
+
206
+ function mapRequest(r: Record<string, unknown>): MyRequest {
207
+ return {
208
+ id: r.id as number, title: (r.title as string) ?? null, content: (r.content as string) ?? '',
209
+ memoType: (r.memo_type as string) ?? '', assignedTo: (r.assigned_to as string) ?? null,
210
+ status: (r.status as string) ?? 'open', createdAt: (r.created_at as string) ?? '',
211
+ supersedesId: (r.supersedes_id as number) ?? null,
212
+ }
213
+ }
214
+
215
+ function mapDecision(r: Record<string, unknown>): Decision {
216
+ return {
217
+ id: r.id as number, title: (r.title as string) ?? null, content: (r.content as string) ?? '',
218
+ createdBy: (r.created_by as string) ?? '', assignedTo: (r.assigned_to as string) ?? null,
219
+ createdAt: (r.created_at as string) ?? '', supersedesId: (r.supersedes_id as number) ?? null,
220
+ }
221
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Media query composable — generic reactive media query wrapper.
3
+ *
4
+ * Usage: const isMobile = useMediaQuery('(max-width: 767px)')
5
+ */
6
+
7
+ import { ref, onMounted, onUnmounted, type Ref } from 'vue'
8
+
9
+ export function useMediaQuery(query: string): Ref<boolean> {
10
+ const matches = ref(false)
11
+ let mql: MediaQueryList | null = null
12
+
13
+ function update(e: MediaQueryListEvent | MediaQueryList) {
14
+ matches.value = e.matches
15
+ }
16
+
17
+ onMounted(() => {
18
+ mql = window.matchMedia(query)
19
+ matches.value = mql.matches
20
+ mql.addEventListener('change', update)
21
+ })
22
+
23
+ onUnmounted(() => {
24
+ mql?.removeEventListener('change', update)
25
+ })
26
+
27
+ return matches
28
+ }