Appearance
Widget CRUD Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Transform the reporting dashboard KPI cards into a configurable 12-column grid with per-widget add, remove, reorder (DnD), and resize -- persisted in the URL.
Architecture: Widget layout state lives in a ?layout= URL param alongside existing ?m= metrics. A useWidgetLayout composable parses the URL into an ordered list of { metric, colspan } items and exposes DnD/resize mutations that update the URL reactively. Edit mode is gated behind authentication. A shared 12-column CSS Grid class is used across both the report viewer and reports list.
Tech Stack: Vue 3 Composition API, TypeScript, CSS Grid, HTML5 Drag and Drop API (no library), Vue Router query params, TanStack Vue Query (unchanged data layer)
Spec: todos/Humology/widget-crud-spec.md
File Structure
New files
| File | Responsibility |
|---|---|
src/utils/layoutUrl.ts | Parse/encode ?layout= URL param to/from LayoutItem[] |
src/composables/useWidgetLayout.ts | Reactive layout state from URL, add/remove/reorder/resize mutations |
src/composables/useEditMode.ts | Edit/view/preview mode toggle, auth-gated |
src/widgets/WidgetGrid.vue | 12-column CSS Grid container, renders widgets in layout order |
src/widgets/WidgetCard.vue | Per-widget wrapper with drag handle, X button, resize handle |
src/widgets/AddMetricDropdown.vue | "+ Add metric" dropdown for adding metrics in edit mode |
src/components/ConfirmDialog.vue | Custom confirm dialog replacing native window.confirm() |
Modified files
| File | Changes |
|---|---|
src/widgets/types.ts | Add LayoutItem type |
src/utils/reportUrl.ts | Thread layout param through parse/encode |
src/stores/currentReport.ts | Expose parsed layout |
src/views/ReportViewer.vue | Replace flat .widgets section with WidgetGrid |
src/views/ReportsListView.vue | 12-col grid for report cards, custom confirm, trash icon |
src/widgets/KpiCardGrid.vue | Accept single metric mode (one card per widget) |
src/styles/tokens.css | Add grid column count token |
Chunk 1: Layout Types, URL Parsing, and Grid Foundation
Task 1: Add layout types
Files:
Modify:
src/widgets/types.ts[ ] Step 1: Add LayoutItem type to types.ts
Add at the end of the file, before the REPORT_SCOPE_INJECTION_KEY export:
typescript
export type WidgetType = 'kpi' // phase 2 adds 'chart' | 'bar' | 'table'
export type LayoutItem = {
metricCode: string
colspan: number
widgetType: WidgetType
}1
2
3
4
5
6
7
2
3
4
5
6
7
- [ ] Step 2: Commit
bash
git add src/widgets/types.ts
git commit -m "feat: add LayoutItem type for widget grid"1
2
2
Task 2: Layout URL encoding/decoding
Files:
Create:
src/utils/layoutUrl.ts[ ] Step 1: Create layoutUrl.ts
typescript
import type { LayoutItem } from '@/widgets/types'
import { STANDARD_METRICS, BUNNY_METRICS } from '@/utils/metricCodes'
const ALL_CODES = new Set([
...Object.keys(STANDARD_METRICS),
...Object.keys(BUNNY_METRICS),
])
const DEFAULT_COLSPAN = 2
/**
* Parse `?layout=imp:3,b_clicks:2,start:4` into LayoutItem[].
* Unknown metric codes are silently dropped.
* If layout string is empty/missing, returns null (use default layout).
*/
export const parseLayout = (raw: string | undefined): LayoutItem[] | null => {
if (!raw) {
return null
}
const items: LayoutItem[] = []
for (const token of raw.split(',')) {
const t = token.trim()
if (!t) {
continue
}
// Format: metricCode:colspan or metricCode:colspan:widgetType
const parts = t.split(':')
const code = parts[0]
// Allow custom metrics (custom:name:colspan) -- "custom" prefix with name
if (code === 'custom' && parts.length >= 2) {
const name = parts[1]
const colspan = Math.min(12, Math.max(2, Number(parts[2]) || DEFAULT_COLSPAN))
items.push({ metricCode: `custom:${name}`, colspan, widgetType: 'kpi' })
continue
}
if (!ALL_CODES.has(code) && !code.startsWith('custom:')) {
continue
}
const colspan = Math.min(12, Math.max(2, Number(parts[1]) || DEFAULT_COLSPAN))
items.push({ metricCode: code, colspan, widgetType: 'kpi' })
}
return items.length > 0 ? items : null
}
/**
* Encode LayoutItem[] back to URL string.
* Omits colspan when it equals the default (2) for brevity.
*/
export const encodeLayout = (items: LayoutItem[]): string => {
return items
.map(item => {
const code = item.metricCode
if (item.colspan === DEFAULT_COLSPAN) {
return code
}
return `${code}:${item.colspan}`
})
.join(',')
}
export { DEFAULT_COLSPAN }1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
- [ ] Step 2: Commit
bash
git add src/utils/layoutUrl.ts
git commit -m "feat: layout URL encoding/decoding for widget grid"1
2
2
Task 3: Thread layout through reportUrl and currentReport
Files:
Modify:
src/utils/reportUrl.tsModify:
src/stores/currentReport.ts[ ] Step 1: Add layout to ReportState and parsing
In src/utils/reportUrl.ts, add the import and update the types:
typescript
// Add import at top
import { parseLayout, encodeLayout } from '@/utils/layoutUrl'
import type { LayoutItem } from '@/widgets/types'
// Add to ReportState interface
export interface ReportState {
workspaceId: string
resourceIds: string[]
metrics: MetricSpec[]
range?: RelativeRange
absoluteRange?: { from: string, to: string }
name: string
layout: LayoutItem[] | null // null = use default layout
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
In parseReportQuery, add before the return:
typescript
const layoutRaw = firstString(q.layout)
const layout = parseLayout(layoutRaw)
return { workspaceId: w, resourceIds, metrics, range, absoluteRange, name: n, layout }1
2
3
4
2
3
4
In encodeReportUrl, add before the const qs line:
typescript
if (state.layout && state.layout.length > 0) {
usp.set('layout', encodeLayout(state.layout))
}1
2
3
2
3
- [ ] Step 2: Expose layout in useReportStore
In src/stores/currentReport.ts, add to the return object:
typescript
const layout = computed(() => parsed.value.layout)
return {
workspaceId,
resourceIds,
metrics,
name,
dateRange,
broadestRange,
rangeMode: computed(() => parsed.value.range ?? null),
absoluteRange: computed(() => parsed.value.absoluteRange ?? null),
layout,
}1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
- [ ] Step 3: Commit
bash
git add src/utils/reportUrl.ts src/stores/currentReport.ts
git commit -m "feat: thread layout param through URL parsing and report store"1
2
2
Task 4: Grid token and base CSS
Files:
Modify:
src/styles/tokens.css[ ] Step 1: Add grid token
Add to the :root block in tokens.css, under the Layout section:
css
--grid-columns: 12;
--grid-gap: var(--space-4);1
2
2
- [ ] Step 2: Commit
bash
git add src/styles/tokens.css
git commit -m "feat: add grid column and gap design tokens"1
2
2
Chunk 2: Edit Mode and Widget Grid
Task 5: Edit mode composable
Files:
Create:
src/composables/useEditMode.ts[ ] Step 1: Create useEditMode.ts
typescript
import { ref, computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
export type EditModeState = 'view' | 'edit' | 'preview'
/**
* Manages edit/view/preview mode for the report viewer.
* Edit mode is only available for authenticated users.
*/
export const useEditMode = () => {
const auth = useAuthStore()
const mode = ref<EditModeState>('view')
const canEdit = computed(() => !!auth.user)
const isEditing = computed(() => mode.value === 'edit')
const isPreviewing = computed(() => mode.value === 'preview')
const isViewOnly = computed(() => mode.value === 'view')
const enterEdit = () => {
if (canEdit.value) {
mode.value = 'edit'
}
}
const enterPreview = () => {
mode.value = 'preview'
}
const exitEdit = () => {
mode.value = 'view'
}
const exitPreview = () => {
mode.value = 'edit'
}
return {
mode,
canEdit,
isEditing,
isPreviewing,
isViewOnly,
enterEdit,
enterPreview,
exitEdit,
exitPreview,
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
- [ ] Step 2: Commit
bash
git add src/composables/useEditMode.ts
git commit -m "feat: edit mode composable with auth gating"1
2
2
Task 6: Widget layout composable
Files:
Create:
src/composables/useWidgetLayout.ts[ ] Step 1: Create useWidgetLayout.ts
typescript
import { computed, type Ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import type { MetricSpec } from '@/widgets/types'
import type { LayoutItem } from '@/widgets/types'
import { encodeLayout, DEFAULT_COLSPAN } from '@/utils/layoutUrl'
import { STANDARD_METRICS, BUNNY_METRICS, encodeMetricCodes } from '@/utils/metricCodes'
// Reverse lookup: metric key+source -> code
const metricToCode = (m: MetricSpec): string | null => {
for (const [code, spec] of Object.entries(STANDARD_METRICS)) {
if (spec.key === m.key && spec.source === m.source) return code
}
for (const [code, spec] of Object.entries(BUNNY_METRICS)) {
if (spec.key === m.key && spec.source === m.source) return code
}
// Custom metrics
if (m.source === 'bunny') return `custom:${m.key}`
return null
}
// Forward lookup: code -> MetricSpec
const codeToMetric = (code: string): MetricSpec | null => {
if (code.startsWith('custom:')) {
const name = code.slice('custom:'.length)
return { key: name, label: name.replace(/_/g, ' '), format: 'number', source: 'bunny' }
}
const std = STANDARD_METRICS[code]
if (std) return std
const bunny = BUNNY_METRICS[code]
if (bunny) return bunny
return null
}
type UseWidgetLayoutArgs = {
metrics: Ref<MetricSpec[]>
layout: Ref<LayoutItem[] | null>
}
export type ResolvedWidget = {
metric: MetricSpec
metricCode: string
colspan: number
}
/**
* Manages the widget layout as an ordered list of resolved widgets.
* Mutations update the URL query params directly (URL is source of truth).
*/
export const useWidgetLayout = (args: UseWidgetLayoutArgs) => {
const router = useRouter()
const route = useRoute()
// Resolve layout items to full MetricSpec objects.
// If no layout param exists, derive from metrics in default order.
const widgets = computed<ResolvedWidget[]>(() => {
const layout = args.layout.value
if (layout) {
const resolved: ResolvedWidget[] = []
for (const item of layout) {
const metric = codeToMetric(item.metricCode)
if (metric) {
resolved.push({
metric,
metricCode: item.metricCode,
colspan: item.colspan,
})
}
}
return resolved
}
// Default: all metrics as 2-colspan KPI cards
return args.metrics.value
.map(m => {
const code = metricToCode(m)
if (!code) return null
return { metric: m, metricCode: code, colspan: DEFAULT_COLSPAN }
})
.filter((w): w is ResolvedWidget => w !== null)
})
// -- Mutations (update URL) --
const updateUrl = (newWidgets: ResolvedWidget[]) => {
const layoutItems: LayoutItem[] = newWidgets.map(w => ({
metricCode: w.metricCode,
colspan: w.colspan,
widgetType: 'kpi' as const,
}))
// Also update the metrics list to match (removed widgets = removed metrics)
const newMetrics = newWidgets.map(w => w.metric)
const newQuery = {
...route.query,
layout: encodeLayout(layoutItems),
m: encodeMetricCodes(newMetrics),
}
void router.replace({ query: newQuery })
}
const removeWidget = (index: number) => {
const next = [...widgets.value]
next.splice(index, 1)
updateUrl(next)
}
const addWidget = (metric: MetricSpec) => {
const code = metricToCode(metric)
if (!code) return
const next = [
...widgets.value,
{ metric, metricCode: code, colspan: DEFAULT_COLSPAN },
]
updateUrl(next)
}
const resizeWidget = (index: number, newColspan: number) => {
const clamped = Math.min(12, Math.max(2, newColspan))
const next = [...widgets.value]
next[index] = { ...next[index], colspan: clamped }
updateUrl(next)
}
const moveWidget = (fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex) return
const next = [...widgets.value]
const [moved] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, moved)
updateUrl(next)
}
// Metrics available for adding (not already in the layout)
const availableMetrics = computed(() => {
const usedKeys = new Set(widgets.value.map(w => `${w.metric.source}:${w.metric.key}`))
const all = [
...Object.values(STANDARD_METRICS),
...Object.values(BUNNY_METRICS),
]
return all.filter(m => !usedKeys.has(`${m.source}:${m.key}`))
})
return {
widgets,
availableMetrics,
removeWidget,
addWidget,
resizeWidget,
moveWidget,
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
- [ ] Step 2: Commit
bash
git add src/composables/useWidgetLayout.ts
git commit -m "feat: widget layout composable with URL-based mutations"1
2
2
Task 7: WidgetCard -- per-widget wrapper with edit controls
Files:
Create:
src/widgets/WidgetCard.vue[ ] Step 1: Create WidgetCard.vue
vue
<script setup lang="ts">
import { ref } from 'vue'
import type { MetricSpec } from './types'
import { useReportStore } from '@/stores/currentReport'
import { useReportTotals } from '@/composables/useReportTotals'
import { formatByMetricSpec } from '@/utils/formatters'
const props = defineProps<{
metric: MetricSpec
colspan: number
index: number
editing: boolean
}>()
const emit = defineEmits<{
remove: [index: number]
resize: [index: number, colspan: number]
dragstart: [index: number, event: DragEvent]
dragover: [index: number, event: DragEvent]
drop: [index: number, event: DragEvent]
}>()
const report = useReportStore()
const { data, isLoading } = useReportTotals({
resourceIds: report.resourceIds,
dateRange: report.dateRange,
metrics: () => [props.metric],
})
const totalFor = (key: string): number => {
let sum = 0
for (const inner of data.value.values()) {
sum += inner.get(key) ?? 0
}
return sum
}
const isZero = () => totalFor(props.metric.key) === 0
// -- Resize --
const resizing = ref(false)
const startX = ref(0)
const startColspan = ref(0)
const onResizeStart = (e: PointerEvent) => {
e.preventDefault()
resizing.value = true
startX.value = e.clientX
startColspan.value = props.colspan
const el = (e.target as HTMLElement).closest('.widget-card') as HTMLElement
const colWidth = el.offsetWidth / props.colspan
const onMove = (moveEvent: PointerEvent) => {
const delta = moveEvent.clientX - startX.value
const colDelta = Math.round(delta / colWidth)
const newColspan = Math.min(12, Math.max(2, startColspan.value + colDelta))
if (newColspan !== props.colspan) {
emit('resize', props.index, newColspan)
}
}
const onUp = () => {
resizing.value = false
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
// -- Drag --
const onDragStart = (e: DragEvent) => {
if (!props.editing) return
e.dataTransfer?.setData('text/plain', String(props.index))
emit('dragstart', props.index, e)
}
const onDragOver = (e: DragEvent) => {
e.preventDefault()
emit('dragover', props.index, e)
}
const onDrop = (e: DragEvent) => {
e.preventDefault()
emit('drop', props.index, e)
}
</script>
<template>
<article
class="widget-card"
:class="{ editing, zero: isZero(), resizing }"
:style="{ gridColumn: `span ${colspan}` }"
:draggable="editing"
@dragstart="onDragStart"
@dragover="onDragOver"
@drop="onDrop"
>
<div
v-if="editing"
class="drag-handle"
title="Drag to reorder"
>
<span class="drag-icon">☰</span>
</div>
<button
v-if="editing"
type="button"
class="remove-btn"
title="Remove widget"
@click="$emit('remove', index)"
>
<span class="remove-icon">×</span>
</button>
<header class="label">
{{ metric.label }}
</header>
<div
v-if="isLoading"
class="value loading"
>
...
</div>
<div
v-else
class="value"
>
{{ formatByMetricSpec(totalFor(metric.key), metric) }}
</div>
<div
v-if="editing"
class="resize-handle"
title="Drag to resize"
@pointerdown="onResizeStart"
>
<span class="resize-icon">⤢</span>
</div>
</article>
</template>
<style scoped>
.widget-card {
position: relative;
background: var(--color-bg-card);
border-radius: var(--radius-md);
padding: var(--space-4);
box-shadow: var(--shadow-sm);
min-height: 100px;
display: flex;
flex-direction: column;
justify-content: center;
transition: opacity 0.2s;
}
.widget-card.zero {
opacity: 0.4;
}
.widget-card.editing {
cursor: grab;
border: 2px dashed transparent;
}
.widget-card.editing:hover {
border-color: var(--color-primary);
}
.widget-card.resizing {
cursor: col-resize;
}
.label {
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-muted);
margin-bottom: var(--space-2);
}
.value {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-text);
}
.value.loading {
color: var(--color-text-muted);
}
/* -- Edit controls -- */
.drag-handle {
position: absolute;
top: var(--space-2);
left: var(--space-2);
cursor: grab;
color: var(--color-text-muted);
font-size: 14px;
opacity: 0.5;
}
.drag-handle:hover {
opacity: 1;
}
.remove-btn {
position: absolute;
top: var(--space-2);
right: var(--space-2);
background: transparent;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 18px;
line-height: 1;
padding: 2px 6px;
border-radius: var(--radius-sm);
}
.remove-btn:hover {
color: var(--color-error);
background: rgba(195, 59, 59, 0.08);
}
.resize-handle {
position: absolute;
bottom: var(--space-1);
right: var(--space-1);
cursor: col-resize;
color: var(--color-text-muted);
font-size: 14px;
opacity: 0.4;
padding: 2px;
user-select: none;
touch-action: none;
}
.resize-handle:hover {
opacity: 1;
}
</style>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
- [ ] Step 2: Commit
bash
git add src/widgets/WidgetCard.vue
git commit -m "feat: WidgetCard with drag, remove, and resize controls"1
2
2
Task 8: AddMetricDropdown
Files:
Create:
src/widgets/AddMetricDropdown.vue[ ] Step 1: Create AddMetricDropdown.vue
vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { MetricSpec } from './types'
const props = defineProps<{
available: MetricSpec[]
}>()
const emit = defineEmits<{
add: [metric: MetricSpec]
}>()
const open = ref(false)
const search = ref('')
const filtered = computed(() => {
const q = search.value.toLowerCase()
if (!q) return props.available
return props.available.filter(m => m.label.toLowerCase().includes(q))
})
const grouped = computed(() => {
const agg = filtered.value.filter(m => m.source === 'aggregation')
const bunny = filtered.value.filter(m => m.source === 'bunny')
return { aggregation: agg, bunny }
})
const select = (m: MetricSpec) => {
emit('add', m)
open.value = false
search.value = ''
}
const toggle = () => {
open.value = !open.value
if (!open.value) {
search.value = ''
}
}
</script>
<template>
<div
class="add-metric-cell"
:style="{ gridColumn: 'span 2' }"
>
<button
type="button"
class="add-btn"
@click="toggle"
>
+ Add metric
</button>
<div
v-if="open"
class="dropdown"
>
<input
v-model="search"
type="text"
class="search"
placeholder="Search metrics..."
@click.stop
>
<div
v-if="filtered.length === 0"
class="empty"
>
No metrics available
</div>
<template v-if="grouped.aggregation.length > 0">
<div class="group-label">
Standard
</div>
<button
v-for="m in grouped.aggregation"
:key="m.key"
type="button"
class="option"
@click="select(m)"
>
{{ m.label }}
</button>
</template>
<template v-if="grouped.bunny.length > 0">
<div class="group-label">
Bunny Analytics
</div>
<button
v-for="m in grouped.bunny"
:key="m.key"
type="button"
class="option"
@click="select(m)"
>
{{ m.label }}
</button>
</template>
</div>
</div>
</template>
<style scoped>
.add-metric-cell {
position: relative;
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
}
.add-btn {
background: transparent;
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-muted);
font: inherit;
font-weight: var(--font-weight-bold);
padding: var(--space-4);
width: 100%;
height: 100%;
min-height: 100px;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.add-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
max-height: 320px;
overflow-y: auto;
margin-top: var(--space-1);
}
.search {
width: 100%;
border: none;
border-bottom: 1px solid var(--color-border);
padding: var(--space-3);
font: inherit;
font-size: var(--font-size-sm);
outline: none;
background: transparent;
}
.group-label {
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.option {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: none;
padding: var(--space-2) var(--space-3) var(--space-2) var(--space-5);
font: inherit;
font-size: var(--font-size-sm);
cursor: pointer;
}
.option:hover {
background: rgba(255, 91, 80, 0.06);
}
.empty {
padding: var(--space-4);
text-align: center;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
</style>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
- [ ] Step 2: Commit
bash
git add src/widgets/AddMetricDropdown.vue
git commit -m "feat: AddMetricDropdown for adding metrics in edit mode"1
2
2
Task 9: WidgetGrid -- the 12-column grid container
Files:
Create:
src/widgets/WidgetGrid.vue[ ] Step 1: Create WidgetGrid.vue
vue
<script setup lang="ts">
import { ref } from 'vue'
import type { MetricSpec } from './types'
import type { ResolvedWidget } from '@/composables/useWidgetLayout'
import WidgetCard from './WidgetCard.vue'
import AddMetricDropdown from './AddMetricDropdown.vue'
const props = defineProps<{
widgets: ResolvedWidget[]
availableMetrics: MetricSpec[]
editing: boolean
}>()
const emit = defineEmits<{
remove: [index: number]
resize: [index: number, colspan: number]
move: [fromIndex: number, toIndex: number]
add: [metric: MetricSpec]
}>()
// -- DnD state --
const dragIndex = ref<number | null>(null)
const dropTargetIndex = ref<number | null>(null)
const onDragStart = (index: number, _e: DragEvent) => {
dragIndex.value = index
}
const onDragOver = (index: number, _e: DragEvent) => {
dropTargetIndex.value = index
}
const onDrop = (toIndex: number, _e: DragEvent) => {
if (dragIndex.value !== null && dragIndex.value !== toIndex) {
emit('move', dragIndex.value, toIndex)
}
dragIndex.value = null
dropTargetIndex.value = null
}
const onDragEnd = () => {
dragIndex.value = null
dropTargetIndex.value = null
}
</script>
<template>
<div
class="widget-grid"
@dragend="onDragEnd"
>
<WidgetCard
v-for="(w, i) in widgets"
:key="w.metricCode"
:metric="w.metric"
:colspan="w.colspan"
:index="i"
:editing="editing"
:class="{ 'drop-target': dropTargetIndex === i && dragIndex !== i }"
@remove="(idx) => $emit('remove', idx)"
@resize="(idx, cs) => $emit('resize', idx, cs)"
@dragstart="onDragStart"
@dragover="onDragOver"
@drop="onDrop"
/>
<AddMetricDropdown
v-if="editing"
:available="availableMetrics"
@add="(m) => $emit('add', m)"
/>
</div>
</template>
<style scoped>
.widget-grid {
display: grid;
grid-template-columns: repeat(var(--grid-columns, 12), 1fr);
gap: var(--grid-gap, var(--space-4));
}
.drop-target {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
@media (max-width: 900px) {
.widget-grid {
grid-template-columns: repeat(6, 1fr);
}
}
@media (max-width: 600px) {
.widget-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
- [ ] Step 2: Commit
bash
git add src/widgets/WidgetGrid.vue
git commit -m "feat: WidgetGrid 12-column container with DnD support"1
2
2
Chunk 3: Integration, Reports List, and Confirm Dialog
Task 10: Custom ConfirmDialog
Files:
Create:
src/components/ConfirmDialog.vue[ ] Step 1: Create ConfirmDialog.vue
vue
<script setup lang="ts">
defineProps<{
title: string
message: string
confirmLabel?: string
cancelLabel?: string
}>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
</script>
<template>
<Teleport to="body">
<div
class="overlay"
@click.self="$emit('cancel')"
>
<div
class="dialog"
role="alertdialog"
aria-modal="true"
>
<h3 class="title">
{{ title }}
</h3>
<p class="message">
{{ message }}
</p>
<div class="actions">
<button
type="button"
class="btn cancel"
@click="$emit('cancel')"
>
{{ cancelLabel ?? 'Cancel' }}
</button>
<button
type="button"
class="btn confirm"
@click="$emit('confirm')"
>
{{ confirmLabel ?? 'Delete' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.dialog {
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-md);
padding: var(--space-5);
max-width: 400px;
width: 90%;
}
.title {
margin: 0 0 var(--space-2);
font-size: var(--font-size-lg);
}
.message {
margin: 0 0 var(--space-5);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
line-height: 1.5;
}
.actions {
display: flex;
gap: var(--space-3);
justify-content: flex-end;
}
.btn {
border: none;
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-4);
font: inherit;
font-weight: var(--font-weight-bold);
cursor: pointer;
}
.cancel {
background: transparent;
color: var(--color-text-muted);
}
.cancel:hover {
background: rgba(0, 0, 0, 0.04);
}
.confirm {
background: var(--color-error);
color: #fff;
}
.confirm:hover {
opacity: 0.9;
}
</style>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
- [ ] Step 2: Commit
bash
git add src/components/ConfirmDialog.vue
git commit -m "feat: custom ConfirmDialog replacing native confirm()"1
2
2
Task 11: Update ReportsListView -- 12-col grid, custom confirm, trash icon
Files:
Modify:
src/views/ReportsListView.vue[ ] Step 1: Replace confirm and grid
Replace confirmAndRemove:
typescript
// Add import at top
import ConfirmDialog from '@/components/ConfirmDialog.vue'
// Replace confirmAndRemove with state-based approach
const pendingDelete = ref<{ id: string, name: string } | null>(null)
function requestDelete(id: string, name: string): void {
pendingDelete.value = { id, name }
}
function executeDelete(): void {
if (pendingDelete.value) {
reportsStore.remove(pendingDelete.value.id)
}
pendingDelete.value = null
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Replace the .report-list template section with:
html
<ul
v-else
class="report-list"
role="list"
>
<li
v-for="r in visibleReports"
:key="r.id"
style="grid-column: span 4"
>
<article class="report-card">
<button
type="button"
class="open-btn"
@click="openReport(r.url, r.id)"
>
<h3 class="report-name">
{{ r.name }}
</h3>
<p class="meta">
Last opened {{ formatDate(r.lastOpened) }}
</p>
</button>
<button
type="button"
class="delete-btn"
:aria-label="`Delete ${r.name}`"
@click="requestDelete(r.id, r.name)"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 4h12M5.33 4V2.67a1.33 1.33 0 011.34-1.34h2.66a1.33 1.33 0 011.34 1.34V4m2 0v9.33a1.33 1.33 0 01-1.34 1.34H4.67a1.33 1.33 0 01-1.34-1.34V4h9.34z" />
</svg>
</button>
</article>
</li>
</ul>
<ConfirmDialog
v-if="pendingDelete"
title="Delete report"
:message="`Remove "${pendingDelete.name}" from this browser? This cannot be undone.`"
@confirm="executeDelete"
@cancel="pendingDelete = null"
/>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
Replace .report-list CSS:
css
.report-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(var(--grid-columns, 12), 1fr);
gap: var(--grid-gap, var(--space-4));
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
- [ ] Step 2: Commit
bash
git add src/views/ReportsListView.vue
git commit -m "feat: reports list uses 12-col grid, custom confirm, trash icon"1
2
2
Task 12: Integrate WidgetGrid into ReportViewer
Files:
Modify:
src/views/ReportViewer.vue[ ] Step 1: Replace the widgets section
Add imports at the top of <script setup>:
typescript
import WidgetGrid from '@/widgets/WidgetGrid.vue'
import { useEditMode } from '@/composables/useEditMode'
import { useWidgetLayout } from '@/composables/useWidgetLayout'1
2
3
2
3
Add after the provide(REPORT_SCOPE_INJECTION_KEY, reactiveScope) line:
typescript
// -- Edit mode --
const {
canEdit, isEditing, isPreviewing,
enterEdit, enterPreview, exitEdit, exitPreview,
} = useEditMode()
// -- Widget layout --
const {
widgets: layoutWidgets,
availableMetrics,
removeWidget,
addWidget,
resizeWidget,
moveWidget,
} = useWidgetLayout({
metrics: report.metrics,
layout: report.layout,
})1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Replace the <section class="widgets"> block in the template:
html
<!-- Edit mode toolbar -->
<div
v-if="isEditing || isPreviewing"
class="edit-toolbar"
>
<template v-if="isEditing">
<button type="button" class="toolbar-btn" @click="enterPreview">
Preview
</button>
<button type="button" class="toolbar-btn primary" @click="exitEdit">
Done
</button>
</template>
<template v-else>
<button type="button" class="toolbar-btn primary" @click="exitPreview">
Back to editing
</button>
</template>
</div>
<section
v-else-if="canEdit"
class="edit-entry"
>
<!-- Keep the existing Edit report button but also add edit-grid entry -->
</section>
<WidgetGrid
v-if="!isPreviewing || isEditing ? true : true"
:widgets="layoutWidgets"
:available-metrics="availableMetrics"
:editing="isEditing"
@remove="removeWidget"
@resize="resizeWidget"
@move="moveWidget"
@add="addWidget"
/>1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
Update the ReportToolbar to emit an edit-layout event alongside the existing edit event:
html
<ReportToolbar
:name="report.name.value"
:date-range="report.dateRange.value"
:can-edit-layout="canEdit"
:is-editing="isEditing"
@edit="editOpen = true"
@edit-layout="enterEdit"
/>1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
Add styles:
css
.edit-toolbar {
display: flex;
gap: var(--space-3);
justify-content: flex-end;
margin-bottom: var(--space-4);
}
.toolbar-btn {
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-4);
font: inherit;
font-weight: var(--font-weight-bold);
cursor: pointer;
}
.toolbar-btn.primary {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.toolbar-btn:hover {
opacity: 0.9;
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Remove the old activeView computed and the WidgetHost v-for loop since WidgetGrid now handles rendering.
- [ ] Step 2: Update ReportToolbar to include edit-layout button
In src/components/ReportToolbar.vue, add an "Edit layout" button next to the existing "Edit report" button when canEditLayout is true and isEditing is false:
html
<button
v-if="canEditLayout && !isEditing"
type="button"
class="edit-layout-btn"
@click="$emit('edit-layout')"
>
Edit layout
</button>1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
- [ ] Step 3: Commit
bash
git add src/views/ReportViewer.vue src/components/ReportToolbar.vue
git commit -m "feat: integrate WidgetGrid into ReportViewer with edit mode"1
2
2
Task 13: Keep engagement funnel and time-series as optional widgets
Files:
Modify:
src/views/ReportViewer.vue[ ] Step 1: Render engagement funnel and chart below the grid
The WidgetGrid handles KPI cards. The engagement funnel and time-series chart remain below the grid as standalone sections (they are multi-metric widgets, not individual cards). Keep them in the template after <WidgetGrid>:
html
<!-- Engagement funnel (aggregation metrics only) -->
<EngagementFunnel
v-if="report.metrics.value.some(m => m.source === 'aggregation')"
/>
<!-- Time-series chart (first metric) -->
<TimeSeriesChart
v-if="report.metrics.value.length > 0"
:metric="report.metrics.value[0]"
/>
<!-- Per-resource breakdown -->
<PerResourceBreakdown />1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
Add the imports for these components (they were previously loaded via WidgetHost):
typescript
import EngagementFunnel from '@/widgets/EngagementFunnel.vue'
import TimeSeriesChart from '@/widgets/TimeSeriesChart.vue'
import PerResourceBreakdown from '@/widgets/PerResourceBreakdown.vue'1
2
3
2
3
- [ ] Step 2: Commit
bash
git add src/views/ReportViewer.vue
git commit -m "feat: keep funnel and chart as standalone widgets below grid"1
2
2
Task 14: Clean up -- remove unused KpiCardGrid
Files:
Modify:
src/widgets/KpiCardGrid.vue(keep for backward compat but it's no longer used by ReportViewer)Modify:
src/widgets/registry.ts[ ] Step 1: Verify KpiCardGrid is no longer imported by ReportViewer
Check that ReportViewer.vue no longer references kpi-card-grid in the activeView computed. If the old activeView computed has been removed, KpiCardGrid is now unused by the viewer (WidgetCard renders individual cards instead).
Keep KpiCardGrid.vue in the codebase for now -- it may be useful for non-grid contexts.
- [ ] Step 2: Commit (if any cleanup needed)
bash
git add -A
git commit -m "chore: clean up unused widget references after grid migration"1
2
2
Summary
| Task | What | Files |
|---|---|---|
| 1 | LayoutItem type | types.ts |
| 2 | Layout URL encode/decode | layoutUrl.ts (new) |
| 3 | Thread layout through URL and store | reportUrl.ts, currentReport.ts |
| 4 | Grid CSS tokens | tokens.css |
| 5 | Edit mode composable | useEditMode.ts (new) |
| 6 | Widget layout composable | useWidgetLayout.ts (new) |
| 7 | WidgetCard with edit controls | WidgetCard.vue (new) |
| 8 | AddMetricDropdown | AddMetricDropdown.vue (new) |
| 9 | WidgetGrid container | WidgetGrid.vue (new) |
| 10 | Custom ConfirmDialog | ConfirmDialog.vue (new) |
| 11 | Reports list grid + confirm | ReportsListView.vue |
| 12 | Integrate grid into viewer | ReportViewer.vue, ReportToolbar.vue |
| 13 | Keep funnel/chart below grid | ReportViewer.vue |
| 14 | Clean up old widget code | registry.ts |