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.
- package/bin/cli.mjs +204 -2
- package/lib/doctor.mjs +38 -1
- package/lib/hydrate.mjs +15 -0
- package/lib/scaffold.mjs +5 -0
- package/lib/setup-wizard.mjs +35 -2
- package/package.json +1 -1
- package/scaffold/.context/project.yaml.example +19 -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,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">🔔</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
|
+
}
|