From a6945bc1f35074366fe31774ffe0fc5bb165a95e Mon Sep 17 00:00:00 2001 From: jeffvli Date: Sat, 29 Nov 2025 16:31:10 -0800 Subject: [PATCH] add date picker operators to smart playlist --- .../query-builder/query-builder-option.tsx | 114 +++++++++++++++++- src/renderer/features/playlists/utils.ts | 37 +++++- src/shared/api/navidrome/navidrome-types.ts | 3 + .../components/date-picker/date-picker.tsx | 40 +++++- 4 files changed, 186 insertions(+), 8 deletions(-) diff --git a/src/renderer/components/query-builder/query-builder-option.tsx b/src/renderer/components/query-builder/query-builder-option.tsx index 1ed8ff66f..033f78031 100644 --- a/src/renderer/components/query-builder/query-builder-option.tsx +++ b/src/renderer/components/query-builder/query-builder-option.tsx @@ -1,7 +1,8 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Filters } from '/@/renderer/components/query-builder'; import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { DateInput } from '/@/shared/components/date-picker/date-picker'; import { Group } from '/@/shared/components/group/group'; import { NumberInput } from '/@/shared/components/number-input/number-input'; import { Select } from '/@/shared/components/select/select'; @@ -33,9 +34,47 @@ interface QueryOptionProps { selectData?: { label: string; value: string }[]; } -const QueryValueInput = ({ data, onChange, type, ...props }: any) => { +const QueryValueInput = ({ data, defaultValue, onChange, operator, type, ...props }: any) => { const [numberRange, setNumberRange] = useState([0, 0]); + // Parse date value helper - converts date string (YYYY-MM-DD) to Date for display + const parseDateValue = (val: any): Date | null => { + if (!val) return null; + if (val instanceof Date) return val; + if (typeof val === 'string') { + // Handle YYYY-MM-DD format strings + const parsed = new Date(val); + if (isNaN(parsed.getTime())) return null; + return parsed; + } + return null; + }; + + // Store date range as strings for state management + const [dateRange, setDateRange] = useState<[null | string, null | string]>(() => { + if (defaultValue && Array.isArray(defaultValue)) { + return [ + typeof defaultValue[0] === 'string' ? defaultValue[0] : null, + typeof defaultValue[1] === 'string' ? defaultValue[1] : null, + ]; + } + return [null, null]; + }); + + // Sync dateRange state when defaultValue changes + useEffect(() => { + if (operator === 'inTheRangeDate' && defaultValue && Array.isArray(defaultValue)) { + setDateRange([ + typeof defaultValue[0] === 'string' ? defaultValue[0] : null, + typeof defaultValue[1] === 'string' ? defaultValue[1] : null, + ]); + } + }, [defaultValue, operator]); + + // Check if operator requires DatePicker + const isDatePickerOperator = + operator === 'beforeDate' || operator === 'afterDate' || operator === 'inTheRangeDate'; + switch (type) { case 'boolean': return ( @@ -49,8 +88,72 @@ const QueryValueInput = ({ data, onChange, type, ...props }: any) => { /> ); case 'date': + if (isDatePickerOperator && operator !== 'inTheRangeDate') { + const dateValue = defaultValue ? parseDateValue(defaultValue) : null; + return ( + { + // DateInput returns string in 'YYYY-MM-DD' format (local timezone) + // Return raw string value - no transformation needed + onChange(date || ''); + }} + size="sm" + value={dateValue} + valueFormat="YYYY-MM-DD" + width="25%" + /> + ); + } return ; case 'dateRange': + if (operator === 'inTheRangeDate') { + return ( + + { + // DateInput returns string in 'YYYY-MM-DD' format (local timezone) + const newRange: [null | string, null | string] = [ + date || null, + dateRange[1], + ]; + setDateRange(newRange); + // Return raw string values - no transformation needed + onChange([date || null, dateRange[1] || null]); + }} + size="sm" + value={dateRange[0] ? parseDateValue(dateRange[0]) : null} + valueFormat="YYYY-MM-DD" + width="10%" + /> + { + // DateInput returns string in 'YYYY-MM-DD' format (local timezone) + const newRange: [null | string, null | string] = [ + dateRange[0], + date || null, + ]; + setDateRange(newRange); + // Return raw string values - no transformation needed + onChange([dateRange[0] || null, date || null]); + }} + size="sm" + value={dateRange[1] ? parseDateValue(dateRange[1]) : null} + valueFormat="YYYY-MM-DD" + width="10%" + /> + + ); + } + return ( <> ) : ( diff --git a/src/renderer/features/playlists/utils.ts b/src/renderer/features/playlists/utils.ts index 823cd24b6..aa5b44c52 100644 --- a/src/renderer/features/playlists/utils.ts +++ b/src/renderer/features/playlists/utils.ts @@ -19,9 +19,18 @@ export const parseQueryBuilderChildren = (groups: QueryBuilderGroup[], data: any for (const rule of group.rules) { if (rule.field && rule.operator) { const [table, field] = rule.field.split('.'); - const operator = rule.operator; + let operator = rule.operator; const value = field !== 'releaseDate' ? rule.value : new Date(rule.value); + // Transform date picker operators back to original operators + if (operator === 'beforeDate') { + operator = 'before'; + } else if (operator === 'afterDate') { + operator = 'after'; + } else if (operator === 'inTheRangeDate') { + operator = 'inTheRange'; + } + switch (table) { default: query[rootType].push({ @@ -56,7 +65,7 @@ export const convertQueryGroupToNDQuery = (filter: QueryBuilderGroup) => { for (const rule of filter.rules) { if (rule.field && rule.operator) { const [field] = rule.field.split('.'); - const operator = rule.operator; + let operator = rule.operator; let value = rule.value; const booleanFields = NDSongQueryFields.filter( @@ -68,6 +77,14 @@ export const convertQueryGroupToNDQuery = (filter: QueryBuilderGroup) => { value = value === 'true'; } + if (operator === 'beforeDate') { + operator = 'before'; + } else if (operator === 'afterDate') { + operator = 'after'; + } else if (operator === 'inTheRangeDate') { + operator = 'inTheRange'; + } + switch (field) { default: rootQuery[rootQueryType].push({ @@ -103,7 +120,7 @@ export const convertNDQueryToQueryGroup = (query: Record) => { const group = convertNDQueryToQueryGroup(rule); rootGroup.group.push(group); } else { - const operator = Object.keys(rule)[0]; + let operator = Object.keys(rule)[0]; const field = Object.keys(rule[operator])[0]; let value = rule[operator][field]; @@ -116,6 +133,20 @@ export const convertNDQueryToQueryGroup = (query: Record) => { value = value.toString(); } + const dateFields = NDSongQueryFields.filter( + (queryField) => queryField.type === 'date' || queryField.type === 'dateRange', + ).map((field) => field.value); + + if (dateFields.includes(field)) { + if (operator === 'before') { + operator = 'beforeDate'; + } else if (operator === 'after') { + operator = 'afterDate'; + } else if (operator === 'inTheRange') { + operator = 'inTheRangeDate'; + } + } + rootGroup.rules.push({ field, operator, diff --git a/src/shared/api/navidrome/navidrome-types.ts b/src/shared/api/navidrome/navidrome-types.ts index 4aa8d6a3b..53d66de4c 100644 --- a/src/shared/api/navidrome/navidrome-types.ts +++ b/src/shared/api/navidrome/navidrome-types.ts @@ -192,6 +192,9 @@ export const NDSongQueryDateOperators = [ { label: 'is in the last', value: 'inTheLast' }, { label: 'is not in the last', value: 'notInTheLast' }, { label: 'is in the range', value: 'inTheRange' }, + { label: 'is before (date)', value: 'beforeDate' }, + { label: 'is after (date)', value: 'afterDate' }, + { label: 'is in the range (date)', value: 'inTheRangeDate' }, ]; export const NDSongQueryStringOperators = [ diff --git a/src/shared/components/date-picker/date-picker.tsx b/src/shared/components/date-picker/date-picker.tsx index add078469..dc31ca545 100644 --- a/src/shared/components/date-picker/date-picker.tsx +++ b/src/shared/components/date-picker/date-picker.tsx @@ -1,6 +1,12 @@ -import type { DateInputProps as MantineDateInputProps } from '@mantine/dates'; +import type { + DateInputProps as MantineDateInputProps, + DateTimePickerProps as MantineDateTimeInputProps, +} from '@mantine/dates'; -import { DateInput as MantineDateInput } from '@mantine/dates'; +import { + DateInput as MantineDateInput, + DateTimePicker as MantineDateTimeInput, +} from '@mantine/dates'; import styles from './date-picker.module.css'; @@ -33,3 +39,33 @@ export const DateInput = ({ /> ); }; + +interface DateTimeInputProps extends MantineDateTimeInputProps { + maxWidth?: number | string; + width?: number | string; +} + +export const DateTimeInput = ({ + classNames, + maxWidth, + size = 'sm', + style, + width, + ...props +}: DateTimeInputProps) => { + return ( + + ); +};