Files
zeroclock/docs/plans/2026-02-17-custom-dropdowns-datepickers-implementation.md

31 KiB

Custom Dropdowns & Date Pickers Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Replace all 6 native <select> and 6 native <input type="date"> elements with custom Vue 3 components matching ZeroClock's dark UI.

Architecture: Two new components (AppSelect, AppDatePicker) using <Teleport to="body"> for overflow-safe positioning. Each renders a styled trigger + a floating panel positioned via getBoundingClientRect(). Click-outside detection and keyboard navigation built in. Then swap every native element across 5 views.

Tech Stack: Vue 3 Composition API (<script setup lang="ts">), Tailwind CSS v4 with @theme tokens, Lucide Vue icons, <Teleport>.


Task 1: Create AppSelect Component

Files:

  • Create: src/components/AppSelect.vue

Context: This component replaces all 6 native <select> elements. It needs to support: v-model with any value type (number, string, null, undefined), an array of option objects with configurable label/value keys, a placeholder, and a disabled state. The dropdown panel uses <Teleport to="body"> to avoid overflow clipping from modals and scrollable containers.

Existing patterns to follow:

  • Input styling: bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-text-primary
  • Focus: border-color: var(--color-accent) with box-shadow: 0 0 0 2px var(--color-accent-muted) (defined in main.css:60-64)
  • Icons: Import from lucide-vue-next (see Timer.vue:121)
  • Animations: 200ms ease-out (see main.css:107-119 for modal-enter pattern)

Step 1: Create the component file

Write src/components/AppSelect.vue with this complete implementation:

<template>
  <div ref="containerRef" class="relative">
    <!-- Trigger button -->
    <button
      ref="triggerRef"
      type="button"
      @click="toggle"
      @keydown="handleTriggerKeydown"
      :disabled="disabled"
      class="w-full flex items-center justify-between px-3 py-2 bg-bg-inset border rounded-xl text-[0.8125rem] text-left transition-colors duration-150 focus:outline-none"
      :class="[
        isOpen ? 'border-accent shadow-[0_0_0_2px_var(--color-accent-muted)]' : 'border-border-subtle',
        disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer hover:border-border-visible'
      ]"
    >
      <span :class="selectedLabel ? 'text-text-primary' : 'text-text-tertiary'">
        {{ selectedLabel || placeholder }}
      </span>
      <ChevronDown
        class="w-3.5 h-3.5 text-text-tertiary shrink-0 ml-2 transition-transform duration-150"
        :class="{ 'rotate-180': isOpen }"
        :stroke-width="2"
      />
    </button>

    <!-- Dropdown panel (teleported to body) -->
    <Teleport to="body">
      <div
        v-if="isOpen"
        ref="panelRef"
        class="fixed z-[100] py-1 bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-y-auto animate-dropdown-enter"
        :style="panelStyle"
        @keydown="handlePanelKeydown"
      >
        <button
          v-for="(option, index) in allOptions"
          :key="getOptionValue(option)"
          type="button"
          @click="selectOption(option)"
          @mouseenter="highlightedIndex = index"
          class="w-full flex items-center justify-between px-3 py-2 text-[0.8125rem] text-left transition-colors duration-100"
          :class="[
            index === highlightedIndex ? 'bg-bg-elevated' : '',
            isSelected(option) ? 'text-accent-text' : 'text-text-primary'
          ]"
        >
          <span>{{ getOptionLabel(option) }}</span>
          <Check
            v-if="isSelected(option)"
            class="w-3.5 h-3.5 text-accent shrink-0 ml-2"
            :stroke-width="2"
          />
        </button>
      </div>
    </Teleport>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { ChevronDown, Check } from 'lucide-vue-next'

interface Props {
  modelValue: any
  options: any[]
  labelKey?: string
  valueKey?: string
  placeholder?: string
  disabled?: boolean
  placeholderValue?: any
}

const props = withDefaults(defineProps<Props>(), {
  labelKey: 'name',
  valueKey: 'id',
  placeholder: 'Select...',
  disabled: false,
  placeholderValue: undefined
})

const emit = defineEmits<{
  'update:modelValue': [value: any]
}>()

const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLButtonElement>()
const panelRef = ref<HTMLElement>()
const isOpen = ref(false)
const highlightedIndex = ref(0)
const panelStyle = ref<Record<string, string>>({})

// Build full options list (placeholder option + real options)
const allOptions = computed(() => {
  if (props.placeholder) {
    return [{ __placeholder: true, [props.labelKey]: props.placeholder, [props.valueKey]: props.placeholderValue }, ...props.options]
  }
  return props.options
})

// Get option value
function getOptionValue(option: any): any {
  if (option.__placeholder) return props.placeholderValue
  return option[props.valueKey]
}

// Get option label
function getOptionLabel(option: any): string {
  return option[props.labelKey] || ''
}

// Check if option is selected
function isSelected(option: any): boolean {
  return getOptionValue(option) === props.modelValue
}

// Selected label for trigger display
const selectedLabel = computed(() => {
  if (props.modelValue === null || props.modelValue === undefined || props.modelValue === props.placeholderValue) {
    return ''
  }
  const found = props.options.find(o => o[props.valueKey] === props.modelValue)
  return found ? found[props.labelKey] : ''
})

// Position the panel below the trigger
function positionPanel() {
  if (!triggerRef.value) return
  const rect = triggerRef.value.getBoundingClientRect()
  panelStyle.value = {
    top: `${rect.bottom + 4}px`,
    left: `${rect.left}px`,
    width: `${rect.width}px`,
    maxHeight: '240px'
  }
}

// Toggle open/close
function toggle() {
  if (props.disabled) return
  if (isOpen.value) {
    close()
  } else {
    open()
  }
}

function open() {
  isOpen.value = true
  // Set highlighted to current selection
  const idx = allOptions.value.findIndex(o => isSelected(o))
  highlightedIndex.value = idx >= 0 ? idx : 0
  nextTick(() => {
    positionPanel()
    document.addEventListener('click', handleClickOutside, true)
    document.addEventListener('scroll', positionPanel, true)
    window.addEventListener('resize', positionPanel)
  })
}

function close() {
  isOpen.value = false
  document.removeEventListener('click', handleClickOutside, true)
  document.removeEventListener('scroll', positionPanel, true)
  window.removeEventListener('resize', positionPanel)
}

function selectOption(option: any) {
  emit('update:modelValue', getOptionValue(option))
  close()
  triggerRef.value?.focus()
}

// Click outside detection
function handleClickOutside(e: MouseEvent) {
  const target = e.target as Node
  if (
    containerRef.value && !containerRef.value.contains(target) &&
    panelRef.value && !panelRef.value.contains(target)
  ) {
    close()
  }
}

// Keyboard handling on trigger
function handleTriggerKeydown(e: KeyboardEvent) {
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
    e.preventDefault()
    if (!isOpen.value) {
      open()
    }
  }
  if (e.key === 'Escape') {
    close()
  }
}

// Keyboard handling on panel
function handlePanelKeydown(e: KeyboardEvent) {
  if (e.key === 'ArrowDown') {
    e.preventDefault()
    highlightedIndex.value = Math.min(highlightedIndex.value + 1, allOptions.value.length - 1)
  } else if (e.key === 'ArrowUp') {
    e.preventDefault()
    highlightedIndex.value = Math.max(highlightedIndex.value - 1, 0)
  } else if (e.key === 'Enter') {
    e.preventDefault()
    selectOption(allOptions.value[highlightedIndex.value])
  } else if (e.key === 'Escape') {
    close()
    triggerRef.value?.focus()
  }
}

// Cleanup on unmount
onBeforeUnmount(() => {
  close()
})
</script>

Step 2: Add dropdown animation to main.css

In src/styles/main.css, add after the existing animate-modal-enter block (after line 120):

/* Dropdown animations */
@keyframes dropdown-enter {
  from {
    opacity: 0;
    transform: translateY(-4px) scale(0.98);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

.animate-dropdown-enter {
  animation: dropdown-enter 150ms ease-out;
}

Step 3: Verify it builds

Run: npx vite build Expected: Build succeeds (component isn't used yet, but it must compile)

Step 4: Commit

git add src/components/AppSelect.vue src/styles/main.css
git commit -m "feat: add AppSelect custom dropdown component"

Task 2: Create AppDatePicker Component

Files:

  • Create: src/components/AppDatePicker.vue

Context: This component replaces all 6 native <input type="date"> elements. The v-model is a YYYY-MM-DD string (same format as native date input). The calendar popover is teleported to body. It needs month navigation, today highlighting, and selected-day highlighting.

Step 1: Create the component file

Write src/components/AppDatePicker.vue with this complete implementation:

<template>
  <div ref="containerRef" class="relative">
    <!-- Trigger -->
    <button
      ref="triggerRef"
      type="button"
      @click="toggle"
      @keydown.escape="close"
      class="w-full flex items-center justify-between px-3 py-2 bg-bg-inset border rounded-xl text-[0.8125rem] text-left transition-colors duration-150 focus:outline-none"
      :class="[
        isOpen ? 'border-accent shadow-[0_0_0_2px_var(--color-accent-muted)]' : 'border-border-subtle',
        'cursor-pointer hover:border-border-visible'
      ]"
    >
      <span :class="modelValue ? 'text-text-primary' : 'text-text-tertiary'">
        {{ displayValue || placeholder }}
      </span>
      <CalendarIcon
        class="w-3.5 h-3.5 text-text-tertiary shrink-0 ml-2"
        :stroke-width="2"
      />
    </button>

    <!-- Calendar popover (teleported to body) -->
    <Teleport to="body">
      <div
        v-if="isOpen"
        ref="panelRef"
        class="fixed z-[100] p-3 bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] animate-dropdown-enter"
        :style="panelStyle"
      >
        <!-- Month/Year header -->
        <div class="flex items-center justify-between mb-3">
          <button
            type="button"
            @click="prevMonth"
            class="p-1 text-text-tertiary hover:text-text-primary rounded-lg hover:bg-bg-elevated transition-colors"
          >
            <ChevronLeft class="w-4 h-4" :stroke-width="2" />
          </button>
          <span class="text-[0.8125rem] font-medium text-text-primary">
            {{ monthNames[viewMonth] }} {{ viewYear }}
          </span>
          <button
            type="button"
            @click="nextMonth"
            class="p-1 text-text-tertiary hover:text-text-primary rounded-lg hover:bg-bg-elevated transition-colors"
          >
            <ChevronRight class="w-4 h-4" :stroke-width="2" />
          </button>
        </div>

        <!-- Day-of-week headers -->
        <div class="grid grid-cols-7 mb-1">
          <div
            v-for="day in dayHeaders"
            :key="day"
            class="text-center text-[0.625rem] text-text-tertiary uppercase tracking-wider py-1"
          >
            {{ day }}
          </div>
        </div>

        <!-- Day grid -->
        <div class="grid grid-cols-7">
          <button
            v-for="(cell, index) in calendarCells"
            :key="index"
            type="button"
            @click="cell.currentMonth ? selectDay(cell.day) : undefined"
            class="w-8 h-8 flex items-center justify-center text-[0.75rem] rounded-lg transition-colors duration-100"
            :class="getDayCellClasses(cell)"
            :disabled="!cell.currentMonth"
          >
            {{ cell.day }}
          </button>
        </div>

        <!-- Today shortcut -->
        <div class="mt-2 pt-2 border-t border-border-subtle">
          <button
            type="button"
            @click="selectToday"
            class="w-full text-center text-[0.75rem] text-text-secondary hover:text-accent-text py-1 transition-colors"
          >
            Today
          </button>
        </div>
      </div>
    </Teleport>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, nextTick, onBeforeUnmount } from 'vue'
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-vue-next'

interface Props {
  modelValue: string  // YYYY-MM-DD format
  placeholder?: string
}

const props = withDefaults(defineProps<Props>(), {
  placeholder: 'Select date'
})

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLButtonElement>()
const panelRef = ref<HTMLElement>()
const isOpen = ref(false)
const panelStyle = ref<Record<string, string>>({})

// Calendar view state
const today = new Date()
const viewYear = ref(today.getFullYear())
const viewMonth = ref(today.getMonth())

const monthNames = [
  'January', 'February', 'March', 'April', 'May', 'June',
  'July', 'August', 'September', 'October', 'November', 'December'
]

const dayHeaders = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']

// Display value for trigger
const displayValue = computed(() => {
  if (!props.modelValue) return ''
  const [year, month, day] = props.modelValue.split('-').map(Number)
  const date = new Date(year, month - 1, day)
  return date.toLocaleDateString('en-US', {
    month: 'short',
    day: 'numeric',
    year: 'numeric'
  })
})

// When modelValue changes, update the view to show that month
watch(() => props.modelValue, (val) => {
  if (val) {
    const [year, month] = val.split('-').map(Number)
    viewYear.value = year
    viewMonth.value = month - 1
  }
}, { immediate: true })

interface CalendarCell {
  day: number
  currentMonth: boolean
  dateStr: string
}

// Generate calendar grid cells
const calendarCells = computed((): CalendarCell[] => {
  const cells: CalendarCell[] = []
  const year = viewYear.value
  const month = viewMonth.value

  // First day of the month (0=Sun, 1=Mon, ...)
  const firstDay = new Date(year, month, 1).getDay()
  // Convert to Monday-start (Mon=0, Tue=1, ..., Sun=6)
  const startOffset = firstDay === 0 ? 6 : firstDay - 1

  // Days in current month
  const daysInMonth = new Date(year, month + 1, 0).getDate()
  // Days in previous month
  const daysInPrevMonth = new Date(year, month, 0).getDate()

  // Previous month padding
  for (let i = startOffset - 1; i >= 0; i--) {
    const day = daysInPrevMonth - i
    const prevMonth = month === 0 ? 11 : month - 1
    const prevYear = month === 0 ? year - 1 : year
    cells.push({
      day,
      currentMonth: false,
      dateStr: formatDateStr(prevYear, prevMonth, day)
    })
  }

  // Current month days
  for (let day = 1; day <= daysInMonth; day++) {
    cells.push({
      day,
      currentMonth: true,
      dateStr: formatDateStr(year, month, day)
    })
  }

  // Next month padding (fill to 42 cells = 6 rows)
  const remaining = 42 - cells.length
  for (let day = 1; day <= remaining; day++) {
    const nextMonth = month === 11 ? 0 : month + 1
    const nextYear = month === 11 ? year + 1 : year
    cells.push({
      day,
      currentMonth: false,
      dateStr: formatDateStr(nextYear, nextMonth, day)
    })
  }

  return cells
})

function formatDateStr(year: number, month: number, day: number): string {
  return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}

// Get CSS classes for a day cell
function getDayCellClasses(cell: CalendarCell): string {
  const classes: string[] = []

  if (!cell.currentMonth) {
    classes.push('text-text-tertiary/40 cursor-default')
    return classes.join(' ')
  }

  // Selected
  if (cell.dateStr === props.modelValue) {
    classes.push('bg-accent text-bg-base font-medium')
    return classes.join(' ')
  }

  // Today
  const todayStr = formatDateStr(today.getFullYear(), today.getMonth(), today.getDate())
  if (cell.dateStr === todayStr) {
    classes.push('ring-1 ring-accent text-accent-text')
  } else {
    classes.push('text-text-primary')
  }

  classes.push('hover:bg-bg-elevated cursor-pointer')
  return classes.join(' ')
}

// Month navigation
function prevMonth() {
  if (viewMonth.value === 0) {
    viewMonth.value = 11
    viewYear.value--
  } else {
    viewMonth.value--
  }
}

function nextMonth() {
  if (viewMonth.value === 11) {
    viewMonth.value = 0
    viewYear.value++
  } else {
    viewMonth.value++
  }
}

// Select a day
function selectDay(day: number) {
  const dateStr = formatDateStr(viewYear.value, viewMonth.value, day)
  emit('update:modelValue', dateStr)
  close()
}

// Select today shortcut
function selectToday() {
  const dateStr = formatDateStr(today.getFullYear(), today.getMonth(), today.getDate())
  emit('update:modelValue', dateStr)
  viewYear.value = today.getFullYear()
  viewMonth.value = today.getMonth()
  close()
}

// Position the panel below the trigger
function positionPanel() {
  if (!triggerRef.value) return
  const rect = triggerRef.value.getBoundingClientRect()
  const panelWidth = 280
  // Ensure panel doesn't overflow right edge
  let left = rect.left
  if (left + panelWidth > window.innerWidth - 8) {
    left = window.innerWidth - panelWidth - 8
  }
  panelStyle.value = {
    top: `${rect.bottom + 4}px`,
    left: `${left}px`,
    width: `${panelWidth}px`
  }
}

function toggle() {
  if (isOpen.value) {
    close()
  } else {
    open()
  }
}

function open() {
  isOpen.value = true
  nextTick(() => {
    positionPanel()
    document.addEventListener('click', handleClickOutside, true)
    document.addEventListener('scroll', positionPanel, true)
    window.addEventListener('resize', positionPanel)
  })
}

function close() {
  isOpen.value = false
  document.removeEventListener('click', handleClickOutside, true)
  document.removeEventListener('scroll', positionPanel, true)
  window.removeEventListener('resize', positionPanel)
}

function handleClickOutside(e: MouseEvent) {
  const target = e.target as Node
  if (
    containerRef.value && !containerRef.value.contains(target) &&
    panelRef.value && !panelRef.value.contains(target)
  ) {
    close()
  }
}

onBeforeUnmount(() => {
  close()
})
</script>

Step 2: Verify it builds

Run: npx vite build Expected: Build succeeds

Step 3: Commit

git add src/components/AppDatePicker.vue
git commit -m "feat: add AppDatePicker custom calendar component"

Task 3: Replace Selects in Timer.vue

Files:

  • Modify: src/views/Timer.vue:29-59 (template selects), src/views/Timer.vue:121 (imports)

Context: Timer.vue has 2 native selects:

  1. Project selector (line 29-42): v-model="selectedProject", options from activeProjects, disabled when timerStore.isRunning
  2. Task selector (line 46-59): v-model="selectedTask", options from projectTasks, disabled when timerStore.isRunning || !selectedProject

Step 1: Add import

In the <script setup> imports section (line 121), add AppSelect:

Change:

import { Timer as TimerIcon } from 'lucide-vue-next'

To:

import { Timer as TimerIcon } from 'lucide-vue-next'
import AppSelect from '../components/AppSelect.vue'

Step 2: Replace project select (lines 29-42)

Replace:

          <select
            v-model="selectedProject"
            :disabled="timerStore.isRunning"
            class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible disabled:opacity-40 disabled:cursor-not-allowed"
          >
            <option :value="null">Select project</option>
            <option
              v-for="project in activeProjects"
              :key="project.id"
              :value="project.id"
            >
              {{ project.name }}
            </option>
          </select>

With:

          <AppSelect
            v-model="selectedProject"
            :options="activeProjects"
            label-key="name"
            value-key="id"
            placeholder="Select project"
            :placeholder-value="null"
            :disabled="timerStore.isRunning"
          />

Step 3: Replace task select (lines 46-59)

Replace:

          <select
            v-model="selectedTask"
            :disabled="timerStore.isRunning || !selectedProject"
            class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible disabled:opacity-40 disabled:cursor-not-allowed"
          >
            <option :value="null">Select task</option>
            <option
              v-for="task in projectTasks"
              :key="task.id"
              :value="task.id"
            >
              {{ task.name }}
            </option>
          </select>

With:

          <AppSelect
            v-model="selectedTask"
            :options="projectTasks"
            label-key="name"
            value-key="id"
            placeholder="Select task"
            :placeholder-value="null"
            :disabled="timerStore.isRunning || !selectedProject"
          />

Step 4: Verify it builds

Run: npx vite build Expected: Build succeeds

Step 5: Commit

git add src/views/Timer.vue
git commit -m "feat: replace native selects with AppSelect in Timer view"

Task 4: Replace Select in Projects.vue

Files:

  • Modify: src/views/Projects.vue:95-107 (template), src/views/Projects.vue:203 (imports)

Context: Projects.vue has 1 native select: Client selector in the create/edit dialog (line 95-107). v-model="formData.client_id", optional with "No client" placeholder, value is undefined when no client.

Step 1: Add import

After line 203 (import { FolderKanban } from 'lucide-vue-next'), add:

import AppSelect from '../components/AppSelect.vue'

Step 2: Replace client select (lines 95-107)

Replace:

            <select
              v-model="formData.client_id"
              class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
            >
              <option :value="undefined">No client</option>
              <option
                v-for="client in clientsStore.clients"
                :key="client.id"
                :value="client.id"
              >
                {{ client.name }}
              </option>
            </select>

With:

            <AppSelect
              v-model="formData.client_id"
              :options="clientsStore.clients"
              label-key="name"
              value-key="id"
              placeholder="No client"
              :placeholder-value="undefined"
            />

Step 3: Verify and commit

Run: npx vite build

git add src/views/Projects.vue
git commit -m "feat: replace native select with AppSelect in Projects view"

Task 5: Replace Selects and Date Pickers in Entries.vue

Files:

  • Modify: src/views/Entries.vue:9-13,17-21,25-37,142-154 (template), src/views/Entries.vue:241 (imports)

Context: Entries.vue has:

  • 2 date pickers: Start Date filter (line 9-13), End Date filter (line 17-21)
  • 1 project filter select (line 25-37): v-model="filterProject", nullable, "All Projects" placeholder
  • 1 project select in edit dialog (line 142-154): v-model="editForm.project_id", required (no placeholder option)

Step 1: Add imports

After line 241 (import { List as ListIcon } from 'lucide-vue-next'), add:

import AppSelect from '../components/AppSelect.vue'
import AppDatePicker from '../components/AppDatePicker.vue'

Step 2: Replace Start Date input (lines 9-13)

Replace:

        <input
          v-model="startDate"
          type="date"
          class="px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
        />

With:

        <AppDatePicker
          v-model="startDate"
          placeholder="Start date"
        />

Step 3: Replace End Date input (lines 17-21)

Replace:

        <input
          v-model="endDate"
          type="date"
          class="px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
        />

With:

        <AppDatePicker
          v-model="endDate"
          placeholder="End date"
        />

Step 4: Replace project filter select (lines 25-37)

Replace:

        <select
          v-model="filterProject"
          class="px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
        >
          <option :value="null">All Projects</option>
          <option
            v-for="project in projectsStore.projects"
            :key="project.id"
            :value="project.id"
          >
            {{ project.name }}
          </option>
        </select>

With:

        <AppSelect
          v-model="filterProject"
          :options="projectsStore.projects"
          label-key="name"
          value-key="id"
          placeholder="All Projects"
          :placeholder-value="null"
        />

Step 5: Replace project select in edit dialog (lines 142-154)

Replace:

            <select
              v-model="editForm.project_id"
              required
              class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
            >
              <option
                v-for="project in projectsStore.projects"
                :key="project.id"
                :value="project.id"
              >
                {{ project.name }}
              </option>
            </select>

With:

            <AppSelect
              v-model="editForm.project_id"
              :options="projectsStore.projects"
              label-key="name"
              value-key="id"
              placeholder="Select project"
            />

Step 6: Verify and commit

Run: npx vite build

git add src/views/Entries.vue
git commit -m "feat: replace native selects and date pickers with custom components in Entries view"

Task 6: Replace Date Pickers in Reports.vue

Files:

  • Modify: src/views/Reports.vue:9-13,17-21 (template), src/views/Reports.vue:108 (imports)

Context: Reports.vue has 2 date pickers: Start Date (line 9-13) and End Date (line 17-21). Both bind to startDate/endDate refs.

Step 1: Add import

After line 108 (import { ref, computed, onMounted } from 'vue'), on a separate line in the imports area, add:

import AppDatePicker from '../components/AppDatePicker.vue'

Step 2: Replace Start Date input (lines 9-13)

Replace:

        <input
          v-model="startDate"
          type="date"
          class="px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
        />

With:

        <AppDatePicker
          v-model="startDate"
          placeholder="Start date"
        />

Step 3: Replace End Date input (lines 17-21)

Replace:

        <input
          v-model="endDate"
          type="date"
          class="px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
        />

With:

        <AppDatePicker
          v-model="endDate"
          placeholder="End date"
        />

Step 4: Verify and commit

Run: npx vite build

git add src/views/Reports.vue
git commit -m "feat: replace native date pickers with AppDatePicker in Reports view"

Task 7: Replace Select and Date Pickers in Invoices.vue

Files:

  • Modify: src/views/Invoices.vue:129-142,149-154,158-162 (template), src/views/Invoices.vue:342-343 (imports)

Context: Invoices.vue has:

  • 1 client select (line 129-142): v-model="createForm.client_id", required, "Select a client" with value 0
  • 1 invoice date (line 149-154): v-model="createForm.date", required
  • 1 due date (line 158-162): v-model="createForm.due_date", optional

Step 1: Add imports

After line 343 (import { FileText } from 'lucide-vue-next'), add:

import AppSelect from '../components/AppSelect.vue'
import AppDatePicker from '../components/AppDatePicker.vue'

Step 2: Replace client select (lines 129-142)

Replace:

          <select
            v-model="createForm.client_id"
            required
            class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
          >
            <option :value="0">Select a client</option>
            <option
              v-for="client in clientsStore.clients"
              :key="client.id"
              :value="client.id"
            >
              {{ client.name }}
            </option>
          </select>

With:

          <AppSelect
            v-model="createForm.client_id"
            :options="clientsStore.clients"
            label-key="name"
            value-key="id"
            placeholder="Select a client"
            :placeholder-value="0"
          />

Step 3: Replace invoice date input (lines 149-154)

Replace:

            <input
              v-model="createForm.date"
              type="date"
              required
              class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
            />

With:

            <AppDatePicker
              v-model="createForm.date"
              placeholder="Invoice date"
            />

Step 4: Replace due date input (lines 158-162)

Replace:

            <input
              v-model="createForm.due_date"
              type="date"
              class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
            />

With:

            <AppDatePicker
              v-model="createForm.due_date"
              placeholder="Due date"
            />

Step 5: Verify and commit

Run: npx vite build

git add src/views/Invoices.vue
git commit -m "feat: replace native select and date pickers with custom components in Invoices view"

Task 8: Final Build Verification

Files: None (verification only)

Step 1: Full build

Run: npx vite build Expected: Clean build, no errors, no warnings about unused imports.

Step 2: Verify no remaining native selects or date inputs

Run grep to confirm zero remaining instances:

grep -rn '<select' src/views/ src/components/
grep -rn 'type="date"' src/views/ src/components/

Expected: No matches (the datetime-local in Entries.vue is type="datetime-local", not type="date", so it won't match).

Step 3: Final commit if any cleanup needed

If all clean, no commit needed. The feature is complete.