popilot 0.6.0 → 0.8.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 (165) 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-notification-server/package.json +18 -0
  9. package/scaffold/mcp-notification-server/src/index.ts +275 -0
  10. package/scaffold/mcp-notification-server/src/turso-client.ts +142 -0
  11. package/scaffold/mcp-notification-server/tsconfig.json +14 -0
  12. package/scaffold/mcp-pm/package.json +19 -0
  13. package/scaffold/mcp-pm/src/api-client.ts +69 -0
  14. package/scaffold/mcp-pm/src/index.ts +660 -0
  15. package/scaffold/mcp-pm/tsconfig.json +14 -0
  16. package/scaffold/pm-api/package.json +21 -0
  17. package/scaffold/pm-api/sql/001-memo-v2.sql +49 -0
  18. package/scaffold/pm-api/sql/002-notifications.sql +18 -0
  19. package/scaffold/pm-api/sql/003-content.sql +66 -0
  20. package/scaffold/pm-api/sql/004-agent-events.sql +21 -0
  21. package/scaffold/pm-api/sql/005-epic-sprint-decoupling.sql +6 -0
  22. package/scaffold/pm-api/sql/schema-core.sql +331 -0
  23. package/scaffold/pm-api/sql/schema-docs.sql +25 -0
  24. package/scaffold/pm-api/sql/schema-meetings.sql +17 -0
  25. package/scaffold/pm-api/sql/schema-rewards.sql +16 -0
  26. package/scaffold/pm-api/src/auth.ts +28 -0
  27. package/scaffold/pm-api/src/blockchain/adapter.ts +20 -0
  28. package/scaffold/pm-api/src/blockchain/tron.ts +62 -0
  29. package/scaffold/pm-api/src/db/adapter.ts +36 -0
  30. package/scaffold/pm-api/src/db/turso.ts +147 -0
  31. package/scaffold/pm-api/src/index.ts +114 -0
  32. package/scaffold/pm-api/src/mcp-tools/dashboard.ts +40 -0
  33. package/scaffold/pm-api/src/mcp-tools/epic.ts +67 -0
  34. package/scaffold/pm-api/src/mcp-tools/event.ts +89 -0
  35. package/scaffold/pm-api/src/mcp-tools/index.ts +11 -0
  36. package/scaffold/pm-api/src/mcp-tools/initiative.ts +51 -0
  37. package/scaffold/pm-api/src/mcp-tools/memo.ts +164 -0
  38. package/scaffold/pm-api/src/mcp-tools/notification.ts +37 -0
  39. package/scaffold/pm-api/src/mcp-tools/retro.ts +183 -0
  40. package/scaffold/pm-api/src/mcp-tools/sprint.ts +204 -0
  41. package/scaffold/pm-api/src/mcp-tools/standup.ts +136 -0
  42. package/scaffold/pm-api/src/mcp-tools/story.ts +230 -0
  43. package/scaffold/pm-api/src/mcp-tools/task.ts +187 -0
  44. package/scaffold/pm-api/src/mcp-tools/utils.ts +83 -0
  45. package/scaffold/pm-api/src/mcp.ts +871 -0
  46. package/scaffold/pm-api/src/nudge.ts +283 -0
  47. package/scaffold/pm-api/src/routes/auth.ts +32 -0
  48. package/scaffold/pm-api/src/routes/v2-activity.ts +27 -0
  49. package/scaffold/pm-api/src/routes/v2-admin.ts +165 -0
  50. package/scaffold/pm-api/src/routes/v2-dashboard.ts +189 -0
  51. package/scaffold/pm-api/src/routes/v2-docs.ts +34 -0
  52. package/scaffold/pm-api/src/routes/v2-initiatives.ts +118 -0
  53. package/scaffold/pm-api/src/routes/v2-kickoff.ts +265 -0
  54. package/scaffold/pm-api/src/routes/v2-meetings.ts +324 -0
  55. package/scaffold/pm-api/src/routes/v2-memos.ts +257 -0
  56. package/scaffold/pm-api/src/routes/v2-nav.ts +260 -0
  57. package/scaffold/pm-api/src/routes/v2-notifications.ts +79 -0
  58. package/scaffold/pm-api/src/routes/v2-page-content.ts +35 -0
  59. package/scaffold/pm-api/src/routes/v2-pm.ts +380 -0
  60. package/scaffold/pm-api/src/routes/v2-policy.ts +58 -0
  61. package/scaffold/pm-api/src/routes/v2-retro.ts +221 -0
  62. package/scaffold/pm-api/src/routes/v2-rewards.ts +132 -0
  63. package/scaffold/pm-api/src/routes/v2-scenarios.ts +48 -0
  64. package/scaffold/pm-api/src/routes/v2-search.ts +32 -0
  65. package/scaffold/pm-api/src/routes/v2-standup.ts +127 -0
  66. package/scaffold/pm-api/src/routes/v2-user.ts +38 -0
  67. package/scaffold/pm-api/src/types.ts +11 -0
  68. package/scaffold/pm-api/src/utils/activity.ts +22 -0
  69. package/scaffold/pm-api/src/utils/admin.ts +9 -0
  70. package/scaffold/pm-api/src/utils/agent-notify.ts +62 -0
  71. package/scaffold/pm-api/src/utils/assignee.ts +69 -0
  72. package/scaffold/pm-api/src/utils/db.ts +45 -0
  73. package/scaffold/pm-api/src/utils/initiative.ts +23 -0
  74. package/scaffold/pm-api/src/utils/retro-link.ts +32 -0
  75. package/scaffold/pm-api/src/utils/sprint-lifecycle.ts +96 -0
  76. package/scaffold/pm-api/tsconfig.json +15 -0
  77. package/scaffold/pm-api/wrangler.toml.hbs +11 -0
  78. package/scaffold/spec-site/package-lock.json +892 -0
  79. package/scaffold/spec-site/package.json +15 -1
  80. package/scaffold/spec-site/src/api/types.ts +6 -0
  81. package/scaffold/spec-site/src/components/AppHeader.vue +429 -55
  82. package/scaffold/spec-site/src/components/AuthGate.vue +117 -0
  83. package/scaffold/spec-site/src/components/BurndownChart.vue +78 -0
  84. package/scaffold/spec-site/src/components/DocComments.vue +137 -0
  85. package/scaffold/spec-site/src/components/DocEditor.vue +118 -0
  86. package/scaffold/spec-site/src/components/DocExportBar.vue +110 -0
  87. package/scaffold/spec-site/src/components/DocsSidebar.vue +309 -0
  88. package/scaffold/spec-site/src/components/EmptyState.vue +30 -0
  89. package/scaffold/spec-site/src/components/ErrorBanner.vue +38 -0
  90. package/scaffold/spec-site/src/components/Icon.vue +58 -0
  91. package/scaffold/spec-site/src/components/MemberSelect.vue +48 -0
  92. package/scaffold/spec-site/src/components/MemoChecklist.vue +88 -0
  93. package/scaffold/spec-site/src/components/MemoGraph.vue +75 -0
  94. package/scaffold/spec-site/src/components/MemoItem.vue +353 -0
  95. package/scaffold/spec-site/src/components/MemoRelations.vue +101 -0
  96. package/scaffold/spec-site/src/components/MemoTimeline.vue +53 -0
  97. package/scaffold/spec-site/src/components/MentionInput.vue +174 -0
  98. package/scaffold/spec-site/src/components/NotificationDropdown.vue +116 -0
  99. package/scaffold/spec-site/src/components/PriorityBadge.vue +23 -0
  100. package/scaffold/spec-site/src/components/SearchModal.vue +102 -0
  101. package/scaffold/spec-site/src/components/SlashCommand.ts +123 -0
  102. package/scaffold/spec-site/src/components/StateDisplay.vue +54 -0
  103. package/scaffold/spec-site/src/components/TreeNode.vue +82 -0
  104. package/scaffold/spec-site/src/components/UserAvatar.vue +24 -0
  105. package/scaffold/spec-site/src/components/VelocityChart.vue +77 -0
  106. package/scaffold/spec-site/src/composables/navTypes.ts +3 -0
  107. package/scaffold/spec-site/src/composables/pmTypes.ts +15 -2
  108. package/scaffold/spec-site/src/composables/useBottomSheet.ts +103 -0
  109. package/scaffold/spec-site/src/composables/useDashboard.ts +221 -0
  110. package/scaffold/spec-site/src/composables/useMediaQuery.ts +28 -0
  111. package/scaffold/spec-site/src/composables/useMemo.ts +39 -0
  112. package/scaffold/spec-site/src/composables/useNotification.ts +200 -0
  113. package/scaffold/spec-site/src/composables/usePmStore.ts +48 -1
  114. package/scaffold/spec-site/src/composables/useRetro.ts +6 -0
  115. package/scaffold/spec-site/src/composables/useStandup.ts +201 -0
  116. package/scaffold/spec-site/src/composables/useTheme.ts +37 -0
  117. package/scaffold/spec-site/src/composables/useTurso.ts +17 -0
  118. package/scaffold/spec-site/src/composables/useUser.ts +19 -1
  119. package/scaffold/spec-site/src/composables/useViewport.ts +26 -0
  120. package/scaffold/spec-site/src/features.ts +108 -0
  121. package/scaffold/spec-site/src/mockup/ComponentPalette.vue +61 -0
  122. package/scaffold/spec-site/src/mockup/MockupCanvas.vue +459 -0
  123. package/scaffold/spec-site/src/mockup/PropertyPanel.vue +217 -0
  124. package/scaffold/spec-site/src/mockup/componentCatalog.ts +68 -0
  125. package/scaffold/spec-site/src/mockup/useScenarios.ts +67 -0
  126. package/scaffold/spec-site/src/pages/AdminPage.vue +299 -0
  127. package/scaffold/spec-site/src/pages/DashboardPage.vue +650 -0
  128. package/scaffold/spec-site/src/pages/DocsEditor.vue +119 -0
  129. package/scaffold/spec-site/src/pages/DocsHub.vue +157 -0
  130. package/scaffold/spec-site/src/pages/DocsPage.vue +444 -0
  131. package/scaffold/spec-site/src/pages/InboxPage.vue +156 -0
  132. package/scaffold/spec-site/src/pages/MeetingsPage.vue +294 -0
  133. package/scaffold/spec-site/src/pages/MemosPage.vue +857 -0
  134. package/scaffold/spec-site/src/pages/MockupEditorPage.vue +611 -0
  135. package/scaffold/spec-site/src/pages/MockupListPage.vue +121 -0
  136. package/scaffold/spec-site/src/pages/MockupViewerPage.vue +199 -0
  137. package/scaffold/spec-site/src/pages/MyPage.vue +343 -0
  138. package/scaffold/spec-site/src/pages/NotificationSettingsPage.vue +59 -0
  139. package/scaffold/spec-site/src/pages/RewardsPage.vue +266 -0
  140. package/scaffold/spec-site/src/pages/SprintAdmin.vue +521 -0
  141. package/scaffold/spec-site/src/pages/SprintTimeline.vue +159 -0
  142. package/scaffold/spec-site/src/pages/board/BoardAdmin.vue +422 -0
  143. package/scaffold/spec-site/src/pages/board/BoardEpicSection.vue +54 -0
  144. package/scaffold/spec-site/src/pages/board/BoardPage.vue +884 -0
  145. package/scaffold/spec-site/src/pages/board/BoardStoryCard.vue +67 -0
  146. package/scaffold/spec-site/src/pages/board/BoardTaskItem.vue +52 -0
  147. package/scaffold/spec-site/src/pages/board/KanbanBoard.vue +93 -0
  148. package/scaffold/spec-site/src/pages/board/MyTasksPage.vue +202 -0
  149. package/scaffold/spec-site/src/pages/board/SprintClose.vue +167 -0
  150. package/scaffold/spec-site/src/pages/board/SprintColumn.vue +49 -0
  151. package/scaffold/spec-site/src/pages/board/SprintKickoff.vue +389 -0
  152. package/scaffold/spec-site/src/pages/board/StatusBadge.vue +52 -0
  153. package/scaffold/spec-site/src/pages/board/StoryDetailPanel.vue +495 -0
  154. package/scaffold/spec-site/src/pages/board/TaskCard.vue +42 -0
  155. package/scaffold/spec-site/src/pages/retro/RetroCard.vue +36 -2
  156. package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +82 -66
  157. package/scaffold/spec-site/src/pages/retro/RetroPage.vue +47 -18
  158. package/scaffold/spec-site/src/pages/standup/StandupEntryCard.vue +551 -0
  159. package/scaffold/spec-site/src/pages/standup/StandupForm.vue +68 -0
  160. package/scaffold/spec-site/src/pages/standup/StandupList.vue +71 -0
  161. package/scaffold/spec-site/src/pages/standup/StandupPage.vue +225 -0
  162. package/scaffold/spec-site/src/router.ts +141 -0
  163. package/scaffold/spec-site/src/styles/buttons.css +124 -0
  164. package/scaffold/spec-site/src/utils/parseMentions.ts +56 -0
  165. package/scaffold/spec-site/src/utils/timezone.ts +18 -0
@@ -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,103 @@
1
+ import { ref, computed, type Ref } from 'vue'
2
+
3
+ export type SheetState = 'peek' | 'half' | 'full'
4
+
5
+ const PEEK_HEIGHT = 48
6
+ const HALF_RATIO = 0.5
7
+ const FULL_RATIO = 0.85
8
+ const VELOCITY_THRESHOLD = 0.5 // px/ms
9
+
10
+ export function useBottomSheet() {
11
+ const state: Ref<SheetState> = ref('peek')
12
+ const dragging = ref(false)
13
+ const dragOffset = ref(0)
14
+
15
+ let startY = 0
16
+ let startTime = 0
17
+ let startTranslateY = 0
18
+
19
+ function getTranslateY(s: SheetState): number {
20
+ const vh = window.innerHeight
21
+ const sheetHeight = vh * FULL_RATIO
22
+ switch (s) {
23
+ case 'peek': return sheetHeight - PEEK_HEIGHT
24
+ case 'half': return sheetHeight - vh * HALF_RATIO
25
+ case 'full': return 0
26
+ }
27
+ }
28
+
29
+ const translateY = computed(() => {
30
+ const base = getTranslateY(state.value)
31
+ return dragging.value ? base + dragOffset.value : base
32
+ })
33
+
34
+ function tap() {
35
+ const order: SheetState[] = ['peek', 'half', 'full']
36
+ const idx = order.indexOf(state.value)
37
+ state.value = order[(idx + 1) % order.length]
38
+ }
39
+
40
+ function onPointerDown(e: PointerEvent) {
41
+ dragging.value = true
42
+ dragOffset.value = 0
43
+ startY = e.clientY
44
+ startTime = Date.now()
45
+ startTranslateY = getTranslateY(state.value)
46
+ ;(e.target as HTMLElement).setPointerCapture(e.pointerId)
47
+ }
48
+
49
+ function onPointerMove(e: PointerEvent) {
50
+ if (!dragging.value) return
51
+ const dy = e.clientY - startY
52
+ const sheetHeight = window.innerHeight * FULL_RATIO
53
+ dragOffset.value = Math.max(-startTranslateY, Math.min(dy, sheetHeight - PEEK_HEIGHT - startTranslateY))
54
+ }
55
+
56
+ function onPointerUp(e: PointerEvent) {
57
+ if (!dragging.value) return
58
+ dragging.value = false
59
+
60
+ const dy = e.clientY - startY
61
+ const dt = Date.now() - startTime
62
+ const velocity = dy / Math.max(dt, 1)
63
+
64
+ if (Math.abs(velocity) > VELOCITY_THRESHOLD) {
65
+ if (velocity > 0) {
66
+ state.value = state.value === 'full' ? 'half' : 'peek'
67
+ } else {
68
+ state.value = state.value === 'peek' ? 'half' : 'full'
69
+ }
70
+ } else {
71
+ const currentTranslate = startTranslateY + dragOffset.value
72
+ const sheetHeight = window.innerHeight * FULL_RATIO
73
+ const peekT = sheetHeight - PEEK_HEIGHT
74
+ const halfT = sheetHeight - window.innerHeight * HALF_RATIO
75
+ const fullT = 0
76
+
77
+ const distances = [
78
+ { state: 'peek' as SheetState, d: Math.abs(currentTranslate - peekT) },
79
+ { state: 'half' as SheetState, d: Math.abs(currentTranslate - halfT) },
80
+ { state: 'full' as SheetState, d: Math.abs(currentTranslate - fullT) },
81
+ ]
82
+ distances.sort((a, b) => a.d - b.d)
83
+ state.value = distances[0].state
84
+ }
85
+
86
+ dragOffset.value = 0
87
+ }
88
+
89
+ function setState(s: SheetState) {
90
+ state.value = s
91
+ }
92
+
93
+ return {
94
+ state,
95
+ dragging,
96
+ translateY,
97
+ tap,
98
+ setState,
99
+ onPointerDown,
100
+ onPointerMove,
101
+ onPointerUp,
102
+ }
103
+ }
@@ -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
+ }
@@ -8,11 +8,50 @@
8
8
  import { ref, computed } from 'vue'
9
9
  import { isStaticMode } from '@/api/client'
10
10
 
11
+ // ── Tier 1 (simple, localStorage-backed) ──
11
12
  export interface MemoItem {
12
13
  id: number
13
14
  text: string
14
15
  author: string
15
16
  ts: number
17
+ // Tier 2 extended fields (API-backed)
18
+ page_id?: string
19
+ content?: string
20
+ memo_type?: MemoType
21
+ status?: 'open' | 'resolved'
22
+ created_by?: string
23
+ assigned_to?: string | null
24
+ review_required?: number
25
+ title?: string | null
26
+ channel?: string
27
+ resolved_by?: string | null
28
+ resolved_at?: string | null
29
+ created_at?: string
30
+ updated_at?: string
31
+ checklist?: string | null
32
+ reply_count?: number
33
+ has_relations?: number
34
+ }
35
+
36
+ export type MemoType = 'memo' | 'decision' | 'request' | 'backlog' | 'blocker' | 'question' | 'announcement'
37
+
38
+ export const MEMO_TYPES: { value: MemoType; label: string; icon: string; color: string }[] = [
39
+ { value: 'memo', label: 'Memo', icon: '📝', color: '#3b82f6' },
40
+ { value: 'decision', label: 'Decision', icon: '⚡', color: '#8b5cf6' },
41
+ { value: 'request', label: 'Request', icon: '📋', color: '#f59e0b' },
42
+ { value: 'backlog', label: 'Backlog', icon: '💡', color: '#22c55e' },
43
+ { value: 'blocker', label: 'Blocker', icon: '🚧', color: '#ef4444' },
44
+ { value: 'question', label: 'Question', icon: '❓', color: '#06b6d4' },
45
+ { value: 'announcement', label: 'Announcement', icon: '📢', color: '#6366f1' },
46
+ ]
47
+
48
+ export interface ReplyItem {
49
+ id: number
50
+ memo_id: number
51
+ content: string
52
+ created_by: string
53
+ review_type?: string
54
+ created_at: string
16
55
  }
17
56
 
18
57
  export function useMemo(pageId: string) {
@@ -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
+ }