You've already forked AstralRinth
11b2b6e6c0
* feat: implement cancel/apply for custom timeframe range picker * feat: implement dot for showing todays date * feat: add max date to be today and show todays date * feat: if ratio mode, dont show total * feat: implement show more batching excess lines into "Other" bucket * refactor: pnpm prepr * feat: add pick and plop for date range start/end dates * feat: implement reset query button * feat: clear button to clear breakdown * feat: more aggressively trim allowed minimum group by option * fix: dont show project status filter when from project settings/analytics * fix: clear selected X above number when appropriate * feat: graph style updates and dont show year in x axis unless more than 2 year timeframe * fix: loading state to include legend in blur * feat: add project icon to project select * feat: filter out draft projects from analytics * feat: implement multiselect sections headers, project select org sections, and project options icons * feat: implement click and drag to select date range * feat: implement windows history for query builder * revert: no longer switch breakdown/filter option if same category * feat: implement showing project for project version breakdown/filter when there are multiple projects * feat: implement modrinth sided events * fix: border radius * feat: implement analytics range highlight * fix: loading state showing empty state text * refactor: pnpm prepr * feat: improve dropdown filter bar and multiselect performance * fix: multiselect keyboard use * fix: graph overflow issues * fix: loading state text on table * feat: implement tooltip scroll * fix: adjust charts event tooltip * feat: shorten time to not repeat am/pm * feat: implement query params for graph component settings * fix: qa * feat: add reset timeframe button * fix: legend colors moving between metric by determining color based on only downloads metric index * feat: implement auto switching temporarily to group by day for renvenue metric and disable revenue metric for time range < 2 days * fix: change to > 1 day * fix: custom timeframe picker * feat: implement big performance improvement for table * feat: implement hover on legend to highlight graph * fix: defer commit in query builder/filter and style fixes * feat: more performance optimization to analytics dashboard state, chart, and table * feat: add tooltip for other item * feat: improve custom time frame range select * feat: implement analytics events admin page * fix: switch column order * pnpm prepr * feat: implement mock analytics events * feat: improve analytics events admin page * feat: focus title input on analytics create event modal * fix: remove labels annoying * feat: hook up analytics events backend * fix: type error * feat: reduce combobox padding * feat: reduce padding on multiselect * feat: add overlay scrollbar for combobox * feat: a bunch of style fixes to combobox, multiselect and dropdown filter bar * feat: MORE PADDING fixes * feat: use user_agent for download source * Revert "feat: use user_agent for download source" This reverts commit d6dc8a99f11f94660872427796cdcf6fc93bb21d. * fix: query filter project version lag and borked virtualization * feat: rename breakdown "none" to "project" * feat: implement right side checkmark for multiselect * feat: keep crossed out legend items still shown in tooltip but also crossed out * fix: focus styles * fix: focus styles pt2 * feat: implement filter by top 8 * fix: preview is incorrect when selecting same date in range date picker * feat (playtest): cross out legend items in tooltip and allow hide/show in tooltip * feat (playtest): table component controls what graph shows * feat: change download source to use user_agent * feat: fix click to cross out in legend * feat: add hover legend item to highlight line in tooltip * fix: export csv to always be dropdown * feat: implement breakdown = none * performance: frontend memory reduction * performance: reduce memory usage from project versions query by keeping only whats necessary * fix: table checked items not in graph if 0 * feat: add shift click to select a range in table * performance: add caching for metric types so switching between them is snappy * performance: batch analytics requests by 15 project ids, with 150 ms delay between, so backend is happy * feat: add analytics table search * refactor: pnpm prepr * fix: query filter options not coming in from analytics fetch * feat: remove breakdown = none when there are multiple projects * feat: improve table sorting * feat: sort projects in project dropdown * fix: getting project name for project versions * fix: add loading state for filter and parallel fetch * performance: use precomputed map for project version options to remove first hover lag * feat: dropdown filter always open on one side and improve styles * fix: custom time range picker being weird * refactor: pnpm prepr * fix: add back in batch with 300ms interval for projects to prevent backend rate limiting * performance: only do queries to populate graph first before other analytics queries * fix: QA polish issues around style and copy * feat: dont show select all when its just one item in section * fix: bugs with ratio mode and hiding chart lines * fix: adjust padding in combobox and multiselect and fix not unfocusing when deselect * fix: small styles * fix: polish admin analytic events * fix: keep scroll position with selection action row appearing when selecting one * feat: add subheading in graph for showing N items from table * feat: add unmonetized explaination tooltip * performance: implement limit on how many lines can be shown in graph * feat: mobile pass * refactor: pnpm prepr * add clear button * feat: add time in analytics event and normalize date/time so its correct to timezones * fix: padding * feat: implement show prev period toggle * feat: extract TimeFramePicker to packages/ui * fix: adjust style * feat: keep table selected persisted in query parameter * fix: style on prev item value in legend * fix: when breakdown switches, reset selected series * fix: tooltip styles * feat: change project selection to reset to show top 8 only if reconciled down to 0 items * feat: implement show top 8 button in graph subheading * fix: rename download type to download reason * fix: formatting label for table * feat: persist table sort by and sort direction * fix: show top 8 button in graph not defaulting to top 8 for other metrics * feat: implement prev period analytics fetch into the same current period fetch by shifting start date * refactor: pnpm prepr * fix: remove number if its just top 1 * fix: brief select items empty state when switch breakdown * feat: implement format table playtime column * feat: update export csv filename * feat: change playtime column to display in hours * refactor: pnpm prepr * fix: still download type in filter * feat: update analytics tooltip * fix: wrong all projects icon * feat: force legend order and graph colour for monetization * refactor: pnpm prepr * fix: multiselect and combobox sizes * fix: chart icon add hover delay * feat: (to playtest) implement multiple breakdowns * fix: couple UX things for multiple breakdown * fix: cannot unpin on page click * fix: multiple breakdown legend and tooltip labels * feat: add right side checkmark for dropdown filtr bar * feat: enabling prev period will cross out prev for current ones already crossed out * feat-mobile: remove drag to select time frame in graph * feat-mobile: dropdown filter to replace dropdown for submenus on small screen * feat-mobile: time frame picker to use different start and end date pickers for mobile * fix-mobile: fix multiselect scroll on mobile * feat: consolidate is mobile ref into context * fix-mobile: combobox and multiselect scroll bug when mobile search bar open, fix timeframe picker mobile pick date, and dropdown filter bar click outside to close * fix-mobile: smaller metric card font * fix: dropdown filter bar scroll while search * feat: implement project side events * feat: implement better mobile view design for query builder * feat: handle events overflow * small: add select none * feat: remove clear project and breakdown * fix: event icon hover color * feat: default hide project events if there are multiple projects, and default show if only 1 project * feat: implement analytics performance updates, including facets, and v3 user projects * feat: grey out dimmed lined on legend item hover * feat-mobile: style fixes * add close on select prop * feat: add close on select for time frame picker mobile * feat: date picker default read only * refactor: pnpm prepr * feat: default to projects breakdown instead of no breakdown with multiple projects * fix-mobile: improve graph touch interactions * small: 2 sig figs on playtime * feat: deduplicate version uploads that have same version number and are uploaded on same day * fix: analytics events grouping causing overflow * feat: improve performance on analytics events grouping * fix: tooltip expanding page width briefly * fix: prevent double tap to zoom on inputs * feat: add click to show chart event for mobile * fix: toggle not having touch manipulation * fix: chart tooltip scroll in mobile * fix: remove project breakdownoption as it is default breakdown when none are selected * fix: dropdown filter bar briefly empty when switching pages in mobile * feat: keep tooltip open after drag in mobile * fix: using plural instead of single for project breakdown * fix: date picker scrolling page after picking date in mobile by suppressing focus * fix: callback to Organization instead of org id * feat: improve chart tooltip date range label formatter to be much more consistent * feat: tap to toggle event tooltip * fix: add user select none on graph and fix zoom into download threshold input * fix: frontend still filtering after backend already filters * feat: fix emptys state height content shift * fix: qa issues * fix: a number of qa issues - Hide project events based on visible project legend/table selection - Filter project status events by end status and add explicit copy for approved, private, and unlisted - Style Modrinth analytics events with blue icon, marker, guide, and range borders - Add scroll fade shadows to analytics chart and event tooltips - Show previous-period date range in the chart tooltip - Make project breakdown conditional on multiple selected projects and allow no breakdown when none are selected - Add breakdown selection actions and fix “Group by day” copy * feat: implement graph controls dropdown * fix date picker typing into time input * fix: styles in events table * small: style * feat: implement using new backend facets route * feat: implement user get all projects * performance: deter non-critical fetches to after analytics is in * fix: refreshing causes multiple projects to do breakdown=none * performance: cache project version options to fix lag on open sub menu * refactor: remove chart event height being controlled by parent * feat: update controls dropdown to have fainter border * fix: loading bar not fading away * fix: cannot click in graph * feat: dont conditionally show multiselect selection actions * fix: z-index and padding issues * fix: project events incorrectly toggling on for first page load * feat: remove show more and show less in legend, always show all * fix: playtime y axis labels * feat: improve y axis formatting for playtime and others * feat: use tabs for game version select, and remove prev period when change breakdown or project selection * refactor: pnpm prepr * feat: change hidden legend items to not contribute to ratio percentages * feat: event icon consume scroll for tooltip panel * feat: remove gap inside chart tooltip * feat: add gap for date picker 2 calendar view * feat: improve analytics events grouping logic for modrinth events to be close to target * pnpm prepr * fix: cant click in gap in toggle * fix: bugs around selected series from table not persisting with timeframe or filter changes * refactor: kabab case * refactor: split up large analytics chart and table component files into smaller components and ts modules * fix: legend is stale after resetting query * refactor: split up giant analytics provider with utils * i18n pass * revert: format number composable change * fix: playtime was choosing y axis ticks in seconds instead of hours * refactor: rename folder that with components to match main component name * refactor: same rename for analytics table for consistency * refactor: name main components to index.vue and keep folder name as component name * refactor: pnpm prepr * refactor: rename types * refactor: move query builder types into types file and move components into components/analytics-dashboard * refactor: colocate query builder url with analytics-dashboard component * refactor: pnpm prepr:frontend * fix: download threshold not width fit * fix: no option to see release/all game versions in selected filter dropdown * fix: game version dropdown width --------- Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com> Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
1091 lines
33 KiB
Vue
1091 lines
33 KiB
Vue
<template>
|
|
<Combobox
|
|
:model-value="highlightedTimeframePreset"
|
|
:options="timeframeDropdownOptions"
|
|
:display-value="selectedTimeframeLabel"
|
|
:max-height="maxHeight"
|
|
:trigger-class="triggerClass"
|
|
:dropdown-min-width="timeframeDropdownMinWidth"
|
|
:outside-click-ignore="timeframeDropdownOutsideClickIgnore"
|
|
:dropdown-class="
|
|
activeTimeframePanel === 'custom_range'
|
|
? 'bg-transparent border-0 -mt-1 pb-2 shadow-none'
|
|
: ''
|
|
"
|
|
@update:model-value="handleTimeframeModelUpdate"
|
|
@open="handleTimeframeSelectOpen"
|
|
@close="handleTimeframeSelectClose"
|
|
@select="handleTimeframePresetSelect"
|
|
>
|
|
<template #prefix>
|
|
<slot name="prefix"></slot>
|
|
</template>
|
|
<template #dropdown-footer>
|
|
<template v-if="activeTimeframePanel === 'custom_range'">
|
|
<div
|
|
class="flex flex-col gap-0 rounded-2xl border border-solid border-surface-5 bg-surface-3 p-0 pt-1"
|
|
>
|
|
<DatePicker
|
|
v-if="!isMobileCustomRangePicker"
|
|
v-model="pickerRange"
|
|
mode="range"
|
|
:show-months="2"
|
|
:clearable="false"
|
|
:default-view-date="todayInputValue"
|
|
view-date-alignment="right"
|
|
:min-date="minDate"
|
|
:max-date="customRangeMaxDate"
|
|
show-today
|
|
calendar-only
|
|
wrapper-class="w-full"
|
|
calendar-class="!border-none"
|
|
/>
|
|
<div v-else class="grid grid-cols-1 gap-3 p-3">
|
|
<div class="flex flex-col gap-1">
|
|
<span class="px-1 text-sm font-semibold text-secondary">
|
|
{{ formatMessage(messages.startDate) }}
|
|
</span>
|
|
<DatePicker
|
|
v-model="mobileStartDate"
|
|
mode="single"
|
|
:show-months="1"
|
|
:clearable="false"
|
|
:default-view-date="mobileStartDefaultViewDate"
|
|
:min-date="minDate"
|
|
:max-date="customRangeMaxDate"
|
|
show-today
|
|
wrapper-class="w-full"
|
|
close-on-select
|
|
/>
|
|
</div>
|
|
<div class="flex flex-col gap-1">
|
|
<span class="px-1 text-sm font-semibold text-secondary">
|
|
{{ formatMessage(messages.endDate) }}
|
|
</span>
|
|
<DatePicker
|
|
v-model="mobileEndDate"
|
|
mode="single"
|
|
:show-months="1"
|
|
:clearable="false"
|
|
:default-view-date="mobileEndDefaultViewDate"
|
|
:min-date="minDate"
|
|
:max-date="customRangeMaxDate"
|
|
show-today
|
|
wrapper-class="w-full"
|
|
close-on-select
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="flex items-center gap-3 p-4 pt-1"
|
|
:class="isMobileCustomRangePicker ? 'justify-end' : 'justify-between'"
|
|
>
|
|
<div v-if="!isMobileCustomRangePicker" class="text-base">
|
|
<template v-if="formattedRange">
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="font-normal text-primary">{{ rangeLabel }}:</span>
|
|
<span class="font-medium text-contrast">{{ formattedRange }}</span>
|
|
<button
|
|
v-if="selectedDraftDates.length !== 1"
|
|
type="button"
|
|
class="ml-1 border-0 bg-transparent p-0 font-normal text-primary underline hover:text-primary"
|
|
@click.stop="clearRange"
|
|
>
|
|
{{ formatMessage(messages.clearRange) }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<span class="font-normal text-primary">
|
|
{{ formatMessage(messages.emptyRange) }}
|
|
</span>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<ButtonStyled type="outlined">
|
|
<button type="button" @click="handleCustomRangeCancel">
|
|
{{ formatMessage(messages.cancel) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
<ButtonStyled color="brand">
|
|
<button type="button" :disabled="!hasCompleteRange" @click="handleCustomRangeApply">
|
|
{{ formatMessage(messages.apply) }}
|
|
</button>
|
|
</ButtonStyled>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div
|
|
v-else
|
|
class="flex flex-col border-0 border-t border-solid border-surface-5 bg-surface-4"
|
|
>
|
|
<div
|
|
class="px-3 py-2"
|
|
:class="draftSelectedTimeframeMode === 'last' ? 'bg-highlight-green' : ''"
|
|
>
|
|
<div class="flex items-center gap-2.5 py-0.5 transition-colors">
|
|
<span
|
|
class="shrink-0 text-sm font-semibold"
|
|
:class="draftSelectedTimeframeMode === 'last' ? 'text-green' : 'text-primary'"
|
|
>
|
|
{{ formatMessage(messages.lastTimeframePrefix) }}
|
|
</span>
|
|
<div
|
|
class="flex h-8 shrink-0 items-center overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-3"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="flex h-8 w-8 touch-manipulation cursor-pointer items-center justify-center border-0 border-r border-solid border-surface-5 bg-transparent p-0 text-secondary transition-colors hover:text-contrast"
|
|
:aria-label="formatMessage(messages.decreaseAmount)"
|
|
@click.stop="decrementAmount"
|
|
>
|
|
<MinusIcon class="size-4" />
|
|
</button>
|
|
<input
|
|
v-model="amountInput"
|
|
type="number"
|
|
min="1"
|
|
step="1"
|
|
class="h-8 w-12 touch-manipulation border-0 bg-transparent px-1 text-center text-sm font-semibold text-primary outline-none ring-0 focus:outline-none focus-visible:shadow-none max-sm:text-base"
|
|
:aria-label="formatMessage(messages.timeframeAmount)"
|
|
@focus="activateLastTimeframe"
|
|
@input="handleAmountInput"
|
|
@blur="commitAmountInput"
|
|
@keydown.enter.prevent.stop="submitAmountInput"
|
|
/>
|
|
<button
|
|
type="button"
|
|
class="flex h-8 w-8 touch-manipulation cursor-pointer items-center justify-center border-0 border-l border-solid border-surface-5 bg-transparent p-0 text-secondary transition-colors hover:text-contrast"
|
|
:aria-label="formatMessage(messages.increaseAmount)"
|
|
@click.stop="incrementAmount"
|
|
>
|
|
<PlusIcon class="size-4" />
|
|
</button>
|
|
</div>
|
|
<select
|
|
v-model="draftSelectedLastTimeframeUnit"
|
|
class="h-8 touch-manipulation rounded-lg border border-solid border-surface-5 bg-surface-3 px-2 text-sm font-semibold text-primary outline-none transition-[box-shadow,color] focus:text-contrast focus:ring-4 focus:ring-brand-shadow"
|
|
:aria-label="formatMessage(messages.timeframeUnit)"
|
|
@change="handleLastTimeframeUnitChange"
|
|
>
|
|
<option
|
|
v-for="option in lastTimeframeUnitOptions"
|
|
:key="option.value"
|
|
:value="option.value"
|
|
>
|
|
{{ option.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="flex cursor-pointer items-center border-0 border-t border-solid border-surface-5 bg-transparent px-3 py-3 text-left text-sm font-semibold text-primary transition-colors hover:bg-surface-5"
|
|
@click.stop="switchDraftToCustomDateRange"
|
|
>
|
|
{{ formatMessage(messages.customRange) }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</Combobox>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { MinusIcon, PlusIcon } from '@modrinth/assets'
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
|
import { defineMessages, useVIntl } from '../../composables/i18n'
|
|
import ButtonStyled from './ButtonStyled.vue'
|
|
import Combobox, { type ComboboxOption } from './Combobox.vue'
|
|
import DatePicker from './DatePicker.vue'
|
|
|
|
export type TimeFramePreset =
|
|
| 'today'
|
|
| 'yesterday'
|
|
| 'last_7_days'
|
|
| 'last_14_days'
|
|
| 'last_30_days'
|
|
| 'last_90_days'
|
|
| 'last_180_days'
|
|
| 'year_to_date'
|
|
| 'all_time'
|
|
|
|
export type TimeFrameMode = 'preset' | 'last' | 'custom_range' | 'custom_datetime_range'
|
|
export type TimeFrameLastUnit = 'hours' | 'days' | 'weeks' | 'months'
|
|
|
|
export type TimeFrameLastUnitOption = {
|
|
value: TimeFrameLastUnit
|
|
label: string
|
|
}
|
|
|
|
export type TimeFramePickerSelection = {
|
|
mode: TimeFrameMode
|
|
preset: TimeFramePreset
|
|
lastAmount: number
|
|
lastUnit: TimeFrameLastUnit
|
|
customStartDate: string
|
|
customEndDate: string
|
|
}
|
|
|
|
type DatePickerValue = string | Date | null | undefined
|
|
type TimeFramePanel = 'preset' | 'custom_range'
|
|
type LastTimeframeValue = {
|
|
amount: number
|
|
unit: TimeFrameLastUnit
|
|
}
|
|
|
|
const TIMEFRAME_DROPDOWN_MAX_HEIGHT = 500
|
|
const TIMEFRAME_DROPDOWN_MIN_WIDTH = '20rem'
|
|
const CUSTOM_RANGE_DROPDOWN_MIN_WIDTH = '41.25rem'
|
|
const MOBILE_CUSTOM_RANGE_DROPDOWN_MIN_WIDTH = 'min(calc(100vw - 1rem), 20rem)'
|
|
const MOBILE_CUSTOM_RANGE_PICKER_QUERY = '(pointer: coarse), (max-width: 800px)'
|
|
const DATE_PICKER_PORTAL_SELECTOR = '.modrinth-date-picker-portal'
|
|
|
|
const DEFAULT_LAST_TIMEFRAME_VALUE_BY_PRESET: Partial<Record<TimeFramePreset, LastTimeframeValue>> =
|
|
{
|
|
today: { amount: 1, unit: 'days' },
|
|
yesterday: { amount: 1, unit: 'days' },
|
|
last_7_days: { amount: 7, unit: 'days' },
|
|
last_14_days: { amount: 14, unit: 'days' },
|
|
last_30_days: { amount: 30, unit: 'days' },
|
|
last_90_days: { amount: 90, unit: 'days' },
|
|
last_180_days: { amount: 180, unit: 'days' },
|
|
}
|
|
|
|
const messages = defineMessages({
|
|
today: {
|
|
id: 'time-frame-picker.option.today',
|
|
defaultMessage: 'Today',
|
|
},
|
|
yesterday: {
|
|
id: 'time-frame-picker.option.yesterday',
|
|
defaultMessage: 'Yesterday',
|
|
},
|
|
last7Days: {
|
|
id: 'time-frame-picker.option.last-7-days',
|
|
defaultMessage: 'Last 7 days',
|
|
},
|
|
last14Days: {
|
|
id: 'time-frame-picker.option.last-14-days',
|
|
defaultMessage: 'Last 14 days',
|
|
},
|
|
last30Days: {
|
|
id: 'time-frame-picker.option.last-30-days',
|
|
defaultMessage: 'Last 30 days',
|
|
},
|
|
last90Days: {
|
|
id: 'time-frame-picker.option.last-90-days',
|
|
defaultMessage: 'Last 90 days',
|
|
},
|
|
last180Days: {
|
|
id: 'time-frame-picker.option.last-180-days',
|
|
defaultMessage: 'Last 180 days',
|
|
},
|
|
yearToDate: {
|
|
id: 'time-frame-picker.option.year-to-date',
|
|
defaultMessage: 'Year to date',
|
|
},
|
|
allTime: {
|
|
id: 'time-frame-picker.option.all-time',
|
|
defaultMessage: 'All time',
|
|
},
|
|
hours: {
|
|
id: 'time-frame-picker.unit.hours',
|
|
defaultMessage: 'hours',
|
|
},
|
|
days: {
|
|
id: 'time-frame-picker.unit.days',
|
|
defaultMessage: 'days',
|
|
},
|
|
weeks: {
|
|
id: 'time-frame-picker.unit.weeks',
|
|
defaultMessage: 'weeks',
|
|
},
|
|
months: {
|
|
id: 'time-frame-picker.unit.months',
|
|
defaultMessage: 'months',
|
|
},
|
|
lastTimeframe: {
|
|
id: 'time-frame-picker.last-timeframe',
|
|
defaultMessage:
|
|
'In the last {amount} {unit, select, hours {{amount, plural, one {hour} other {hours}}} days {{amount, plural, one {day} other {days}}} weeks {{amount, plural, one {week} other {weeks}}} months {{amount, plural, one {month} other {months}}} other {days}}',
|
|
},
|
|
lastTimeframePrefix: {
|
|
id: 'time-frame-picker.last-timeframe-prefix',
|
|
defaultMessage: 'In the last',
|
|
},
|
|
customRange: {
|
|
id: 'time-frame-picker.custom-range',
|
|
defaultMessage: 'Custom fixed date range...',
|
|
},
|
|
clearRange: {
|
|
id: 'time-frame-picker.clear-range',
|
|
defaultMessage: 'Clear',
|
|
},
|
|
cancel: {
|
|
id: 'time-frame-picker.cancel',
|
|
defaultMessage: 'Cancel',
|
|
},
|
|
apply: {
|
|
id: 'time-frame-picker.apply',
|
|
defaultMessage: 'Apply',
|
|
},
|
|
emptyRange: {
|
|
id: 'time-frame-picker.empty-range',
|
|
defaultMessage: 'No date range selected.',
|
|
},
|
|
selectingRange: {
|
|
id: 'time-frame-picker.selecting-range',
|
|
defaultMessage: 'Selecting',
|
|
},
|
|
selectedRange: {
|
|
id: 'time-frame-picker.selected-range',
|
|
defaultMessage: 'Selected',
|
|
},
|
|
startDate: {
|
|
id: 'time-frame-picker.start-date',
|
|
defaultMessage: 'Start date',
|
|
},
|
|
endDate: {
|
|
id: 'time-frame-picker.end-date',
|
|
defaultMessage: 'End date',
|
|
},
|
|
selectTimeframe: {
|
|
id: 'time-frame-picker.select-timeframe',
|
|
defaultMessage: 'Select timeframe',
|
|
},
|
|
decreaseAmount: {
|
|
id: 'time-frame-picker.decrease-amount',
|
|
defaultMessage: 'Decrease timeframe amount',
|
|
},
|
|
increaseAmount: {
|
|
id: 'time-frame-picker.increase-amount',
|
|
defaultMessage: 'Increase timeframe amount',
|
|
},
|
|
timeframeAmount: {
|
|
id: 'time-frame-picker.timeframe-amount',
|
|
defaultMessage: 'Timeframe amount',
|
|
},
|
|
timeframeUnit: {
|
|
id: 'time-frame-picker.timeframe-unit',
|
|
defaultMessage: 'Timeframe unit',
|
|
},
|
|
})
|
|
|
|
const mode = defineModel<TimeFrameMode>('mode', { required: true })
|
|
const preset = defineModel<TimeFramePreset>('preset', { required: true })
|
|
const lastAmount = defineModel<number>('lastAmount', { required: true })
|
|
const lastUnit = defineModel<TimeFrameLastUnit>('lastUnit', { required: true })
|
|
const customStartDate = defineModel<string>('customStartDate', { required: true })
|
|
const customEndDate = defineModel<string>('customEndDate', { required: true })
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
timeframeOptions?: ComboboxOption<TimeFramePreset>[]
|
|
lastTimeframeUnitOptions?: TimeFrameLastUnitOption[]
|
|
lastTimeframeValueByPreset?: Partial<Record<TimeFramePreset, LastTimeframeValue>>
|
|
minDate?: string
|
|
maxDate?: string
|
|
nowTimestamp?: number
|
|
maxHeight?: number
|
|
triggerClass?: string
|
|
dropdownMinWidth?: string | number
|
|
customRangeDropdownMinWidth?: string | number
|
|
}>(),
|
|
{
|
|
maxHeight: TIMEFRAME_DROPDOWN_MAX_HEIGHT,
|
|
dropdownMinWidth: TIMEFRAME_DROPDOWN_MIN_WIDTH,
|
|
customRangeDropdownMinWidth: CUSTOM_RANGE_DROPDOWN_MIN_WIDTH,
|
|
},
|
|
)
|
|
|
|
const { formatMessage, locale } = useVIntl()
|
|
|
|
const emit = defineEmits<{
|
|
open: []
|
|
close: []
|
|
cancel: []
|
|
commit: [selection: TimeFramePickerSelection]
|
|
apply: [selection: TimeFramePickerSelection]
|
|
'draft-change': [selection: TimeFramePickerSelection]
|
|
'preset-select': [option: ComboboxOption<TimeFramePreset>, selection: TimeFramePickerSelection]
|
|
}>()
|
|
|
|
const isTimeframeSelectOpen = ref(false)
|
|
const activeTimeframePanel = ref<TimeFramePanel>('preset')
|
|
const draftSelectedTimeframeMode = ref<TimeFrameMode>(mode.value)
|
|
const draftSelectedTimeframe = ref<TimeFramePreset>(preset.value)
|
|
const draftSelectedLastTimeframeAmount = ref(lastAmount.value)
|
|
const draftSelectedLastTimeframeUnit = ref<TimeFrameLastUnit>(lastUnit.value)
|
|
const draftSelectedCustomTimeframeStartDate = ref(customStartDate.value)
|
|
const draftSelectedCustomTimeframeEndDate = ref(customEndDate.value)
|
|
const amountInput = ref(String(lastAmount.value))
|
|
const pickerRange = ref<DatePickerValue[]>([customStartDate.value, customEndDate.value])
|
|
const mobileStartDate = ref<DatePickerValue>(customStartDate.value)
|
|
const mobileEndDate = ref<DatePickerValue>(customEndDate.value)
|
|
const isMobileCustomRangePicker = ref(false)
|
|
let mobileCustomRangePickerMedia: MediaQueryList | null = null
|
|
|
|
const timeframeOptions = computed<ComboboxOption<TimeFramePreset>[]>(
|
|
() =>
|
|
props.timeframeOptions ?? [
|
|
{ value: 'today', label: formatMessage(messages.today) },
|
|
{ value: 'yesterday', label: formatMessage(messages.yesterday) },
|
|
{ value: 'last_7_days', label: formatMessage(messages.last7Days) },
|
|
{ value: 'last_14_days', label: formatMessage(messages.last14Days) },
|
|
{ value: 'last_30_days', label: formatMessage(messages.last30Days) },
|
|
{ value: 'last_90_days', label: formatMessage(messages.last90Days) },
|
|
{ value: 'last_180_days', label: formatMessage(messages.last180Days) },
|
|
{ value: 'year_to_date', label: formatMessage(messages.yearToDate) },
|
|
{ value: 'all_time', label: formatMessage(messages.allTime) },
|
|
],
|
|
)
|
|
const lastTimeframeUnitOptions = computed<TimeFrameLastUnitOption[]>(
|
|
() =>
|
|
props.lastTimeframeUnitOptions ?? [
|
|
{ value: 'hours', label: formatMessage(messages.hours) },
|
|
{ value: 'days', label: formatMessage(messages.days) },
|
|
{ value: 'weeks', label: formatMessage(messages.weeks) },
|
|
{ value: 'months', label: formatMessage(messages.months) },
|
|
],
|
|
)
|
|
const lastTimeframeValueByPreset = computed(
|
|
() => props.lastTimeframeValueByPreset ?? DEFAULT_LAST_TIMEFRAME_VALUE_BY_PRESET,
|
|
)
|
|
const timeframeDropdownOptions = computed<ComboboxOption<TimeFramePreset>[]>(() =>
|
|
activeTimeframePanel.value === 'custom_range' ? [] : timeframeOptions.value,
|
|
)
|
|
const timeframeDropdownMinWidth = computed(() =>
|
|
activeTimeframePanel.value === 'custom_range'
|
|
? isMobileCustomRangePicker.value
|
|
? MOBILE_CUSTOM_RANGE_DROPDOWN_MIN_WIDTH
|
|
: props.customRangeDropdownMinWidth
|
|
: props.dropdownMinWidth,
|
|
)
|
|
const timeframeDropdownOutsideClickIgnore = computed(() =>
|
|
activeTimeframePanel.value === 'custom_range' && isMobileCustomRangePicker.value
|
|
? [DATE_PICKER_PORTAL_SELECTOR]
|
|
: [],
|
|
)
|
|
const highlightedTimeframePreset = computed<TimeFramePreset | undefined>(() =>
|
|
draftSelectedTimeframeMode.value === 'preset' ? draftSelectedTimeframe.value : undefined,
|
|
)
|
|
const selectedTimeframeLabel = computed(() => {
|
|
const useDraftTimeframeLabel =
|
|
isTimeframeSelectOpen.value && activeTimeframePanel.value !== 'custom_range'
|
|
|
|
return getTimeframeLabel(
|
|
useDraftTimeframeLabel ? draftSelectedTimeframeMode.value : mode.value,
|
|
useDraftTimeframeLabel ? draftSelectedTimeframe.value : preset.value,
|
|
useDraftTimeframeLabel ? draftSelectedLastTimeframeAmount.value : lastAmount.value,
|
|
useDraftTimeframeLabel ? draftSelectedLastTimeframeUnit.value : lastUnit.value,
|
|
useDraftTimeframeLabel ? draftSelectedCustomTimeframeStartDate.value : customStartDate.value,
|
|
useDraftTimeframeLabel ? draftSelectedCustomTimeframeEndDate.value : customEndDate.value,
|
|
)
|
|
})
|
|
const todayInputValue = computed(() => getDateInputValue(new Date()))
|
|
const customRangeMaxDate = computed(() => props.maxDate ?? todayInputValue.value)
|
|
const mobileStartDefaultViewDate = computed(
|
|
() => draftSelectedCustomTimeframeStartDate.value || todayInputValue.value,
|
|
)
|
|
const mobileEndDefaultViewDate = computed(
|
|
() => draftSelectedCustomTimeframeEndDate.value || todayInputValue.value,
|
|
)
|
|
const selectedDraftDates = computed(() =>
|
|
pickerRange.value
|
|
.map(getDatePickerValueString)
|
|
.filter((value): value is string => Boolean(value)),
|
|
)
|
|
const rangeLabel = computed(() =>
|
|
formatMessage(
|
|
selectedDraftDates.value.length === 1 ? messages.selectingRange : messages.selectedRange,
|
|
),
|
|
)
|
|
const hasCompleteRange = computed(() => Boolean(getOrderedRange(pickerRange.value)))
|
|
const formattedRange = computed(() => {
|
|
if (selectedDraftDates.value.length === 1) {
|
|
return `${formatDateString(selectedDraftDates.value[0])} -`
|
|
}
|
|
|
|
const orderedRange =
|
|
getOrderedRange(pickerRange.value) ??
|
|
getOrderedRange([
|
|
draftSelectedCustomTimeframeStartDate.value,
|
|
draftSelectedCustomTimeframeEndDate.value,
|
|
])
|
|
if (!orderedRange) return ''
|
|
|
|
const [nextStartDate, nextEndDate] = orderedRange
|
|
if (nextStartDate === nextEndDate) return formatDateString(nextStartDate)
|
|
return `${formatDateString(nextStartDate)} - ${formatDateString(nextEndDate)}`
|
|
})
|
|
|
|
function getTimeframeLabel(
|
|
selectedMode: TimeFrameMode,
|
|
selectedPreset: TimeFramePreset,
|
|
selectedLastAmount: number,
|
|
selectedLastUnit: TimeFrameLastUnit,
|
|
selectedCustomStartDate: string,
|
|
selectedCustomEndDate: string,
|
|
): string {
|
|
if (selectedMode === 'last') {
|
|
return formatMessage(messages.lastTimeframe, {
|
|
amount: selectedLastAmount,
|
|
unit: selectedLastUnit,
|
|
})
|
|
}
|
|
|
|
if (selectedMode === 'custom_range') {
|
|
return formatCustomTimeframeRangeLabel(selectedCustomStartDate, selectedCustomEndDate)
|
|
}
|
|
|
|
if (selectedMode === 'custom_datetime_range') {
|
|
return formatCustomDateTimeRangeLabel(selectedCustomStartDate, selectedCustomEndDate)
|
|
}
|
|
|
|
return (
|
|
timeframeOptions.value.find((option) => option.value === selectedPreset)?.label ??
|
|
formatMessage(messages.selectTimeframe)
|
|
)
|
|
}
|
|
|
|
function getDraftSelection(): TimeFramePickerSelection {
|
|
return {
|
|
mode: draftSelectedTimeframeMode.value,
|
|
preset: draftSelectedTimeframe.value,
|
|
lastAmount: draftSelectedLastTimeframeAmount.value,
|
|
lastUnit: draftSelectedLastTimeframeUnit.value,
|
|
customStartDate: draftSelectedCustomTimeframeStartDate.value,
|
|
customEndDate: draftSelectedCustomTimeframeEndDate.value,
|
|
}
|
|
}
|
|
|
|
function emitDraftChange() {
|
|
emit('draft-change', getDraftSelection())
|
|
}
|
|
|
|
function syncMobileCustomRangePickerState() {
|
|
isMobileCustomRangePicker.value = mobileCustomRangePickerMedia?.matches ?? false
|
|
}
|
|
|
|
function setupMobileCustomRangePickerMedia() {
|
|
if (typeof window === 'undefined') {
|
|
return
|
|
}
|
|
|
|
mobileCustomRangePickerMedia = window.matchMedia(MOBILE_CUSTOM_RANGE_PICKER_QUERY)
|
|
syncMobileCustomRangePickerState()
|
|
mobileCustomRangePickerMedia.addEventListener('change', syncMobileCustomRangePickerState)
|
|
}
|
|
|
|
function teardownMobileCustomRangePickerMedia() {
|
|
mobileCustomRangePickerMedia?.removeEventListener('change', syncMobileCustomRangePickerState)
|
|
mobileCustomRangePickerMedia = null
|
|
}
|
|
|
|
function getDateInputValue(date: Date): string {
|
|
const year = date.getFullYear()
|
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
const day = String(date.getDate()).padStart(2, '0')
|
|
return `${year}-${month}-${day}`
|
|
}
|
|
|
|
function getDateFromInputValue(value: string): Date | undefined {
|
|
const date = new Date(`${value}T00:00:00`)
|
|
if (Number.isNaN(date.getTime()) || getDateInputValue(date) !== value) {
|
|
return undefined
|
|
}
|
|
|
|
return date
|
|
}
|
|
|
|
function getDateTimeFromInputValue(value: string): Date | undefined {
|
|
const date = new Date(value)
|
|
if (Number.isNaN(date.getTime())) {
|
|
return undefined
|
|
}
|
|
|
|
return date
|
|
}
|
|
|
|
function formatDateString(value: string): string {
|
|
const parsed = new Date(`${value}T00:00:00`)
|
|
if (Number.isNaN(parsed.getTime())) return value
|
|
return formatDate(parsed, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
function isValidDateInputValue(value: string): boolean {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
return false
|
|
}
|
|
|
|
const parsedDate = new Date(`${value}T00:00:00`)
|
|
return !Number.isNaN(parsedDate.getTime()) && getDateInputValue(parsedDate) === value
|
|
}
|
|
|
|
function getDatePickerValueString(value: DatePickerValue): string | null {
|
|
if (typeof value === 'string') {
|
|
return isValidDateInputValue(value) ? value : null
|
|
}
|
|
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
|
return getDateInputValue(value)
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
function getOrderedRange(values: DatePickerValue[]): [string, string] | null {
|
|
const dates = values
|
|
.map(getDatePickerValueString)
|
|
.filter((value): value is string => Boolean(value))
|
|
if (dates.length < 2) {
|
|
return null
|
|
}
|
|
|
|
const firstDate = dates[0]
|
|
const secondDate = dates[1]
|
|
if (!firstDate || !secondDate) {
|
|
return null
|
|
}
|
|
|
|
return firstDate <= secondDate ? [firstDate, secondDate] : [secondDate, firstDate]
|
|
}
|
|
|
|
function formatCustomTimeframeRangeLabel(startDateValue: string, endDateValue: string): string {
|
|
const startDate = getDateFromInputValue(startDateValue)
|
|
const endDate = getDateFromInputValue(endDateValue)
|
|
if (!startDate || !endDate) {
|
|
return `${startDateValue} - ${endDateValue}`
|
|
}
|
|
|
|
if (startDateValue === endDateValue) {
|
|
return formatDate(startDate, {
|
|
month: 'long',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
})
|
|
}
|
|
|
|
const sameYear = startDate.getFullYear() === endDate.getFullYear()
|
|
if (sameYear) {
|
|
const startLabel = formatDate(startDate, { month: 'long', day: 'numeric' })
|
|
const endLabel = formatDate(endDate, { month: 'long', day: 'numeric' })
|
|
return `${startLabel} - ${endLabel}, ${startDate.getFullYear()}`
|
|
}
|
|
|
|
const startLabel = formatDate(startDate, {
|
|
month: 'long',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
})
|
|
const endLabel = formatDate(endDate, {
|
|
month: 'long',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
})
|
|
return `${startLabel} - ${endLabel}`
|
|
}
|
|
|
|
function formatCustomDateTimeRangeLabel(startDateValue: string, endDateValue: string): string {
|
|
const startDate = getDateTimeFromInputValue(startDateValue)
|
|
const endDate = getDateTimeFromInputValue(endDateValue)
|
|
if (!startDate || !endDate) {
|
|
return `${startDateValue} - ${endDateValue}`
|
|
}
|
|
|
|
if (startDate.getTime() === endDate.getTime()) {
|
|
return formatDate(startDate, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
const sameYear = startDate.getFullYear() === endDate.getFullYear()
|
|
if (sameYear) {
|
|
const startLabel = formatDate(startDate, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
})
|
|
const endLabel = formatDate(endDate, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
})
|
|
return `${startLabel} - ${endLabel}, ${startDate.getFullYear()}`
|
|
}
|
|
|
|
const startLabel = formatDate(startDate, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
})
|
|
const endLabel = formatDate(endDate, {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: 'numeric',
|
|
minute: '2-digit',
|
|
})
|
|
return `${startLabel} - ${endLabel}`
|
|
}
|
|
|
|
function formatDate(date: Date, options: Intl.DateTimeFormatOptions): string {
|
|
return new Intl.DateTimeFormat(locale.value, options).format(date)
|
|
}
|
|
|
|
function getRoundedNow(timestamp: number): Date {
|
|
const roundedTimestamp = Math.floor(timestamp / 60000) * 60000
|
|
return new Date(roundedTimestamp)
|
|
}
|
|
|
|
function addDays(date: Date, days: number): Date {
|
|
const nextDate = new Date(date)
|
|
nextDate.setDate(nextDate.getDate() + days)
|
|
return nextDate
|
|
}
|
|
|
|
function isStartOfDay(date: Date): boolean {
|
|
return (
|
|
date.getHours() === 0 &&
|
|
date.getMinutes() === 0 &&
|
|
date.getSeconds() === 0 &&
|
|
date.getMilliseconds() === 0
|
|
)
|
|
}
|
|
|
|
function getInclusiveEndDateInputValue(end: Date): string {
|
|
return getDateInputValue(isStartOfDay(end) ? addDays(end, -1) : end)
|
|
}
|
|
|
|
function subtractCalendarMonths(date: Date, months: number): Date {
|
|
const nextDate = new Date(date)
|
|
const day = nextDate.getDate()
|
|
nextDate.setDate(1)
|
|
nextDate.setMonth(nextDate.getMonth() - months)
|
|
const daysInMonth = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate()
|
|
nextDate.setDate(Math.min(day, daysInMonth))
|
|
return nextDate
|
|
}
|
|
|
|
function getTimeRangeForLastTimeframe() {
|
|
const end = getRoundedNow(props.nowTimestamp ?? Date.now())
|
|
const amount = Math.max(1, Math.floor(draftSelectedLastTimeframeAmount.value))
|
|
|
|
switch (draftSelectedLastTimeframeUnit.value) {
|
|
case 'hours':
|
|
return { start: new Date(end.getTime() - amount * 60 * 60 * 1000), end }
|
|
case 'days':
|
|
return { start: new Date(end.getTime() - amount * 24 * 60 * 60 * 1000), end }
|
|
case 'weeks':
|
|
return { start: new Date(end.getTime() - amount * 7 * 24 * 60 * 60 * 1000), end }
|
|
case 'months':
|
|
return { start: subtractCalendarMonths(end, amount), end }
|
|
}
|
|
}
|
|
|
|
function getDraftTimeRange() {
|
|
if (draftSelectedTimeframeMode.value === 'last') {
|
|
return getTimeRangeForLastTimeframe()
|
|
}
|
|
|
|
const startDate =
|
|
draftSelectedTimeframeMode.value === 'custom_datetime_range'
|
|
? getDateTimeFromInputValue(draftSelectedCustomTimeframeStartDate.value)
|
|
: getDateFromInputValue(draftSelectedCustomTimeframeStartDate.value)
|
|
const endDate =
|
|
draftSelectedTimeframeMode.value === 'custom_datetime_range'
|
|
? getDateTimeFromInputValue(draftSelectedCustomTimeframeEndDate.value)
|
|
: getDateFromInputValue(draftSelectedCustomTimeframeEndDate.value)
|
|
|
|
if (!startDate || !endDate) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
start: startDate,
|
|
end: draftSelectedTimeframeMode.value === 'custom_range' ? addDays(endDate, 1) : endDate,
|
|
}
|
|
}
|
|
|
|
function resetTimeframeDraft() {
|
|
draftSelectedTimeframeMode.value = mode.value
|
|
draftSelectedTimeframe.value = preset.value
|
|
draftSelectedLastTimeframeAmount.value = lastAmount.value
|
|
draftSelectedLastTimeframeUnit.value = lastUnit.value
|
|
draftSelectedCustomTimeframeStartDate.value = customStartDate.value
|
|
draftSelectedCustomTimeframeEndDate.value = customEndDate.value
|
|
amountInput.value = String(lastAmount.value)
|
|
syncPickerRangeFromDraft()
|
|
}
|
|
|
|
function commitTimeframeDraft() {
|
|
const selection = getDraftSelection()
|
|
|
|
mode.value = selection.mode
|
|
preset.value = selection.preset
|
|
lastAmount.value = selection.lastAmount
|
|
lastUnit.value = selection.lastUnit
|
|
customStartDate.value = selection.customStartDate
|
|
customEndDate.value = selection.customEndDate
|
|
emit('commit', selection)
|
|
}
|
|
|
|
function handleTimeframeSelectOpen() {
|
|
resetTimeframeDraft()
|
|
activeTimeframePanel.value = 'preset'
|
|
isTimeframeSelectOpen.value = true
|
|
emit('open')
|
|
}
|
|
|
|
function handleTimeframeSelectClose() {
|
|
if (activeTimeframePanel.value !== 'custom_range') {
|
|
commitTimeframeDraft()
|
|
}
|
|
isTimeframeSelectOpen.value = false
|
|
emit('close')
|
|
}
|
|
|
|
function closeTimeframeSelectDropdown(event: Event) {
|
|
const eventTarget = event.target
|
|
if (!(eventTarget instanceof HTMLElement)) {
|
|
isTimeframeSelectOpen.value = false
|
|
return
|
|
}
|
|
|
|
const dropdown = eventTarget.closest('[role="listbox"], [role="menu"]')
|
|
if (!dropdown) {
|
|
isTimeframeSelectOpen.value = false
|
|
return
|
|
}
|
|
|
|
dropdown.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
}
|
|
|
|
async function applyTimeframeDraft(event: Event) {
|
|
commitTimeframeDraft()
|
|
closeTimeframeSelectDropdown(event)
|
|
await nextTick()
|
|
emit('apply', getDraftSelection())
|
|
}
|
|
|
|
function handleCustomRangeCancel() {
|
|
resetTimeframeDraft()
|
|
activeTimeframePanel.value = 'preset'
|
|
emit('cancel')
|
|
emitDraftChange()
|
|
}
|
|
|
|
async function handleCustomRangeApply(event: MouseEvent) {
|
|
if (!hasCompleteDraftCustomDateRange()) {
|
|
return
|
|
}
|
|
|
|
const orderedRange = getOrderedRange([
|
|
draftSelectedCustomTimeframeStartDate.value,
|
|
draftSelectedCustomTimeframeEndDate.value,
|
|
])
|
|
if (orderedRange) {
|
|
const [nextStartDate, nextEndDate] = orderedRange
|
|
draftSelectedCustomTimeframeStartDate.value = nextStartDate
|
|
draftSelectedCustomTimeframeEndDate.value = nextEndDate
|
|
}
|
|
|
|
draftSelectedTimeframeMode.value = 'custom_range'
|
|
await applyTimeframeDraft(event)
|
|
}
|
|
|
|
function handleTimeframeModelUpdate(value: TimeFramePreset) {
|
|
draftSelectedTimeframe.value = value
|
|
emitDraftChange()
|
|
}
|
|
|
|
function handleTimeframePresetSelect(option: ComboboxOption<TimeFramePreset>) {
|
|
draftSelectedTimeframeMode.value = 'preset'
|
|
|
|
const lastTimeframeValue = lastTimeframeValueByPreset.value[option.value]
|
|
if (lastTimeframeValue) {
|
|
draftSelectedLastTimeframeAmount.value = lastTimeframeValue.amount
|
|
draftSelectedLastTimeframeUnit.value = lastTimeframeValue.unit
|
|
amountInput.value = String(lastTimeframeValue.amount)
|
|
}
|
|
|
|
const selection = getDraftSelection()
|
|
emit('preset-select', option, selection)
|
|
emit('draft-change', selection)
|
|
}
|
|
|
|
function hasCompleteDraftCustomDateRange() {
|
|
return Boolean(
|
|
getDateFromInputValue(draftSelectedCustomTimeframeStartDate.value) &&
|
|
getDateFromInputValue(draftSelectedCustomTimeframeEndDate.value),
|
|
)
|
|
}
|
|
|
|
function switchDraftToCustomDateRange() {
|
|
if (draftSelectedTimeframeMode.value === 'preset') {
|
|
draftSelectedCustomTimeframeStartDate.value = ''
|
|
draftSelectedCustomTimeframeEndDate.value = ''
|
|
} else {
|
|
const rawRange = getDraftTimeRange()
|
|
draftSelectedCustomTimeframeStartDate.value = rawRange ? getDateInputValue(rawRange.start) : ''
|
|
draftSelectedCustomTimeframeEndDate.value = rawRange
|
|
? getInclusiveEndDateInputValue(rawRange.end)
|
|
: ''
|
|
}
|
|
|
|
draftSelectedTimeframeMode.value = 'custom_range'
|
|
activeTimeframePanel.value = 'custom_range'
|
|
syncPickerRangeFromDraft()
|
|
emitDraftChange()
|
|
}
|
|
|
|
function activateLastTimeframe() {
|
|
draftSelectedTimeframeMode.value = 'last'
|
|
emitDraftChange()
|
|
}
|
|
|
|
function parseAmountInput() {
|
|
const nextAmount = Number(amountInput.value)
|
|
return Number.isFinite(nextAmount) ? Math.max(1, Math.floor(nextAmount)) : null
|
|
}
|
|
|
|
function handleAmountInput() {
|
|
const nextAmount = parseAmountInput()
|
|
if (nextAmount !== null && String(nextAmount) === amountInput.value) {
|
|
draftSelectedLastTimeframeAmount.value = nextAmount
|
|
}
|
|
|
|
activateLastTimeframe()
|
|
}
|
|
|
|
function commitAmountInput() {
|
|
const nextAmount = parseAmountInput() ?? 1
|
|
draftSelectedLastTimeframeAmount.value = nextAmount
|
|
amountInput.value = String(nextAmount)
|
|
activateLastTimeframe()
|
|
}
|
|
|
|
function submitAmountInput(event: KeyboardEvent) {
|
|
commitAmountInput()
|
|
void applyTimeframeDraft(event)
|
|
}
|
|
|
|
function incrementAmount() {
|
|
commitAmountInput()
|
|
draftSelectedLastTimeframeAmount.value += 1
|
|
amountInput.value = String(draftSelectedLastTimeframeAmount.value)
|
|
activateLastTimeframe()
|
|
}
|
|
|
|
function decrementAmount() {
|
|
commitAmountInput()
|
|
draftSelectedLastTimeframeAmount.value = Math.max(1, draftSelectedLastTimeframeAmount.value - 1)
|
|
amountInput.value = String(draftSelectedLastTimeframeAmount.value)
|
|
activateLastTimeframe()
|
|
}
|
|
|
|
function handleLastTimeframeUnitChange() {
|
|
activateLastTimeframe()
|
|
}
|
|
|
|
function clearRange() {
|
|
draftSelectedCustomTimeframeStartDate.value = ''
|
|
draftSelectedCustomTimeframeEndDate.value = ''
|
|
pickerRange.value = []
|
|
mobileStartDate.value = ''
|
|
mobileEndDate.value = ''
|
|
emitDraftChange()
|
|
}
|
|
|
|
function syncPickerRangeFromDraft() {
|
|
if (mobileStartDate.value !== draftSelectedCustomTimeframeStartDate.value) {
|
|
mobileStartDate.value = draftSelectedCustomTimeframeStartDate.value
|
|
}
|
|
if (mobileEndDate.value !== draftSelectedCustomTimeframeEndDate.value) {
|
|
mobileEndDate.value = draftSelectedCustomTimeframeEndDate.value
|
|
}
|
|
|
|
if (
|
|
pickerRange.value.length === 2 &&
|
|
pickerRange.value[0] === draftSelectedCustomTimeframeStartDate.value &&
|
|
pickerRange.value[1] === draftSelectedCustomTimeframeEndDate.value
|
|
) {
|
|
return
|
|
}
|
|
|
|
pickerRange.value = [
|
|
draftSelectedCustomTimeframeStartDate.value,
|
|
draftSelectedCustomTimeframeEndDate.value,
|
|
]
|
|
}
|
|
|
|
watch([mode, preset, lastAmount, lastUnit, customStartDate, customEndDate], () => {
|
|
if (isTimeframeSelectOpen.value) {
|
|
return
|
|
}
|
|
|
|
resetTimeframeDraft()
|
|
})
|
|
|
|
watch(
|
|
[draftSelectedCustomTimeframeStartDate, draftSelectedCustomTimeframeEndDate],
|
|
syncPickerRangeFromDraft,
|
|
)
|
|
|
|
watch(mobileStartDate, (nextDate) => {
|
|
const nextStartDate = getDatePickerValueString(nextDate)
|
|
if (!nextStartDate || nextStartDate === draftSelectedCustomTimeframeStartDate.value) {
|
|
return
|
|
}
|
|
|
|
draftSelectedCustomTimeframeStartDate.value = nextStartDate
|
|
draftSelectedTimeframeMode.value = 'custom_range'
|
|
emitDraftChange()
|
|
})
|
|
|
|
watch(mobileEndDate, (nextDate) => {
|
|
const nextEndDate = getDatePickerValueString(nextDate)
|
|
if (!nextEndDate || nextEndDate === draftSelectedCustomTimeframeEndDate.value) {
|
|
return
|
|
}
|
|
|
|
draftSelectedCustomTimeframeEndDate.value = nextEndDate
|
|
draftSelectedTimeframeMode.value = 'custom_range'
|
|
emitDraftChange()
|
|
})
|
|
|
|
watch(pickerRange, (nextRange) => {
|
|
if (isMobileCustomRangePicker.value) {
|
|
return
|
|
}
|
|
|
|
const orderedRange = getOrderedRange(nextRange)
|
|
if (!orderedRange) {
|
|
return
|
|
}
|
|
|
|
const [nextStartDate, nextEndDate] = orderedRange
|
|
draftSelectedCustomTimeframeStartDate.value = nextStartDate
|
|
draftSelectedCustomTimeframeEndDate.value = nextEndDate
|
|
emitDraftChange()
|
|
})
|
|
|
|
onMounted(setupMobileCustomRangePickerMedia)
|
|
|
|
onBeforeUnmount(teardownMobileCustomRangePickerMedia)
|
|
</script>
|