popilot 0.2.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/README.md +372 -0
- package/adapters/claude-code/.claude/commands/_domain.md.hbs +32 -0
- package/adapters/claude-code/.claude/commands/analytics.md.hbs +55 -0
- package/adapters/claude-code/.claude/commands/daily.md.hbs +301 -0
- package/adapters/claude-code/.claude/commands/dev.md.hbs +62 -0
- package/adapters/claude-code/.claude/commands/handoff.md +258 -0
- package/adapters/claude-code/.claude/commands/market.md +120 -0
- package/adapters/claude-code/.claude/commands/metrics.md +123 -0
- package/adapters/claude-code/.claude/commands/oscar-loop.md +436 -0
- package/adapters/claude-code/.claude/commands/party.md +85 -0
- package/adapters/claude-code/.claude/commands/plan.md +43 -0
- package/adapters/claude-code/.claude/commands/research.md +203 -0
- package/adapters/claude-code/.claude/commands/retro.md +68 -0
- package/adapters/claude-code/.claude/commands/save.md +440 -0
- package/adapters/claude-code/.claude/commands/sessions.md +139 -0
- package/adapters/claude-code/.claude/commands/sprint.md +106 -0
- package/adapters/claude-code/.claude/commands/start.md +368 -0
- package/adapters/claude-code/.claude/commands/strategy.md +41 -0
- package/adapters/claude-code/.claude/commands/task.md +220 -0
- package/adapters/claude-code/.claude/commands/tracking.md +116 -0
- package/adapters/claude-code/.claude/commands/validate.md +58 -0
- package/adapters/claude-code/CLAUDE.md.hbs +208 -0
- package/adapters/claude-code/manifest.yaml +36 -0
- package/bin/cli.mjs +218 -0
- package/lib/adapter.mjs +68 -0
- package/lib/doctor.mjs +161 -0
- package/lib/hydrate.mjs +421 -0
- package/lib/prompt.mjs +78 -0
- package/lib/scaffold.mjs +155 -0
- package/lib/setup-wizard.mjs +331 -0
- package/lib/template-engine.mjs +164 -0
- package/lib/yaml-lite.mjs +476 -0
- package/package.json +30 -0
- package/scaffold/.context/.secrets.yaml.example +20 -0
- package/scaffold/.context/WORKFLOW.md.hbs +332 -0
- package/scaffold/.context/agents/TEMPLATE.md +115 -0
- package/scaffold/.context/agents/analyst.md.hbs +362 -0
- package/scaffold/.context/agents/developer.md.hbs +390 -0
- package/scaffold/.context/agents/handoff-specialist.md.hbs +292 -0
- package/scaffold/.context/agents/market-researcher.md.hbs +288 -0
- package/scaffold/.context/agents/ollie.md +323 -0
- package/scaffold/.context/agents/operations.md.hbs +293 -0
- package/scaffold/.context/agents/orchestrator.md.hbs +434 -0
- package/scaffold/.context/agents/planner.md.hbs +405 -0
- package/scaffold/.context/agents/qa.md.hbs +409 -0
- package/scaffold/.context/agents/researcher.md.hbs +330 -0
- package/scaffold/.context/agents/sage.md +349 -0
- package/scaffold/.context/agents/strategist.md.hbs +339 -0
- package/scaffold/.context/agents/tracking-governor.md.hbs +291 -0
- package/scaffold/.context/agents/validator.md.hbs +365 -0
- package/scaffold/.context/integrations/_registry.yaml +38 -0
- package/scaffold/.context/integrations/providers/channel_io.yaml +38 -0
- package/scaffold/.context/integrations/providers/corti.yaml +203 -0
- package/scaffold/.context/integrations/providers/ga4.yaml +116 -0
- package/scaffold/.context/integrations/providers/intercom.yaml +47 -0
- package/scaffold/.context/integrations/providers/linear.yaml +46 -0
- package/scaffold/.context/integrations/providers/mixpanel.yaml +73 -0
- package/scaffold/.context/integrations/providers/notebooklm.yaml +74 -0
- package/scaffold/.context/integrations/providers/notion.yaml +129 -0
- package/scaffold/.context/integrations/providers/prod_db.yaml +183 -0
- package/scaffold/.context/oscar/workflows/multi-agent.md +82 -0
- package/scaffold/.context/oscar/workflows/ollie-sage.md +128 -0
- package/scaffold/.context/oscar/workflows/session-git.md +71 -0
- package/scaffold/.context/oscar/workflows/setup.md +663 -0
- package/scaffold/.context/oscar/workflows/tracking.md +118 -0
- package/scaffold/.context/project.yaml.example +102 -0
- package/scaffold/.context/templates/dev-guide.md +217 -0
- package/scaffold/.context/templates/epic-spec.md +225 -0
- package/scaffold/.context/templates/guardrail.md +94 -0
- package/scaffold/.context/templates/handoff-checklist.md +197 -0
- package/scaffold/.context/templates/prd.md +80 -0
- package/scaffold/.context/templates/retrospective.md +78 -0
- package/scaffold/.context/templates/screen-spec.md +714 -0
- package/scaffold/.context/templates/sprint-plan.md +72 -0
- package/scaffold/.context/templates/sprint-status.yaml +109 -0
- package/scaffold/.context/templates/story-v2.md +228 -0
- package/scaffold/.context/templates/validation-report.md +99 -0
- package/scaffold/.gitignore.append +7 -0
- package/scaffold/spec-site/env.d.ts +7 -0
- package/scaffold/spec-site/index.html +14 -0
- package/scaffold/spec-site/package.json +20 -0
- package/scaffold/spec-site/src/App.vue +27 -0
- package/scaffold/spec-site/src/assets/icons/menu/ic_ads.svg +10 -0
- package/scaffold/spec-site/src/assets/icons/menu/ic_ads_on.svg +10 -0
- package/scaffold/spec-site/src/assets/icons/menu/ic_board.svg +14 -0
- package/scaffold/spec-site/src/assets/icons/menu/ic_board_on.svg +14 -0
- package/scaffold/spec-site/src/assets/icons/menu/ic_dashboard.svg +21 -0
- package/scaffold/spec-site/src/assets/icons/menu/ic_dashboard_on.svg +21 -0
- package/scaffold/spec-site/src/assets/icons/menu/ic_pricing.svg +20 -0
- package/scaffold/spec-site/src/assets/icons/menu/ic_pricing_on.svg +20 -0
- package/scaffold/spec-site/src/assets/icons/menu/ic_store.svg +11 -0
- package/scaffold/spec-site/src/assets/icons/menu/ic_store_on.svg +11 -0
- package/scaffold/spec-site/src/components/Accordion.vue +108 -0
- package/scaffold/spec-site/src/components/AppHeader.vue +304 -0
- package/scaffold/spec-site/src/components/Badge.vue +25 -0
- package/scaffold/spec-site/src/components/CoachingCard.vue +112 -0
- package/scaffold/spec-site/src/components/MemoSidebar.vue +239 -0
- package/scaffold/spec-site/src/components/MockupShell.vue +100 -0
- package/scaffold/spec-site/src/components/RuleTable.vue +99 -0
- package/scaffold/spec-site/src/components/ScenarioSwitcher.vue +103 -0
- package/scaffold/spec-site/src/components/SpecNav.vue +26 -0
- package/scaffold/spec-site/src/components/SpecSection.vue +59 -0
- package/scaffold/spec-site/src/components/SummaryGrid.vue +39 -0
- package/scaffold/spec-site/src/components/VersionBadge.vue +38 -0
- package/scaffold/spec-site/src/composables/useActiveSection.ts +53 -0
- package/scaffold/spec-site/src/composables/useMemo.ts +138 -0
- package/scaffold/spec-site/src/composables/useRetro.ts +313 -0
- package/scaffold/spec-site/src/composables/useScenario.ts +43 -0
- package/scaffold/spec-site/src/composables/useScenarioStore.ts +102 -0
- package/scaffold/spec-site/src/composables/useTurso.ts +160 -0
- package/scaffold/spec-site/src/composables/useUser.ts +25 -0
- package/scaffold/spec-site/src/data/navigation.ts +59 -0
- package/scaffold/spec-site/src/data/types.ts +90 -0
- package/scaffold/spec-site/src/data/wireframeRegistry.ts +25 -0
- package/scaffold/spec-site/src/layouts/SplitPaneLayout.vue +79 -0
- package/scaffold/spec-site/src/main.ts +10 -0
- package/scaffold/spec-site/src/pages/IndexPage.vue +66 -0
- package/scaffold/spec-site/src/pages/PolicyDetail.vue +215 -0
- package/scaffold/spec-site/src/pages/PolicyIndex.vue +74 -0
- package/scaffold/spec-site/src/pages/retro/RetroActions.vue +191 -0
- package/scaffold/spec-site/src/pages/retro/RetroBoard.vue +192 -0
- package/scaffold/spec-site/src/pages/retro/RetroCard.vue +131 -0
- package/scaffold/spec-site/src/pages/retro/RetroHeader.vue +287 -0
- package/scaffold/spec-site/src/pages/retro/RetroPage.vue +178 -0
- package/scaffold/spec-site/src/pages/shared/NoContentPlaceholder.vue +34 -0
- package/scaffold/spec-site/src/pages/shared/PlaceholderContent.vue +22 -0
- package/scaffold/spec-site/src/pages/shared/PlaceholderSpecPanel.vue +16 -0
- package/scaffold/spec-site/src/pages/shared/PolicyFallback.vue +145 -0
- package/scaffold/spec-site/src/pages/wireframe/WireframeShell.vue +151 -0
- package/scaffold/spec-site/src/router.ts +85 -0
- package/scaffold/spec-site/src/styles/base.css +21 -0
- package/scaffold/spec-site/src/styles/split-pane.css +143 -0
- package/scaffold/spec-site/src/styles/variables.css +47 -0
- package/scaffold/spec-site/src/utils/markdown.ts +197 -0
- package/scaffold/spec-site/tsconfig.json +20 -0
- package/scaffold/spec-site/vite.config.ts +18 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { ref, provide, inject, nextTick } from 'vue'
|
|
2
|
+
import type { InjectionKey, Ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
interface ActiveSectionCtx {
|
|
5
|
+
activeSection: Ref<string | null>
|
|
6
|
+
setActiveSection: (id: string) => void
|
|
7
|
+
clearActiveSection: () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ActiveSectionKey: InjectionKey<ActiveSectionCtx> = Symbol('activeSection')
|
|
11
|
+
|
|
12
|
+
export function provideActiveSection() {
|
|
13
|
+
const activeSection = ref<string | null>(null)
|
|
14
|
+
|
|
15
|
+
function setActiveSection(id: string) {
|
|
16
|
+
activeSection.value = id
|
|
17
|
+
|
|
18
|
+
nextTick(() => {
|
|
19
|
+
// Highlight mockup area
|
|
20
|
+
document.querySelectorAll('[data-area]').forEach(el =>
|
|
21
|
+
el.classList.remove('area-active')
|
|
22
|
+
)
|
|
23
|
+
const mockupArea = document.querySelector(`[data-area="${id}"]`)
|
|
24
|
+
if (mockupArea) {
|
|
25
|
+
mockupArea.classList.add('area-active')
|
|
26
|
+
mockupArea.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Scroll spec panel
|
|
30
|
+
const specEl = document.getElementById(`spec-section-${id}`)
|
|
31
|
+
if (specEl) {
|
|
32
|
+
const body = document.getElementById('spec-body')
|
|
33
|
+
if (body) body.scrollTop = 0
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function clearActiveSection() {
|
|
39
|
+
activeSection.value = null
|
|
40
|
+
document.querySelectorAll('[data-area]').forEach(el =>
|
|
41
|
+
el.classList.remove('area-active')
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
provide(ActiveSectionKey, { activeSection, setActiveSection, clearActiveSection })
|
|
46
|
+
return { activeSection, setActiveSection, clearActiveSection }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useActiveSection() {
|
|
50
|
+
const ctx = inject(ActiveSectionKey)
|
|
51
|
+
if (!ctx) throw new Error('useActiveSection requires SplitPaneLayout ancestor')
|
|
52
|
+
return ctx
|
|
53
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { ref, computed } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface MemoItem {
|
|
4
|
+
id: number
|
|
5
|
+
text: string
|
|
6
|
+
author: string
|
|
7
|
+
ts: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useMemo(pageId: string) {
|
|
11
|
+
const STORAGE_KEY = `spec-memo-${pageId}`
|
|
12
|
+
const memos = ref<MemoItem[]>([])
|
|
13
|
+
const memoCount = computed(() => memos.value.length)
|
|
14
|
+
const loading = ref(false)
|
|
15
|
+
const error = ref<string | null>(null)
|
|
16
|
+
|
|
17
|
+
// localStorage (sync, immediately available)
|
|
18
|
+
function loadFromLocal() {
|
|
19
|
+
try {
|
|
20
|
+
const raw = localStorage.getItem(STORAGE_KEY)
|
|
21
|
+
if (raw) {
|
|
22
|
+
const items = JSON.parse(raw) as Array<{ id: number; text: string; ts: number; author?: string }>
|
|
23
|
+
memos.value = items.map((m) => ({
|
|
24
|
+
id: m.id,
|
|
25
|
+
text: m.text,
|
|
26
|
+
author: m.author ?? '',
|
|
27
|
+
ts: m.ts,
|
|
28
|
+
}))
|
|
29
|
+
}
|
|
30
|
+
} catch (err) {
|
|
31
|
+
error.value = err instanceof Error ? err.message : 'Failed to read local memos'
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function saveToLocal() {
|
|
36
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(memos.value))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Turso dynamic load attempt (falls back to localStorage)
|
|
40
|
+
async function tryLoadFromTurso() {
|
|
41
|
+
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],
|
|
46
|
+
)
|
|
47
|
+
if (!r.error) {
|
|
48
|
+
memos.value = r.rows.map((row) => ({
|
|
49
|
+
id: Number(row.id),
|
|
50
|
+
text: String(row.content),
|
|
51
|
+
author: String(row.author),
|
|
52
|
+
ts: new Date(row.created_at + 'Z').getTime(),
|
|
53
|
+
}))
|
|
54
|
+
} else {
|
|
55
|
+
error.value = r.error
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
error.value = err instanceof Error ? err.message : 'Turso load failed'
|
|
59
|
+
// Keep localStorage fallback silently in UI behavior
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function loadMemos() {
|
|
64
|
+
loading.value = true
|
|
65
|
+
error.value = null
|
|
66
|
+
await tryLoadFromTurso()
|
|
67
|
+
loading.value = false
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function addMemo(text: string, author: string): Promise<boolean> {
|
|
71
|
+
const trimmed = text.trim()
|
|
72
|
+
if (!trimmed) return false
|
|
73
|
+
|
|
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
|
|
83
|
+
}
|
|
84
|
+
error.value = r.error ?? 'Turso save failed'
|
|
85
|
+
} catch (err) {
|
|
86
|
+
error.value = err instanceof Error ? err.message : 'Turso save failed'
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// localStorage fallback
|
|
90
|
+
memos.value.unshift({ id: Date.now(), text: trimmed, author, ts: Date.now() })
|
|
91
|
+
saveToLocal()
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
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
|
|
102
|
+
}
|
|
103
|
+
error.value = r.error ?? 'Turso delete failed'
|
|
104
|
+
} catch (err) {
|
|
105
|
+
error.value = err instanceof Error ? err.message : 'Turso delete failed'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
memos.value = memos.value.filter((m) => m.id !== id)
|
|
109
|
+
saveToLocal()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
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'
|
|
119
|
+
}
|
|
120
|
+
memos.value = []
|
|
121
|
+
saveToLocal()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatTime(ts: number): string {
|
|
125
|
+
const d = new Date(ts)
|
|
126
|
+
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
|
127
|
+
const dd = String(d.getDate()).padStart(2, '0')
|
|
128
|
+
const hh = String(d.getHours()).padStart(2, '0')
|
|
129
|
+
const mi = String(d.getMinutes()).padStart(2, '0')
|
|
130
|
+
return `${mm}/${dd} ${hh}:${mi}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Immediately load from localStorage (sync), then try Turso (async)
|
|
134
|
+
loadFromLocal()
|
|
135
|
+
loadMemos()
|
|
136
|
+
|
|
137
|
+
return { memos, memoCount, loading, error, addMemo, deleteMemo, clearAll, formatTime, loadMemos }
|
|
138
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { ref, computed, onUnmounted } from 'vue'
|
|
2
|
+
import { query, execute } from './useTurso'
|
|
3
|
+
|
|
4
|
+
export type RetroPhase = 'write' | 'vote' | 'discuss' | 'done'
|
|
5
|
+
export type RetroCategory = 'keep' | 'problem' | 'try'
|
|
6
|
+
|
|
7
|
+
export interface RetroSession {
|
|
8
|
+
id: number
|
|
9
|
+
sprint: string
|
|
10
|
+
title: string | null
|
|
11
|
+
phase: RetroPhase
|
|
12
|
+
created_at: string
|
|
13
|
+
updated_at: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RetroItem {
|
|
17
|
+
id: number
|
|
18
|
+
session_id: number
|
|
19
|
+
category: RetroCategory
|
|
20
|
+
content: string
|
|
21
|
+
author: string
|
|
22
|
+
created_at: string
|
|
23
|
+
voteCount: number
|
|
24
|
+
hasVoted: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RetroAction {
|
|
28
|
+
id: number
|
|
29
|
+
session_id: number
|
|
30
|
+
content: string
|
|
31
|
+
assignee: string | null
|
|
32
|
+
status: 'pending' | 'done'
|
|
33
|
+
created_at: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const VOTES_PER_PERSON = 5
|
|
37
|
+
const POLL_INTERVAL_MS = 4000
|
|
38
|
+
|
|
39
|
+
export function useRetro(sprintId: string) {
|
|
40
|
+
const session = ref<RetroSession | null>(null)
|
|
41
|
+
const items = ref<RetroItem[]>([])
|
|
42
|
+
const actions = ref<RetroAction[]>([])
|
|
43
|
+
const loading = ref(false)
|
|
44
|
+
const error = ref<string | null>(null)
|
|
45
|
+
|
|
46
|
+
// -- Derived --
|
|
47
|
+
const keepItems = computed(() => items.value.filter((i) => i.category === 'keep'))
|
|
48
|
+
const problemItems = computed(() => items.value.filter((i) => i.category === 'problem'))
|
|
49
|
+
const tryItems = computed(() => items.value.filter((i) => i.category === 'try'))
|
|
50
|
+
|
|
51
|
+
// -- Session --
|
|
52
|
+
async function loadOrCreateSession() {
|
|
53
|
+
loading.value = true
|
|
54
|
+
error.value = null
|
|
55
|
+
|
|
56
|
+
const r = await query<RetroSession>(
|
|
57
|
+
'SELECT * FROM retro_sessions WHERE sprint = ? LIMIT 1',
|
|
58
|
+
[sprintId],
|
|
59
|
+
)
|
|
60
|
+
if (r.error) {
|
|
61
|
+
error.value = r.error
|
|
62
|
+
loading.value = false
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (r.rows.length > 0) {
|
|
67
|
+
session.value = r.rows[0]
|
|
68
|
+
} else {
|
|
69
|
+
const ins = await execute(
|
|
70
|
+
'INSERT INTO retro_sessions (sprint, title) VALUES (?, ?)',
|
|
71
|
+
[sprintId, `${sprintId.toUpperCase()} Retro`],
|
|
72
|
+
)
|
|
73
|
+
if (ins.error) {
|
|
74
|
+
error.value = ins.error
|
|
75
|
+
loading.value = false
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
const r2 = await query<RetroSession>(
|
|
79
|
+
'SELECT * FROM retro_sessions WHERE sprint = ? LIMIT 1',
|
|
80
|
+
[sprintId],
|
|
81
|
+
)
|
|
82
|
+
session.value = r2.rows[0] ?? null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
await refresh()
|
|
86
|
+
loading.value = false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function setPhase(phase: RetroPhase) {
|
|
90
|
+
if (!session.value) return
|
|
91
|
+
await execute(
|
|
92
|
+
"UPDATE retro_sessions SET phase = ?, updated_at = datetime('now') WHERE id = ?",
|
|
93
|
+
[phase, session.value.id],
|
|
94
|
+
)
|
|
95
|
+
session.value = { ...session.value, phase }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// -- Items --
|
|
99
|
+
async function loadItems(currentUser: string) {
|
|
100
|
+
if (!session.value) return
|
|
101
|
+
const r = await query<Record<string, unknown>>(
|
|
102
|
+
`SELECT i.*,
|
|
103
|
+
COUNT(v.voter) as voteCount,
|
|
104
|
+
MAX(CASE WHEN v.voter = ? THEN 1 ELSE 0 END) as hasVoted
|
|
105
|
+
FROM retro_items i
|
|
106
|
+
LEFT JOIN retro_votes v ON v.item_id = i.id
|
|
107
|
+
WHERE i.session_id = ?
|
|
108
|
+
GROUP BY i.id
|
|
109
|
+
ORDER BY i.created_at ASC`,
|
|
110
|
+
[currentUser, session.value.id],
|
|
111
|
+
)
|
|
112
|
+
if (!r.error) {
|
|
113
|
+
items.value = r.rows.map((row) => ({
|
|
114
|
+
id: Number(row.id),
|
|
115
|
+
session_id: Number(row.session_id),
|
|
116
|
+
category: row.category as RetroCategory,
|
|
117
|
+
content: row.content as string,
|
|
118
|
+
author: row.author as string,
|
|
119
|
+
created_at: row.created_at as string,
|
|
120
|
+
voteCount: Number(row.voteCount),
|
|
121
|
+
hasVoted: Boolean(Number(row.hasVoted)),
|
|
122
|
+
}))
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function addItem(category: RetroCategory, content: string, author: string) {
|
|
127
|
+
if (!session.value) return
|
|
128
|
+
const trimmed = content.trim()
|
|
129
|
+
if (!trimmed) return
|
|
130
|
+
await execute(
|
|
131
|
+
'INSERT INTO retro_items (session_id, category, content, author) VALUES (?, ?, ?, ?)',
|
|
132
|
+
[session.value.id, category, trimmed, author],
|
|
133
|
+
)
|
|
134
|
+
await loadItems(author)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function deleteItem(itemId: number, currentUser: string) {
|
|
138
|
+
await execute('DELETE FROM retro_votes WHERE item_id = ?', [itemId])
|
|
139
|
+
await execute('DELETE FROM retro_items WHERE id = ?', [itemId])
|
|
140
|
+
await loadItems(currentUser)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// -- Votes --
|
|
144
|
+
const myVoteCount = computed(() => items.value.filter((i) => i.hasVoted).length)
|
|
145
|
+
const votesRemaining = computed(() => VOTES_PER_PERSON - myVoteCount.value)
|
|
146
|
+
|
|
147
|
+
async function toggleVote(itemId: number, currentUser: string, hasVoted: boolean) {
|
|
148
|
+
if (!hasVoted && votesRemaining.value <= 0) return
|
|
149
|
+
if (hasVoted) {
|
|
150
|
+
await execute('DELETE FROM retro_votes WHERE item_id = ? AND voter = ?', [
|
|
151
|
+
itemId,
|
|
152
|
+
currentUser,
|
|
153
|
+
])
|
|
154
|
+
} else {
|
|
155
|
+
await execute('INSERT OR IGNORE INTO retro_votes (item_id, voter) VALUES (?, ?)', [
|
|
156
|
+
itemId,
|
|
157
|
+
currentUser,
|
|
158
|
+
])
|
|
159
|
+
}
|
|
160
|
+
await loadItems(currentUser)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// -- Actions --
|
|
164
|
+
async function loadActions() {
|
|
165
|
+
if (!session.value) return
|
|
166
|
+
const r = await query<RetroAction>(
|
|
167
|
+
'SELECT * FROM retro_actions WHERE session_id = ? ORDER BY created_at ASC',
|
|
168
|
+
[session.value.id],
|
|
169
|
+
)
|
|
170
|
+
if (!r.error) actions.value = r.rows
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function addAction(content: string, assignee: string | null) {
|
|
174
|
+
if (!session.value) return
|
|
175
|
+
await execute(
|
|
176
|
+
'INSERT INTO retro_actions (session_id, content, assignee) VALUES (?, ?, ?)',
|
|
177
|
+
[session.value.id, content.trim(), assignee],
|
|
178
|
+
)
|
|
179
|
+
await loadActions()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function toggleActionStatus(actionId: number, currentStatus: 'pending' | 'done') {
|
|
183
|
+
const next = currentStatus === 'pending' ? 'done' : 'pending'
|
|
184
|
+
await execute('UPDATE retro_actions SET status = ? WHERE id = ?', [next, actionId])
|
|
185
|
+
await loadActions()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// -- Reset --
|
|
189
|
+
async function resetSession() {
|
|
190
|
+
if (!session.value) return
|
|
191
|
+
const sid = session.value.id
|
|
192
|
+
await execute(
|
|
193
|
+
'DELETE FROM retro_votes WHERE item_id IN (SELECT id FROM retro_items WHERE session_id = ?)',
|
|
194
|
+
[sid],
|
|
195
|
+
)
|
|
196
|
+
await execute('DELETE FROM retro_actions WHERE session_id = ?', [sid])
|
|
197
|
+
await execute('DELETE FROM retro_items WHERE session_id = ?', [sid])
|
|
198
|
+
await execute('DELETE FROM retro_sessions WHERE id = ?', [sid])
|
|
199
|
+
session.value = null
|
|
200
|
+
items.value = []
|
|
201
|
+
actions.value = []
|
|
202
|
+
await loadOrCreateSession()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// -- Export --
|
|
206
|
+
function exportMarkdown(): string {
|
|
207
|
+
if (!session.value) return ''
|
|
208
|
+
const s = session.value
|
|
209
|
+
const lines: string[] = []
|
|
210
|
+
|
|
211
|
+
lines.push(`# ${s.sprint.toUpperCase()} Sprint Retro`)
|
|
212
|
+
lines.push('')
|
|
213
|
+
|
|
214
|
+
const cats: { key: RetroCategory; emoji: string; label: string }[] = [
|
|
215
|
+
{ key: 'keep', emoji: '✅', label: 'Keep' },
|
|
216
|
+
{ key: 'problem', emoji: '🔴', label: 'Problem' },
|
|
217
|
+
{ key: 'try', emoji: '💡', label: 'Try' },
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
for (const cat of cats) {
|
|
221
|
+
const catItems = [...items.value]
|
|
222
|
+
.filter((i) => i.category === cat.key)
|
|
223
|
+
.sort((a, b) => b.voteCount - a.voteCount)
|
|
224
|
+
|
|
225
|
+
lines.push(`## ${cat.emoji} ${cat.label}`)
|
|
226
|
+
lines.push('')
|
|
227
|
+
if (catItems.length === 0) {
|
|
228
|
+
lines.push('- (none)')
|
|
229
|
+
} else {
|
|
230
|
+
for (const item of catItems) {
|
|
231
|
+
const votes = item.voteCount > 0 ? ` (👍 ${item.voteCount})` : ''
|
|
232
|
+
lines.push(`- ${item.content}${votes} — _${item.author}_`)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
lines.push('')
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (actions.value.length > 0) {
|
|
239
|
+
lines.push('## 📋 Action Items')
|
|
240
|
+
lines.push('')
|
|
241
|
+
for (const a of actions.value) {
|
|
242
|
+
const check = a.status === 'done' ? '✅' : '⬜'
|
|
243
|
+
const assignee = a.assignee ? ` @${a.assignee}` : ''
|
|
244
|
+
lines.push(`- ${check} ${a.content}${assignee}`)
|
|
245
|
+
}
|
|
246
|
+
lines.push('')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
lines.push(`---`)
|
|
250
|
+
lines.push(`_Generated: ${new Date().toLocaleDateString()}_`)
|
|
251
|
+
|
|
252
|
+
return lines.join('\n')
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// -- Refresh (items + actions + session phase) --
|
|
256
|
+
let _currentUser = ''
|
|
257
|
+
|
|
258
|
+
async function refresh() {
|
|
259
|
+
if (!session.value) return
|
|
260
|
+
const r = await query<{ phase: string }>(
|
|
261
|
+
'SELECT phase FROM retro_sessions WHERE id = ?',
|
|
262
|
+
[session.value.id],
|
|
263
|
+
)
|
|
264
|
+
if (r.rows[0] && r.rows[0].phase !== session.value.phase) {
|
|
265
|
+
session.value = { ...session.value, phase: r.rows[0].phase as RetroPhase }
|
|
266
|
+
}
|
|
267
|
+
await loadItems(_currentUser)
|
|
268
|
+
await loadActions()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// -- Polling --
|
|
272
|
+
let pollTimer: ReturnType<typeof setInterval> | null = null
|
|
273
|
+
|
|
274
|
+
function startPolling(currentUser: string) {
|
|
275
|
+
_currentUser = currentUser
|
|
276
|
+
stopPolling()
|
|
277
|
+
pollTimer = setInterval(refresh, POLL_INTERVAL_MS)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function stopPolling() {
|
|
281
|
+
if (pollTimer) {
|
|
282
|
+
clearInterval(pollTimer)
|
|
283
|
+
pollTimer = null
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
onUnmounted(stopPolling)
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
session,
|
|
291
|
+
items,
|
|
292
|
+
actions,
|
|
293
|
+
loading,
|
|
294
|
+
error,
|
|
295
|
+
keepItems,
|
|
296
|
+
problemItems,
|
|
297
|
+
tryItems,
|
|
298
|
+
myVoteCount,
|
|
299
|
+
votesRemaining,
|
|
300
|
+
loadOrCreateSession,
|
|
301
|
+
setPhase,
|
|
302
|
+
addItem,
|
|
303
|
+
deleteItem,
|
|
304
|
+
toggleVote,
|
|
305
|
+
addAction,
|
|
306
|
+
toggleActionStatus,
|
|
307
|
+
resetSession,
|
|
308
|
+
exportMarkdown,
|
|
309
|
+
startPolling,
|
|
310
|
+
stopPolling,
|
|
311
|
+
refresh,
|
|
312
|
+
}
|
|
313
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { ref, provide, inject, computed } from 'vue'
|
|
2
|
+
import type { InjectionKey, Ref, ComputedRef } from 'vue'
|
|
3
|
+
import type { Scenario } from '@/data/types'
|
|
4
|
+
|
|
5
|
+
interface ScenarioCtx<T = any> {
|
|
6
|
+
scenarios: Ref<Scenario<T>[]>
|
|
7
|
+
activeScenarioId: Ref<string>
|
|
8
|
+
activeScenario: ComputedRef<Scenario<T> | undefined>
|
|
9
|
+
mockData: ComputedRef<T | undefined>
|
|
10
|
+
setScenario: (id: string) => void
|
|
11
|
+
updateScenarios: (newList: Scenario<any>[], defaultId?: string) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ScenarioKey = '__scenario__' as unknown as InjectionKey<ScenarioCtx>
|
|
15
|
+
|
|
16
|
+
export function provideScenario<T>(scenarioList: Scenario<T>[], defaultId?: string) {
|
|
17
|
+
const scenarios = ref(scenarioList) as Ref<Scenario<T>[]>
|
|
18
|
+
const activeScenarioId = ref(defaultId ?? scenarioList[0]?.id ?? '')
|
|
19
|
+
|
|
20
|
+
const activeScenario = computed(() =>
|
|
21
|
+
scenarios.value.find(s => s.id === activeScenarioId.value)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
const mockData = computed(() => activeScenario.value?.data)
|
|
25
|
+
|
|
26
|
+
function setScenario(id: string) {
|
|
27
|
+
activeScenarioId.value = id
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function updateScenarios(newList: Scenario<any>[], defaultId?: string) {
|
|
31
|
+
scenarios.value = newList as any
|
|
32
|
+
activeScenarioId.value = defaultId ?? newList[0]?.id ?? ''
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
provide(ScenarioKey, { scenarios, activeScenarioId, activeScenario, mockData, setScenario, updateScenarios } as ScenarioCtx)
|
|
36
|
+
return { scenarios, activeScenarioId, activeScenario, mockData, setScenario, updateScenarios }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useScenario<T = any>() {
|
|
40
|
+
const ctx = inject(ScenarioKey)
|
|
41
|
+
if (!ctx) throw new Error('useScenario requires a provideScenario ancestor')
|
|
42
|
+
return ctx as ScenarioCtx<T>
|
|
43
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import { query, execute } from './useTurso'
|
|
3
|
+
import type { Scenario } from '@/data/types'
|
|
4
|
+
|
|
5
|
+
export function useScenarioStore(pageId: string, sprint: string) {
|
|
6
|
+
const customScenarios = ref<Scenario<any>[]>([])
|
|
7
|
+
const loading = ref(false)
|
|
8
|
+
const error = ref<string | null>(null)
|
|
9
|
+
|
|
10
|
+
async function loadCustomScenarios(): Promise<Scenario<any>[]> {
|
|
11
|
+
loading.value = true
|
|
12
|
+
error.value = null
|
|
13
|
+
const r = await query<{
|
|
14
|
+
scenario_id: string
|
|
15
|
+
label: string
|
|
16
|
+
data_json: string
|
|
17
|
+
}>(
|
|
18
|
+
'SELECT scenario_id, label, data_json FROM scenario_data WHERE page_id = ? AND sprint = ? ORDER BY created_at ASC',
|
|
19
|
+
[pageId, sprint],
|
|
20
|
+
)
|
|
21
|
+
loading.value = false
|
|
22
|
+
if (r.error) {
|
|
23
|
+
error.value = r.error
|
|
24
|
+
customScenarios.value = []
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
const list = r.rows
|
|
28
|
+
.map((row) => {
|
|
29
|
+
try {
|
|
30
|
+
return {
|
|
31
|
+
id: String(row.scenario_id),
|
|
32
|
+
label: String(row.label),
|
|
33
|
+
data: JSON.parse(String(row.data_json)),
|
|
34
|
+
} as Scenario<any>
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
.filter((s): s is Scenario<any> => s !== null)
|
|
40
|
+
customScenarios.value = list
|
|
41
|
+
return list
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function saveScenario(scenario: Scenario<any>, author: string): Promise<boolean> {
|
|
45
|
+
error.value = null
|
|
46
|
+
const dataJson = JSON.stringify(scenario.data)
|
|
47
|
+
const res = await execute(
|
|
48
|
+
`INSERT INTO scenario_data (page_id, sprint, scenario_id, label, data_json, author)
|
|
49
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
50
|
+
ON CONFLICT(page_id, sprint, scenario_id)
|
|
51
|
+
DO UPDATE SET label = ?, data_json = ?, author = ?, updated_at = datetime('now')`,
|
|
52
|
+
[pageId, sprint, scenario.id, scenario.label, dataJson, author, scenario.label, dataJson, author],
|
|
53
|
+
)
|
|
54
|
+
if (res.error) {
|
|
55
|
+
error.value = res.error
|
|
56
|
+
return false
|
|
57
|
+
}
|
|
58
|
+
await loadCustomScenarios()
|
|
59
|
+
return true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function deleteScenario(scenarioId: string): Promise<boolean> {
|
|
63
|
+
error.value = null
|
|
64
|
+
const res = await execute(
|
|
65
|
+
'DELETE FROM scenario_data WHERE page_id = ? AND sprint = ? AND scenario_id = ?',
|
|
66
|
+
[pageId, sprint, scenarioId],
|
|
67
|
+
)
|
|
68
|
+
if (res.error) {
|
|
69
|
+
error.value = res.error
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
await loadCustomScenarios()
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function duplicateScenario(
|
|
77
|
+
source: Scenario<any>,
|
|
78
|
+
newLabel: string,
|
|
79
|
+
author: string,
|
|
80
|
+
): Promise<string> {
|
|
81
|
+
error.value = null
|
|
82
|
+
const newId = `custom-${Date.now()}`
|
|
83
|
+
const newScenario: Scenario<any> = {
|
|
84
|
+
id: newId,
|
|
85
|
+
label: newLabel,
|
|
86
|
+
data: structuredClone(source.data),
|
|
87
|
+
}
|
|
88
|
+
const ok = await saveScenario(newScenario, author)
|
|
89
|
+
if (!ok) return ''
|
|
90
|
+
return newId
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
customScenarios,
|
|
95
|
+
loading,
|
|
96
|
+
error,
|
|
97
|
+
loadCustomScenarios,
|
|
98
|
+
saveScenario,
|
|
99
|
+
deleteScenario,
|
|
100
|
+
duplicateScenario,
|
|
101
|
+
}
|
|
102
|
+
}
|