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.
- package/lib/hydrate.mjs +6 -1
- package/lib/setup-wizard.mjs +29 -3
- package/package.json +1 -1
- package/scaffold/.claude/commands/_domain.md.hbs +33 -0
- package/scaffold/.claude/commands/analytics.md.hbs +55 -0
- package/scaffold/.claude/commands/daily.md.hbs +301 -0
- package/scaffold/.claude/commands/dev.md.hbs +62 -0
- package/scaffold/.claude/commands/gtm.md +82 -0
- package/scaffold/.claude/commands/handoff.md +259 -0
- package/scaffold/.claude/commands/market.md +120 -0
- package/scaffold/.claude/commands/metrics.md +123 -0
- package/scaffold/.claude/commands/oscar-loop.md +436 -0
- package/scaffold/.claude/commands/party.md +85 -0
- package/scaffold/.claude/commands/plan.md +43 -0
- package/scaffold/.claude/commands/poc.md +69 -0
- package/scaffold/.claude/commands/research.md +203 -0
- package/scaffold/.claude/commands/retro.md +68 -0
- package/scaffold/.claude/commands/save.md +440 -0
- package/scaffold/.claude/commands/sessions.md +139 -0
- package/scaffold/.claude/commands/sprint.md +106 -0
- package/scaffold/.claude/commands/start.md +396 -0
- package/scaffold/.claude/commands/strategy.md +41 -0
- package/scaffold/.claude/commands/task.md +220 -0
- package/scaffold/.claude/commands/tracking.md +116 -0
- package/scaffold/.claude/commands/validate.md +58 -0
- package/scaffold/.context/WORKFLOW.md.hbs +58 -26
- package/scaffold/.context/agents/planner.md.hbs +35 -7
- package/scaffold/.context/integrations/_registry.yaml +6 -0
- package/scaffold/.context/integrations/providers/sqlite_lambda.yaml +24 -0
- package/scaffold/.context/integrations/providers/supabase.yaml +34 -0
- package/scaffold/.context/integrations/providers/turso_cf.yaml +34 -0
- package/scaffold/.context/poc/_skills/build.md +79 -0
- package/scaffold/.context/poc/_skills/scope.md +50 -0
- package/scaffold/.context/poc/_skills/spec.md +80 -0
- package/scaffold/.context/poc/_skills/verify.md +60 -0
- package/scaffold/CLAUDE.md.hbs +210 -0
- package/scaffold/spec-site/.env.example +11 -0
- package/scaffold/spec-site/index.html +2 -2
- package/scaffold/spec-site/sql/schema.sql +224 -0
- package/scaffold/spec-site/src/api/client.ts +131 -0
- package/scaffold/spec-site/src/api/types.ts +177 -0
- package/scaffold/spec-site/src/components/Accordion.vue +1 -1
- package/scaffold/spec-site/src/components/AppHeader.vue +5 -4
- package/scaffold/spec-site/src/components/CoachingCard.vue +1 -1
- package/scaffold/spec-site/src/components/ScenarioSwitcher.vue +1 -1
- package/scaffold/spec-site/src/composables/navTypes.ts +39 -0
- package/scaffold/spec-site/src/composables/pmTypes.ts +134 -0
- package/scaffold/spec-site/src/composables/useAuth.ts +139 -0
- package/scaffold/spec-site/src/composables/useMemo.ts +51 -40
- package/scaffold/spec-site/src/composables/useNavStore.ts +202 -0
- package/scaffold/spec-site/src/composables/usePageContent.ts +208 -0
- package/scaffold/spec-site/src/composables/usePmStore.ts +224 -0
- package/scaffold/spec-site/src/composables/useRetro.ts +181 -95
- package/scaffold/spec-site/src/composables/useScenarioStore.ts +74 -30
- package/scaffold/spec-site/src/composables/useUser.ts +12 -6
- package/scaffold/spec-site/src/data/navigation.ts +7 -42
- package/scaffold/spec-site/src/data/types.ts +13 -43
- package/scaffold/spec-site/src/main.ts +7 -0
- package/scaffold/spec-site/src/pages/PolicyDetail.vue +30 -11
- package/scaffold/spec-site/src/pages/PolicyIndex.vue +22 -7
- package/scaffold/spec-site/src/pages/retro/RetroActions.vue +3 -3
- package/scaffold/spec-site/src/pages/retro/RetroBoard.vue +2 -2
- package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +5 -7
- package/scaffold/spec-site/src/pages/retro/RetroPage.vue +2 -2
- package/scaffold/spec-site/src/pages/shared/NoContentPlaceholder.vue +2 -2
- package/scaffold/spec-site/src/pages/shared/PolicyFallback.vue +25 -13
- package/scaffold/spec-site/src/router.ts +11 -7
- package/scaffold/spec-site/src/styles/base.css +2 -2
- package/scaffold/spec-site/src/styles/split-pane.css +1 -1
- package/scaffold/spec-site/src/styles/variables.css +7 -7
- package/scaffold/spec-site/src/assets/icons/menu/ic_ads.svg +0 -10
- package/scaffold/spec-site/src/assets/icons/menu/ic_ads_on.svg +0 -10
- package/scaffold/spec-site/src/assets/icons/menu/ic_board.svg +0 -14
- package/scaffold/spec-site/src/assets/icons/menu/ic_board_on.svg +0 -14
- package/scaffold/spec-site/src/assets/icons/menu/ic_dashboard.svg +0 -21
- package/scaffold/spec-site/src/assets/icons/menu/ic_dashboard_on.svg +0 -21
- package/scaffold/spec-site/src/assets/icons/menu/ic_pricing.svg +0 -20
- package/scaffold/spec-site/src/assets/icons/menu/ic_pricing_on.svg +0 -20
- package/scaffold/spec-site/src/assets/icons/menu/ic_store.svg +0 -11
- package/scaffold/spec-site/src/assets/icons/menu/ic_store_on.svg +0 -11
- 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
|
-
|
|
40
|
-
|
|
47
|
+
async function tryLoadFromApi() {
|
|
48
|
+
if (isStaticMode()) return
|
|
49
|
+
|
|
41
50
|
try {
|
|
42
|
-
const {
|
|
43
|
-
const r = await
|
|
44
|
-
'
|
|
45
|
-
|
|
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.
|
|
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 : '
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
+
}
|