Skip to content

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

FileResponsibility
src/utils/layoutUrl.tsParse/encode ?layout= URL param to/from LayoutItem[]
src/composables/useWidgetLayout.tsReactive layout state from URL, add/remove/reorder/resize mutations
src/composables/useEditMode.tsEdit/view/preview mode toggle, auth-gated
src/widgets/WidgetGrid.vue12-column CSS Grid container, renders widgets in layout order
src/widgets/WidgetCard.vuePer-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.vueCustom confirm dialog replacing native window.confirm()

Modified files

FileChanges
src/widgets/types.tsAdd LayoutItem type
src/utils/reportUrl.tsThread layout param through parse/encode
src/stores/currentReport.tsExpose parsed layout
src/views/ReportViewer.vueReplace flat .widgets section with WidgetGrid
src/views/ReportsListView.vue12-col grid for report cards, custom confirm, trash icon
src/widgets/KpiCardGrid.vueAccept single metric mode (one card per widget)
src/styles/tokens.cssAdd 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
}
  • [ ] Step 2: Commit
bash
git add src/widgets/types.ts
git commit -m "feat: add LayoutItem type for widget grid"

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 }
  • [ ] Step 2: Commit
bash
git add src/utils/layoutUrl.ts
git commit -m "feat: layout URL encoding/decoding for widget grid"

Task 3: Thread layout through reportUrl and currentReport

Files:

  • Modify: src/utils/reportUrl.ts

  • Modify: 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
}

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 }

In encodeReportUrl, add before the const qs line:

typescript
  if (state.layout && state.layout.length > 0) {
    usp.set('layout', encodeLayout(state.layout))
  }
  • [ ] 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,
  }
  • [ ] 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"

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);
  • [ ] Step 2: Commit
bash
git add src/styles/tokens.css
git commit -m "feat: add grid column and gap design tokens"

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,
  }
}
  • [ ] Step 2: Commit
bash
git add src/composables/useEditMode.ts
git commit -m "feat: edit mode composable with auth gating"

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,
  }
}
  • [ ] Step 2: Commit
bash
git add src/composables/useWidgetLayout.ts
git commit -m "feat: widget layout composable with URL-based mutations"

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">&#x2630;</span>
    </div>

    <button
      v-if="editing"
      type="button"
      class="remove-btn"
      title="Remove widget"
      @click="$emit('remove', index)"
    >
      <span class="remove-icon">&times;</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">&#x2922;</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>
  • [ ] Step 2: Commit
bash
git add src/widgets/WidgetCard.vue
git commit -m "feat: WidgetCard with drag, remove, and resize controls"

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>
  • [ ] Step 2: Commit
bash
git add src/widgets/AddMetricDropdown.vue
git commit -m "feat: AddMetricDropdown for adding metrics in edit mode"

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>
  • [ ] Step 2: Commit
bash
git add src/widgets/WidgetGrid.vue
git commit -m "feat: WidgetGrid 12-column container with DnD support"

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>
  • [ ] Step 2: Commit
bash
git add src/components/ConfirmDialog.vue
git commit -m "feat: custom ConfirmDialog replacing native confirm()"

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
}

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 &quot;${pendingDelete.name}&quot; from this browser? This cannot be undone.`"
        @confirm="executeDelete"
        @cancel="pendingDelete = null"
      />

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));
}
  • [ ] Step 2: Commit
bash
git add src/views/ReportsListView.vue
git commit -m "feat: reports list uses 12-col grid, custom confirm, trash icon"

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'

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,
})

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"
        />

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"
        />

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;
}

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>
  • [ ] 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"

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 />

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'
  • [ ] Step 2: Commit
bash
git add src/views/ReportViewer.vue
git commit -m "feat: keep funnel and chart as standalone widgets below grid"

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"

Summary

TaskWhatFiles
1LayoutItem typetypes.ts
2Layout URL encode/decodelayoutUrl.ts (new)
3Thread layout through URL and storereportUrl.ts, currentReport.ts
4Grid CSS tokenstokens.css
5Edit mode composableuseEditMode.ts (new)
6Widget layout composableuseWidgetLayout.ts (new)
7WidgetCard with edit controlsWidgetCard.vue (new)
8AddMetricDropdownAddMetricDropdown.vue (new)
9WidgetGrid containerWidgetGrid.vue (new)
10Custom ConfirmDialogConfirmDialog.vue (new)
11Reports list grid + confirmReportsListView.vue
12Integrate grid into viewerReportViewer.vue, ReportToolbar.vue
13Keep funnel/chart below gridReportViewer.vue
14Clean up old widget coderegistry.ts

Internal documentation