diff --git a/vueManager/package.json b/vueManager/package.json index 370f116e..a28cd519 100644 --- a/vueManager/package.json +++ b/vueManager/package.json @@ -12,7 +12,8 @@ "format": "prettier --write \"src/**/*.{js,vue,scss,css}\"", "format:check": "prettier --check \"src/**/*.{js,vue,scss,css}\"", "lint:all": "npm run lint && npm run format:check && npm run lint:scss", - "lint:scss": "stylelint \"src/**/*.scss\" --fix" + "lint:scss": "stylelint \"src/**/*.scss\" --fix", + "test:utils": "node src/utils/formatLocalDateYmd.test.mjs" }, "dependencies": { "@primeuix/themes": "^2.0.3", diff --git a/vueManager/src/components/OrdersGrid.vue b/vueManager/src/components/OrdersGrid.vue index cbae3248..fdfb8301 100644 --- a/vueManager/src/components/OrdersGrid.vue +++ b/vueManager/src/components/OrdersGrid.vue @@ -17,6 +17,7 @@ import { computed, onMounted, ref } from 'vue' import { useGridFilterParams } from '../composables/useGridFilterParams.js' import { useSelection } from '../composables/useSelection.js' import request from '../request.js' +import { formatLocalDateYmd } from '../utils/formatLocalDateYmd.js' import ActionsColumn from './ActionsColumn.vue' const toast = useToast() @@ -106,18 +107,18 @@ async function loadOrders() { addFilterParam( params, filterConfig.fields?.from || `${key}_from`, - formatDateForApi(value[0]) + formatLocalDateYmd(value[0]) ) } if (value[1]) { addFilterParam( params, filterConfig.fields?.to || `${key}_to`, - formatDateForApi(value[1]) + formatLocalDateYmd(value[1]) ) } } else if (filterConfig?.type === 'datepicker' && value) { - addFilterParam(params, key, formatDateForApi(value)) + addFilterParam(params, key, formatLocalDateYmd(value)) } else { addFilterParam(params, key, value) } @@ -169,15 +170,6 @@ function onSort(event) { loadOrders() } -/** - * Format date for API (YYYY-MM-DD) - */ -function formatDateForApi(date) { - if (!date) return null - const d = new Date(date) - return d.toISOString().split('T')[0] -} - /** * Open order for editing (redirect to order page) */ diff --git a/vueManager/src/components/product/ProductOptionField.vue b/vueManager/src/components/product/ProductOptionField.vue index 7865cebd..cf43cbf5 100644 --- a/vueManager/src/components/product/ProductOptionField.vue +++ b/vueManager/src/components/product/ProductOptionField.vue @@ -11,6 +11,7 @@ import Textarea from 'primevue/textarea' import { computed, ref, watch } from 'vue' import request from '../../request.js' +import { formatLocalDateYmd } from '../../utils/formatLocalDateYmd.js' const props = defineProps({ option: { type: Object, required: true }, @@ -80,17 +81,9 @@ function onChange() { emit('change', { key: props.option.key, value: value.value }) } -/** - * Format a Date in local timezone as YYYY-MM-DD. - * Never use Date.toISOString() here — it converts to UTC and shifts the date east of UTC - * (user picks 2026-04-20, toISOString() returns 2026-04-19). - */ function formatDateForPost(raw) { if (raw instanceof Date) { - const y = raw.getFullYear() - const m = String(raw.getMonth() + 1).padStart(2, '0') - const d = String(raw.getDate()).padStart(2, '0') - return `${y}-${m}-${d}` + return formatLocalDateYmd(raw) || '' } return raw || '' } diff --git a/vueManager/src/utils/formatLocalDateYmd.js b/vueManager/src/utils/formatLocalDateYmd.js new file mode 100644 index 00000000..64b0517a --- /dev/null +++ b/vueManager/src/utils/formatLocalDateYmd.js @@ -0,0 +1,24 @@ +/** + * Format a date as YYYY-MM-DD in the browser's local calendar. + * + * Do not use Date.toISOString().split('T')[0] — ISO is UTC and shifts the + * calendar day east/west of UTC (issue #386). + * + * @param {string|number|Date|null|undefined} date + * @returns {string|null} + */ +export function formatLocalDateYmd(date) { + if (date === null || date === undefined || date === '') { + return null + } + + const d = date instanceof Date ? date : new Date(date) + if (Number.isNaN(d.getTime())) { + return null + } + + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} diff --git a/vueManager/src/utils/formatLocalDateYmd.test.mjs b/vueManager/src/utils/formatLocalDateYmd.test.mjs new file mode 100644 index 00000000..d4b75886 --- /dev/null +++ b/vueManager/src/utils/formatLocalDateYmd.test.mjs @@ -0,0 +1,40 @@ +/** + * Regression: local YYYY-MM-DD must not use UTC from toISOString (#386). + * + * Run: node src/utils/formatLocalDateYmd.test.mjs + */ + +import assert from 'node:assert/strict' +import { stdout } from 'node:process' + +import { formatLocalDateYmd } from './formatLocalDateYmd.js' + +assert.equal(formatLocalDateYmd(null), null) +assert.equal(formatLocalDateYmd(''), null) +assert.equal(formatLocalDateYmd('invalid'), null) + +// Local calendar components (month is 0-based) +const localMidnight = new Date(2026, 3, 20, 0, 0, 0) +assert.equal(formatLocalDateYmd(localMidnight), '2026-04-20') + +// Early local morning east of UTC: toISOString often yields previous UTC day +const earlyLocalMorning = new Date(2026, 3, 21, 1, 0, 0) +assert.equal(formatLocalDateYmd(earlyLocalMorning), '2026-04-21') + +const utcDay = earlyLocalMorning.toISOString().split('T')[0] +const offsetMinutes = earlyLocalMorning.getTimezoneOffset() +if (offsetMinutes < 0 && utcDay !== '2026-04-21') { + // Negative getTimezoneOffset => local is ahead of UTC (e.g. Asia/Almaty) + assert.notEqual( + utcDay, + formatLocalDateYmd(earlyLocalMorning), + 'toISOString must differ from local YMD east of UTC (documents #386 bug)' + ) +} + +assert.equal( + formatLocalDateYmd('2026-04-20T12:00:00'), + formatLocalDateYmd(new Date('2026-04-20T12:00:00')) +) + +stdout.write('OK formatLocalDateYmd.test.mjs\n')