popilot 0.3.0 → 0.5.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 (81) hide show
  1. package/lib/hydrate.mjs +6 -1
  2. package/lib/setup-wizard.mjs +29 -3
  3. package/package.json +1 -1
  4. package/scaffold/.claude/commands/_domain.md.hbs +33 -0
  5. package/scaffold/.claude/commands/analytics.md.hbs +55 -0
  6. package/scaffold/.claude/commands/daily.md.hbs +301 -0
  7. package/scaffold/.claude/commands/dev.md.hbs +62 -0
  8. package/scaffold/.claude/commands/gtm.md +82 -0
  9. package/scaffold/.claude/commands/handoff.md +259 -0
  10. package/scaffold/.claude/commands/market.md +120 -0
  11. package/scaffold/.claude/commands/metrics.md +123 -0
  12. package/scaffold/.claude/commands/oscar-loop.md +436 -0
  13. package/scaffold/.claude/commands/party.md +85 -0
  14. package/scaffold/.claude/commands/plan.md +43 -0
  15. package/scaffold/.claude/commands/poc.md +69 -0
  16. package/scaffold/.claude/commands/research.md +203 -0
  17. package/scaffold/.claude/commands/retro.md +68 -0
  18. package/scaffold/.claude/commands/save.md +440 -0
  19. package/scaffold/.claude/commands/sessions.md +139 -0
  20. package/scaffold/.claude/commands/sprint.md +106 -0
  21. package/scaffold/.claude/commands/start.md +396 -0
  22. package/scaffold/.claude/commands/strategy.md +41 -0
  23. package/scaffold/.claude/commands/task.md +220 -0
  24. package/scaffold/.claude/commands/tracking.md +116 -0
  25. package/scaffold/.claude/commands/validate.md +58 -0
  26. package/scaffold/.context/WORKFLOW.md.hbs +58 -26
  27. package/scaffold/.context/agents/planner.md.hbs +35 -7
  28. package/scaffold/.context/integrations/_registry.yaml +6 -0
  29. package/scaffold/.context/integrations/providers/sqlite_lambda.yaml +24 -0
  30. package/scaffold/.context/integrations/providers/supabase.yaml +34 -0
  31. package/scaffold/.context/integrations/providers/turso_cf.yaml +34 -0
  32. package/scaffold/.context/poc/_skills/build.md +79 -0
  33. package/scaffold/.context/poc/_skills/scope.md +50 -0
  34. package/scaffold/.context/poc/_skills/spec.md +80 -0
  35. package/scaffold/.context/poc/_skills/verify.md +60 -0
  36. package/scaffold/CLAUDE.md.hbs +210 -0
  37. package/scaffold/spec-site/.env.example +11 -0
  38. package/scaffold/spec-site/index.html +2 -2
  39. package/scaffold/spec-site/sql/schema.sql +224 -0
  40. package/scaffold/spec-site/src/api/client.ts +131 -0
  41. package/scaffold/spec-site/src/api/types.ts +177 -0
  42. package/scaffold/spec-site/src/components/Accordion.vue +1 -1
  43. package/scaffold/spec-site/src/components/AppHeader.vue +5 -4
  44. package/scaffold/spec-site/src/components/CoachingCard.vue +1 -1
  45. package/scaffold/spec-site/src/components/ScenarioSwitcher.vue +1 -1
  46. package/scaffold/spec-site/src/composables/navTypes.ts +39 -0
  47. package/scaffold/spec-site/src/composables/pmTypes.ts +134 -0
  48. package/scaffold/spec-site/src/composables/useAuth.ts +139 -0
  49. package/scaffold/spec-site/src/composables/useMemo.ts +51 -40
  50. package/scaffold/spec-site/src/composables/useNavStore.ts +202 -0
  51. package/scaffold/spec-site/src/composables/usePageContent.ts +208 -0
  52. package/scaffold/spec-site/src/composables/usePmStore.ts +224 -0
  53. package/scaffold/spec-site/src/composables/useRetro.ts +181 -95
  54. package/scaffold/spec-site/src/composables/useScenarioStore.ts +74 -30
  55. package/scaffold/spec-site/src/composables/useUser.ts +12 -6
  56. package/scaffold/spec-site/src/data/navigation.ts +7 -42
  57. package/scaffold/spec-site/src/data/types.ts +13 -43
  58. package/scaffold/spec-site/src/main.ts +7 -0
  59. package/scaffold/spec-site/src/pages/PolicyDetail.vue +30 -11
  60. package/scaffold/spec-site/src/pages/PolicyIndex.vue +22 -7
  61. package/scaffold/spec-site/src/pages/retro/RetroActions.vue +3 -3
  62. package/scaffold/spec-site/src/pages/retro/RetroBoard.vue +2 -2
  63. package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +5 -7
  64. package/scaffold/spec-site/src/pages/retro/RetroPage.vue +2 -2
  65. package/scaffold/spec-site/src/pages/shared/NoContentPlaceholder.vue +2 -2
  66. package/scaffold/spec-site/src/pages/shared/PolicyFallback.vue +25 -13
  67. package/scaffold/spec-site/src/router.ts +11 -7
  68. package/scaffold/spec-site/src/styles/base.css +2 -2
  69. package/scaffold/spec-site/src/styles/split-pane.css +1 -1
  70. package/scaffold/spec-site/src/styles/variables.css +7 -7
  71. package/scaffold/spec-site/src/assets/icons/menu/ic_ads.svg +0 -10
  72. package/scaffold/spec-site/src/assets/icons/menu/ic_ads_on.svg +0 -10
  73. package/scaffold/spec-site/src/assets/icons/menu/ic_board.svg +0 -14
  74. package/scaffold/spec-site/src/assets/icons/menu/ic_board_on.svg +0 -14
  75. package/scaffold/spec-site/src/assets/icons/menu/ic_dashboard.svg +0 -21
  76. package/scaffold/spec-site/src/assets/icons/menu/ic_dashboard_on.svg +0 -21
  77. package/scaffold/spec-site/src/assets/icons/menu/ic_pricing.svg +0 -20
  78. package/scaffold/spec-site/src/assets/icons/menu/ic_pricing_on.svg +0 -20
  79. package/scaffold/spec-site/src/assets/icons/menu/ic_store.svg +0 -11
  80. package/scaffold/spec-site/src/assets/icons/menu/ic_store_on.svg +0 -11
  81. package/scaffold/spec-site/src/composables/useTurso.ts +0 -160
@@ -0,0 +1,134 @@
1
+ /**
2
+ * PM Store — Type definitions, DB row mappers, and constants
3
+ */
4
+
5
+ // ── Domain types ──
6
+
7
+ export type StoryStatus = 'draft' | 'backlog' | 'ready' | 'in-progress' | 'review' | 'done'
8
+ export type TaskStatus = 'todo' | 'in-progress' | 'done'
9
+ export type Priority = 'low' | 'medium' | 'high' | 'critical'
10
+ export type EpicStatus = 'active' | 'completed' | 'archived'
11
+
12
+ export interface PmEpic {
13
+ id: number
14
+ title: string
15
+ description: string | null
16
+ status: EpicStatus
17
+ owner: string | null
18
+ createdAt: string
19
+ updatedAt: string
20
+ }
21
+
22
+ export interface PmStory {
23
+ id: number
24
+ epicId: number | null
25
+ sprint: string
26
+ title: string
27
+ description: string | null
28
+ acceptanceCriteria: string | null
29
+ assignee: string | null
30
+ status: StoryStatus
31
+ priority: Priority
32
+ area: string
33
+ storyPoints: number | null
34
+ sortOrder: number
35
+ createdAt: string
36
+ updatedAt: string
37
+ }
38
+
39
+ export interface PmTask {
40
+ id: number
41
+ storyId: number
42
+ title: string
43
+ assignee: string | null
44
+ status: TaskStatus
45
+ description: string | null
46
+ sortOrder: number
47
+ createdAt: string
48
+ updatedAt: string
49
+ }
50
+
51
+ // ── Row mappers ──
52
+
53
+ import type { PmEpicRow, PmStoryRow, PmTaskRow } from '@/api/types'
54
+
55
+ export function mapEpic(r: PmEpicRow): PmEpic {
56
+ return {
57
+ id: r.id,
58
+ title: r.title,
59
+ description: r.description,
60
+ status: (r.status ?? 'active') as EpicStatus,
61
+ owner: r.owner,
62
+ createdAt: r.created_at,
63
+ updatedAt: r.updated_at,
64
+ }
65
+ }
66
+
67
+ export function mapStory(r: PmStoryRow): PmStory {
68
+ return {
69
+ id: r.id,
70
+ epicId: r.epic_id,
71
+ sprint: r.sprint ?? '',
72
+ title: r.title,
73
+ description: r.description,
74
+ acceptanceCriteria: r.acceptance_criteria,
75
+ assignee: r.assignee,
76
+ status: r.status as StoryStatus,
77
+ priority: (r.priority ?? 'medium') as Priority,
78
+ area: r.area ?? 'FE',
79
+ storyPoints: r.story_points,
80
+ sortOrder: r.sort_order,
81
+ createdAt: r.created_at,
82
+ updatedAt: r.updated_at,
83
+ }
84
+ }
85
+
86
+ export function mapTask(r: PmTaskRow): PmTask {
87
+ return {
88
+ id: r.id,
89
+ storyId: r.story_id,
90
+ title: r.title,
91
+ assignee: r.assignee,
92
+ status: r.status as TaskStatus,
93
+ description: r.description,
94
+ sortOrder: r.sort_order,
95
+ createdAt: r.created_at,
96
+ updatedAt: r.updated_at,
97
+ }
98
+ }
99
+
100
+ // ── Status constants ──
101
+
102
+ export const STORY_STATUSES: StoryStatus[] = ['draft', 'backlog', 'ready', 'in-progress', 'review', 'done']
103
+ export const TASK_STATUSES: TaskStatus[] = ['todo', 'in-progress', 'done']
104
+ export const PRIORITIES: Priority[] = ['low', 'medium', 'high', 'critical']
105
+ export const AREAS = ['FE', 'BE', 'Design', 'Data', 'Infra', 'PO'] as const
106
+ export const EPIC_STATUSES: EpicStatus[] = ['active', 'completed', 'archived']
107
+
108
+ export const STORY_STATUS_LABELS: Record<StoryStatus, string> = {
109
+ 'draft': 'Draft',
110
+ 'backlog': 'Backlog',
111
+ 'ready': 'Ready',
112
+ 'in-progress': 'In Progress',
113
+ 'review': 'Review',
114
+ 'done': 'Done',
115
+ }
116
+
117
+ export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
118
+ 'todo': 'To Do',
119
+ 'in-progress': 'In Progress',
120
+ 'done': 'Done',
121
+ }
122
+
123
+ export const PRIORITY_LABELS: Record<Priority, string> = {
124
+ 'low': 'Low',
125
+ 'medium': 'Medium',
126
+ 'high': 'High',
127
+ 'critical': 'Critical',
128
+ }
129
+
130
+ export const EPIC_STATUS_LABELS: Record<EpicStatus, string> = {
131
+ 'active': 'Active',
132
+ 'completed': 'Completed',
133
+ 'archived': 'Archived',
134
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Auth composable — Token-based authentication for spec-site.
3
+ *
4
+ * Supports:
5
+ * - URL token parameter (?token=...) with auto-cleanup
6
+ * - localStorage persistence
7
+ * - API verification with offline fallback
8
+ *
9
+ * In static mode, auth is disabled (always authenticated).
10
+ */
11
+
12
+ import { ref, readonly } from 'vue'
13
+ import { isStaticMode } from '@/api/client'
14
+ import { useUser } from './useUser'
15
+
16
+ const AUTH_STORAGE_KEY = 'spec-auth-token'
17
+ const AUTH_USER_KEY = 'spec-auth-user'
18
+
19
+ const isAuthenticated = ref(false)
20
+ const authUser = ref<string | null>(null)
21
+ const authLoading = ref(false)
22
+ const _token = ref<string | null>(null)
23
+
24
+ // Static mode: always authenticated
25
+ if (isStaticMode()) {
26
+ isAuthenticated.value = true
27
+ authUser.value = 'local'
28
+ } else {
29
+ // Restore from localStorage
30
+ const savedToken = localStorage.getItem(AUTH_STORAGE_KEY)
31
+ const savedUser = localStorage.getItem(AUTH_USER_KEY)
32
+ if (savedToken && savedUser) {
33
+ _token.value = savedToken
34
+ authUser.value = savedUser
35
+ isAuthenticated.value = true
36
+ const { setUser } = useUser()
37
+ setUser(savedUser)
38
+ }
39
+ }
40
+
41
+ async function verifyToken(token: string): Promise<string | null> {
42
+ if (isStaticMode()) return 'local'
43
+
44
+ const apiUrl = import.meta.env.VITE_API_URL as string
45
+ const savedToken = localStorage.getItem(AUTH_STORAGE_KEY)
46
+ const savedUser = localStorage.getItem(AUTH_USER_KEY)
47
+
48
+ try {
49
+ const resp = await fetch(`${apiUrl}/api/auth/verify`, {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({ token }),
53
+ signal: AbortSignal.timeout(5000),
54
+ })
55
+ if (resp.ok) {
56
+ const data = await resp.json()
57
+ if (data.userName) return String(data.userName)
58
+ }
59
+ } catch {
60
+ // API unreachable — use cached auth
61
+ if (savedToken === token && savedUser) {
62
+ return savedUser
63
+ }
64
+ }
65
+ return null
66
+ }
67
+
68
+ async function login(token: string): Promise<boolean> {
69
+ authLoading.value = true
70
+ try {
71
+ const userName = await verifyToken(token)
72
+ if (!userName) return false
73
+
74
+ _token.value = token
75
+ authUser.value = userName
76
+ isAuthenticated.value = true
77
+
78
+ localStorage.setItem(AUTH_STORAGE_KEY, token)
79
+ localStorage.setItem(AUTH_USER_KEY, userName)
80
+
81
+ const { setUser } = useUser()
82
+ setUser(userName)
83
+
84
+ // Log activity (non-critical)
85
+ try {
86
+ const { apiPost } = await import('@/api/client')
87
+ await apiPost('/api/v2/user/activity', { userName })
88
+ } catch { /* non-critical */ }
89
+
90
+ return true
91
+ } finally {
92
+ authLoading.value = false
93
+ }
94
+ }
95
+
96
+ function logout() {
97
+ _token.value = null
98
+ authUser.value = null
99
+ isAuthenticated.value = false
100
+ localStorage.removeItem(AUTH_STORAGE_KEY)
101
+ localStorage.removeItem(AUTH_USER_KEY)
102
+
103
+ const { clearUser } = useUser()
104
+ clearUser()
105
+ }
106
+
107
+ async function tryAutoLogin(): Promise<boolean> {
108
+ if (isStaticMode()) return true
109
+
110
+ // 1) URL token parameter
111
+ const params = new URLSearchParams(window.location.search)
112
+ const urlToken = params.get('token')
113
+ if (urlToken) {
114
+ // Remove token from URL (security: prevent referrer/history exposure)
115
+ const cleanUrl = new URL(window.location.href)
116
+ cleanUrl.searchParams.delete('token')
117
+ window.history.replaceState({}, '', cleanUrl.pathname + cleanUrl.search + cleanUrl.hash)
118
+ return await login(urlToken)
119
+ }
120
+
121
+ // 2) localStorage token
122
+ const stored = localStorage.getItem(AUTH_STORAGE_KEY)
123
+ if (stored) {
124
+ return await login(stored)
125
+ }
126
+
127
+ return false
128
+ }
129
+
130
+ export function useAuth() {
131
+ return {
132
+ isAuthenticated: readonly(isAuthenticated),
133
+ authUser: readonly(authUser),
134
+ authLoading: readonly(authLoading),
135
+ login,
136
+ logout,
137
+ tryAutoLogin,
138
+ }
139
+ }
@@ -1,4 +1,12 @@
1
+ /**
2
+ * Memo composable — Page-level notes/memos
3
+ *
4
+ * Always uses localStorage as primary storage.
5
+ * In API mode, also syncs with backend.
6
+ */
7
+
1
8
  import { ref, computed } from 'vue'
9
+ import { isStaticMode } from '@/api/client'
2
10
 
3
11
  export interface MemoItem {
4
12
  id: number
@@ -36,34 +44,34 @@ export function useMemo(pageId: string) {
36
44
  localStorage.setItem(STORAGE_KEY, JSON.stringify(memos.value))
37
45
  }
38
46
 
39
- // Turso dynamic load attempt (falls back to localStorage)
40
- async function tryLoadFromTurso() {
47
+ async function tryLoadFromApi() {
48
+ if (isStaticMode()) return
49
+
41
50
  try {
42
- const { query } = await import('./useTurso')
43
- const r = await query<{ id: number; content: string; author: string; created_at: string }>(
44
- 'SELECT id, content, author, created_at FROM memos WHERE page_id = ? ORDER BY created_at DESC',
45
- [pageId],
51
+ const { apiGet } = await import('@/api/client')
52
+ const r = await apiGet<{ memos: Array<{ id: number; content: string; author: string; created_at: string }> }>(
53
+ '/api/v2/memos',
54
+ { pageId },
46
55
  )
47
- if (!r.error) {
48
- memos.value = r.rows.map((row) => ({
56
+ if (!r.error && r.data) {
57
+ memos.value = r.data.memos.map((row) => ({
49
58
  id: Number(row.id),
50
59
  text: String(row.content),
51
60
  author: String(row.author),
52
61
  ts: new Date(row.created_at + 'Z').getTime(),
53
62
  }))
54
- } else {
63
+ } else if (r.error) {
55
64
  error.value = r.error
56
65
  }
57
66
  } catch (err) {
58
- error.value = err instanceof Error ? err.message : 'Turso load failed'
59
- // Keep localStorage fallback silently in UI behavior
67
+ error.value = err instanceof Error ? err.message : 'API load failed'
60
68
  }
61
69
  }
62
70
 
63
71
  async function loadMemos() {
64
72
  loading.value = true
65
73
  error.value = null
66
- await tryLoadFromTurso()
74
+ await tryLoadFromApi()
67
75
  loading.value = false
68
76
  }
69
77
 
@@ -71,19 +79,18 @@ export function useMemo(pageId: string) {
71
79
  const trimmed = text.trim()
72
80
  if (!trimmed) return false
73
81
 
74
- // Try Turso
75
- try {
76
- const { execute } = await import('./useTurso')
77
- const r = await execute('INSERT INTO memos (page_id, content, author) VALUES (?, ?, ?)', [
78
- pageId, trimmed, author,
79
- ])
80
- if (!r.error) {
81
- await tryLoadFromTurso()
82
- return true
82
+ if (!isStaticMode()) {
83
+ try {
84
+ const { apiPost } = await import('@/api/client')
85
+ const r = await apiPost('/api/v2/memos', { pageId, content: trimmed, author })
86
+ if (!r.error) {
87
+ await tryLoadFromApi()
88
+ return true
89
+ }
90
+ error.value = r.error ?? 'API save failed'
91
+ } catch (err) {
92
+ error.value = err instanceof Error ? err.message : 'API save failed'
83
93
  }
84
- error.value = r.error ?? 'Turso save failed'
85
- } catch (err) {
86
- error.value = err instanceof Error ? err.message : 'Turso save failed'
87
94
  }
88
95
 
89
96
  // localStorage fallback
@@ -93,16 +100,18 @@ export function useMemo(pageId: string) {
93
100
  }
94
101
 
95
102
  async function deleteMemo(id: number) {
96
- try {
97
- const { execute } = await import('./useTurso')
98
- const r = await execute('DELETE FROM memos WHERE id = ?', [id])
99
- if (!r.error) {
100
- await tryLoadFromTurso()
101
- return
103
+ if (!isStaticMode()) {
104
+ try {
105
+ const { apiDelete } = await import('@/api/client')
106
+ const r = await apiDelete(`/api/v2/memos/${id}`)
107
+ if (!r.error) {
108
+ await tryLoadFromApi()
109
+ return
110
+ }
111
+ error.value = r.error ?? 'API delete failed'
112
+ } catch (err) {
113
+ error.value = err instanceof Error ? err.message : 'API delete failed'
102
114
  }
103
- error.value = r.error ?? 'Turso delete failed'
104
- } catch (err) {
105
- error.value = err instanceof Error ? err.message : 'Turso delete failed'
106
115
  }
107
116
 
108
117
  memos.value = memos.value.filter((m) => m.id !== id)
@@ -110,12 +119,14 @@ export function useMemo(pageId: string) {
110
119
  }
111
120
 
112
121
  async function clearAll() {
113
- try {
114
- const { execute } = await import('./useTurso')
115
- const r = await execute('DELETE FROM memos WHERE page_id = ?', [pageId])
116
- if (r.error) error.value = r.error
117
- } catch (err) {
118
- error.value = err instanceof Error ? err.message : 'Turso clear failed'
122
+ if (!isStaticMode()) {
123
+ try {
124
+ const { apiDelete } = await import('@/api/client')
125
+ const r = await apiDelete('/api/v2/memos', { pageId })
126
+ if (r.error) error.value = r.error
127
+ } catch (err) {
128
+ error.value = err instanceof Error ? err.message : 'API clear failed'
129
+ }
119
130
  }
120
131
  memos.value = []
121
132
  saveToLocal()
@@ -130,7 +141,7 @@ export function useMemo(pageId: string) {
130
141
  return `${mm}/${dd} ${hh}:${mi}`
131
142
  }
132
143
 
133
- // Immediately load from localStorage (sync), then try Turso (async)
144
+ // Immediately load from localStorage (sync), then try API (async)
134
145
  loadFromLocal()
135
146
  loadMemos()
136
147
 
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Navigation store — API-backed sprints & epics
3
+ *
4
+ * Singleton pattern: refs are module-level, shared across all consumers.
5
+ * Initialized with fallback data so router/AppHeader work synchronously.
6
+ *
7
+ * In static mode, uses fallback data only (no API calls).
8
+ * In API mode, fetches from backend and overwrites refs.
9
+ */
10
+
11
+ import { ref } from 'vue'
12
+ import { apiGet, apiPost, apiPatch, apiDelete, isStaticMode } from '@/api/client'
13
+ import type { NavApiResponse } from '@/api/types'
14
+ import {
15
+ type SprintConfig, type PageConfig,
16
+ FALLBACK_SPRINTS, FALLBACK_EPICS,
17
+ } from './navTypes'
18
+
19
+ export type { SprintConfig, PageConfig } from './navTypes'
20
+
21
+ // ── Singleton state ──
22
+
23
+ export const sprints = ref<SprintConfig[]>([...FALLBACK_SPRINTS])
24
+ export const epics = ref<PageConfig[]>([...FALLBACK_EPICS])
25
+ export const loaded = ref(false)
26
+
27
+ // ── Read helpers (synchronous, use current ref values) ──
28
+
29
+ export function getActiveSprint(): SprintConfig {
30
+ return sprints.value.find(s => s.active) ?? sprints.value[0]
31
+ }
32
+
33
+ export function getPagesByCategory(sprint: string, category: string): PageConfig[] {
34
+ return epics.value.filter(p => p.sprint === sprint && p.category === category)
35
+ }
36
+
37
+ // ── API loading ──
38
+
39
+ export async function loadNavData(): Promise<void> {
40
+ if (isStaticMode()) {
41
+ loaded.value = true
42
+ return
43
+ }
44
+
45
+ const { data, error } = await apiGet<NavApiResponse>('/api/v2/nav')
46
+ if (error || !data) return
47
+
48
+ if (data.sprints.length > 0) {
49
+ sprints.value = data.sprints.map(r => ({
50
+ id: r.id,
51
+ label: r.label,
52
+ theme: r.theme,
53
+ active: r.active === 1,
54
+ startDate: r.start_date,
55
+ endDate: r.end_date,
56
+ sortOrder: r.sort_order,
57
+ }))
58
+ }
59
+
60
+ if (data.epics.length > 0) {
61
+ epics.value = data.epics.map(r => ({
62
+ id: r.epic_id,
63
+ label: r.label,
64
+ badge: r.badge ?? undefined,
65
+ category: r.category,
66
+ sprint: r.sprint,
67
+ description: r.description ?? undefined,
68
+ sortOrder: r.sort_order,
69
+ uid: r.uid ?? `${r.sprint}:${r.epic_id}`,
70
+ originSprint: r.origin_sprint ?? r.sprint,
71
+ }))
72
+ }
73
+
74
+ loaded.value = true
75
+ }
76
+
77
+ // ── CRUD: Sprints ──
78
+
79
+ export async function addSprint(data: {
80
+ id: string
81
+ label: string
82
+ theme: string
83
+ startDate?: string | null
84
+ endDate?: string | null
85
+ }): Promise<{ error?: string }> {
86
+ if (isStaticMode()) return { error: 'CRUD not available in static mode' }
87
+ const maxOrder = sprints.value.reduce((max, s) => Math.max(max, s.sortOrder), -1)
88
+ const { error } = await apiPost('/api/v2/nav/sprints', {
89
+ id: data.id, label: data.label, theme: data.theme,
90
+ startDate: data.startDate ?? null, endDate: data.endDate ?? null,
91
+ sortOrder: maxOrder + 1,
92
+ })
93
+ if (error) return { error }
94
+ await loadNavData()
95
+ return {}
96
+ }
97
+
98
+ export async function updateSprint(id: string, data: {
99
+ label?: string
100
+ theme?: string
101
+ startDate?: string | null
102
+ endDate?: string | null
103
+ }): Promise<{ error?: string }> {
104
+ if (isStaticMode()) return { error: 'CRUD not available in static mode' }
105
+ const { error } = await apiPatch(`/api/v2/nav/sprints/${id}`, data as Record<string, unknown>)
106
+ if (error) return { error }
107
+ await loadNavData()
108
+ return {}
109
+ }
110
+
111
+ export async function deleteSprint(id: string): Promise<{ error?: string }> {
112
+ if (isStaticMode()) return { error: 'CRUD not available in static mode' }
113
+ const { error } = await apiDelete(`/api/v2/nav/sprints/${id}`)
114
+ if (error) return { error }
115
+ await loadNavData()
116
+ return {}
117
+ }
118
+
119
+ export async function setActiveSprint(id: string): Promise<{ error?: string }> {
120
+ if (isStaticMode()) return { error: 'CRUD not available in static mode' }
121
+ const { error } = await apiPost(`/api/v2/nav/sprints/${id}/activate`, {})
122
+ if (error) return { error }
123
+ await loadNavData()
124
+ return {}
125
+ }
126
+
127
+ // ── CRUD: Epics ──
128
+
129
+ export async function addEpic(sprint: string, data: {
130
+ epicId: string
131
+ label: string
132
+ badge?: string | null
133
+ category?: string
134
+ description?: string | null
135
+ }): Promise<{ error?: string }> {
136
+ if (isStaticMode()) return { error: 'CRUD not available in static mode' }
137
+ const currentEpics = epics.value.filter(e => e.sprint === sprint)
138
+ const maxOrder = currentEpics.reduce((max, e) => Math.max(max, e.sortOrder), -1)
139
+ const uid = `${sprint}:${data.epicId}`
140
+ const { error } = await apiPost('/api/v2/nav/epics', {
141
+ sprint, epicId: data.epicId, label: data.label,
142
+ badge: data.badge ?? null, category: data.category ?? 'policy',
143
+ description: data.description ?? null, sortOrder: maxOrder + 1,
144
+ uid, originSprint: sprint,
145
+ })
146
+ if (error) return { error }
147
+ await loadNavData()
148
+ return {}
149
+ }
150
+
151
+ export async function updateEpic(sprint: string, epicId: string, data: {
152
+ label?: string
153
+ badge?: string | null
154
+ category?: string
155
+ description?: string | null
156
+ }): Promise<{ error?: string }> {
157
+ if (isStaticMode()) return { error: 'CRUD not available in static mode' }
158
+ const { error } = await apiPatch(`/api/v2/nav/epics/${sprint}/${epicId}`, data as Record<string, unknown>)
159
+ if (error) return { error }
160
+ await loadNavData()
161
+ return {}
162
+ }
163
+
164
+ export async function deleteEpic(sprint: string, epicId: string): Promise<{ error?: string }> {
165
+ if (isStaticMode()) return { error: 'CRUD not available in static mode' }
166
+ const { error } = await apiDelete(`/api/v2/nav/epics/${sprint}/${epicId}`)
167
+ if (error) return { error }
168
+ await loadNavData()
169
+ return {}
170
+ }
171
+
172
+ export async function carryOverEpic(
173
+ uid: string,
174
+ targetSprint: string,
175
+ newEpicId: string,
176
+ newLabel?: string,
177
+ newBadge?: string,
178
+ ): Promise<{ error?: string }> {
179
+ if (isStaticMode()) return { error: 'CRUD not available in static mode' }
180
+ const epic = epics.value.find(e => e.uid === uid)
181
+ if (!epic) return { error: `Epic not found: ${uid}` }
182
+
183
+ const targetEpics = epics.value.filter(e => e.sprint === targetSprint)
184
+ const maxOrder = targetEpics.reduce((max, e) => Math.max(max, e.sortOrder), -1)
185
+
186
+ const { error } = await apiPost('/api/v2/nav/epics/carry-over', {
187
+ sprint: targetSprint,
188
+ epicId: newEpicId,
189
+ label: newLabel ?? epic.label,
190
+ badge: newBadge ?? epic.badge ?? null,
191
+ category: epic.category,
192
+ description: epic.description ?? null,
193
+ sortOrder: maxOrder + 1,
194
+ uid,
195
+ originSprint: epic.originSprint ?? epic.sprint,
196
+ oldSprint: epic.sprint,
197
+ oldEpicId: epic.id,
198
+ })
199
+ if (error) return { error }
200
+ await loadNavData()
201
+ return {}
202
+ }