Skip to content

Date range ​

A period is two params, startDate and endDate, with one rule between them: the start never comes after the end. The rule belongs to the pair rather than to either date, so it lives in a computed over the two states and not in a parser.

URL/
Show code
date-range.vue
vue
<script setup lang="ts">
import { computed } from 'vue'
import { parseAsDate } from 'usewagen'
import { useRouteStates } from 'usewagen/router'

type Range = [Date, Date]

const { startDate, endDate, set } = useRouteStates([
  { key: 'startDate', parser: parseAsDate },
  { key: 'endDate', parser: parseAsDate },
])

const range = computed({
  get: (): Range | null => {
    const start = startDate.value
    const end = endDate.value
    return start && end && start <= end ? [start, end] : null
  },
  set: (next: Range | null) => {
    if (!next) {
      set({ startDate: null, endDate: null })
      return
    }
    const [start, end] = next[0] <= next[1] ? next : [next[1], next[0]]
    set({ startDate: start, endDate: end })
  },
})

function pick(index: 0 | 1, event: Event) {
  const picked = (event.target as HTMLInputElement).valueAsDate
  if (!picked) {
    range.value = null
    return
  }
  const next: Range = [startDate.value ?? picked, endDate.value ?? picked]
  next[index] = picked
  range.value = next
}

function asInput(date: Date | null) {
  return date ? parseAsDate.serialize(date) : ''
}
</script>

<template>
  <div class="demo-row">
    <input type="date" :value="asInput(startDate)" @change="pick(0, $event)" />
    <span>to</span>
    <input type="date" :value="asInput(endDate)" @change="pick(1, $event)" />
    <button :disabled="!startDate && !endDate" @click="range = null">Clear</button>
  </div>

  <p v-if="range">{{ asInput(range[0]) }} to {{ asInput(range[1]) }} is selected.</p>
  <p v-else>No range is selected.</p>
</template>

range is the pair the component works with, and it is what a range picker that takes both dates as one v-model binds to. Reading it puts the two params together, and writing it hands both to set, so the URL never carries a pair that is half updated. Pick a start after the end above: the two dates land swapped, in one navigation.

A URL edited by hand into the wrong order reads as null, the same way a param the parser refuses reads as missing, and it is left as it is until the next write. A parser answers for the one string it is given, and a rule over two strings has to wait until both are read, which is what the computed adds. Nothing else is needed, because the pair is still a function of the URL and nothing beside it.

One param instead ​

When the shape of the URL is yours to choose, the pair can be one param, and then the parser owns the rule.

ts
import { defineParser, parseAsDate } from 'usewagen'
import { useRouteState } from 'usewagen/router'

const parseAsDateRange = defineParser<[Date, Date]>({
  parse: raw => {
    const [start, end] = raw.split('..').map(parseAsDate.parse)
    return start && end && start <= end ? [start, end] : null
  },
  serialize: ([start, end]) => `${parseAsDate.serialize(start)}..${parseAsDate.serialize(end)}`,
})

const range = useRouteState({ key: 'range', parser: parseAsDateRange })

?range=2026-01-01..2026-01-31 reads as the pair and anything else as null. Two params keep each date readable on its own and match what an existing link or API already uses; one param keeps the rule where the type is decided. Either way the rule is written once.

Released under the MIT License.