app.component('document-search', {
});
}
},
+ onSearchKeyup(ev) {
+ if (
+ this.autocompleteSuggestions.length > 0 &&
+ (ev.key === 'ArrowUp' || ev.key === 'ArrowDown')
+ ) {
+ return;
+ }
+ this.updateAutocomplete();
+ },
updateAutocomplete() {
const input = this.$refs.searchInput;
const cursorPos = input ? input.selectionStart : 0;
@@ -111,7 +120,14 @@ module.exports = app => app.component('document-search', {
if (!localDateTime || !this.datePickerContext) {
return;
}
- const iso = new Date(localDateTime).toISOString();
+ const picked = new Date(localDateTime);
+ if (Number.isNaN(picked.getTime())) {
+ if (this.$toast) {
+ this.$toast.error('Invalid date or time. Enter a valid date and time, then try again.');
+ }
+ return;
+ }
+ const iso = picked.toISOString();
const result = insertQuotedIsoInDateArgument(
this.searchText,
this.datePickerContext,
From 3fae467f98a4aa343cd925c00de9f2341e1aed2e Mon Sep 17 00:00:00 2001
From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com>
Date: Fri, 8 May 2026 18:38:20 -0400
Subject: [PATCH 4/7] new date UI
---
.../date-range-filter/date-range-filter.html | 205 +++++++++++
.../date-range-filter/date-range-filter.js | 327 ++++++++++++++++++
frontend/src/models/models.html | 4 +
3 files changed, 536 insertions(+)
create mode 100644 frontend/src/models/date-range-filter/date-range-filter.html
create mode 100644 frontend/src/models/date-range-filter/date-range-filter.js
diff --git a/frontend/src/models/date-range-filter/date-range-filter.html b/frontend/src/models/date-range-filter/date-range-filter.html
new file mode 100644
index 00000000..a65cbcb0
--- /dev/null
+++ b/frontend/src/models/date-range-filter/date-range-filter.html
@@ -0,0 +1,205 @@
+
diff --git a/frontend/src/models/date-range-filter/date-range-filter.js b/frontend/src/models/date-range-filter/date-range-filter.js
new file mode 100644
index 00000000..e9946ef3
--- /dev/null
+++ b/frontend/src/models/date-range-filter/date-range-filter.js
@@ -0,0 +1,327 @@
+'use strict';
+
+const template = require('./date-range-filter.html');
+
+const MS_DAY = 24 * 60 * 60 * 1000;
+
+function intFromNumberInput(v) {
+ if (v === '' || v === null || v === undefined || (typeof v === 'number' && !Number.isFinite(v))) {
+ return NaN;
+ }
+ const n = Number(v);
+ return Number.isFinite(n) ? Math.trunc(n) : NaN;
+}
+
+/**
+ * Anchor from typed parts interpreted as UTC (calendar + clock in Z).
+ * The emitted filter uses toISOString(), so the numbers you type match that string (no local TZ shift).
+ */
+function utcDateFromParts(year, month, day, hour, minute) {
+ const y = intFromNumberInput(year);
+ const mo = intFromNumberInput(month);
+ const d = intFromNumberInput(day);
+ const h = intFromNumberInput(hour);
+ const mi = intFromNumberInput(minute);
+ if (![y, mo, d, h, mi].every(n => Number.isFinite(n))) {
+ return null;
+ }
+ if (mo < 1 || mo > 12 || d < 1 || d > 31 || h < 0 || h > 23 || mi < 0 || mi > 59) {
+ return null;
+ }
+ const t = Date.UTC(y, mo - 1, d, h, mi, 0, 0);
+ const dt = new Date(t);
+ if (
+ dt.getUTCFullYear() !== y ||
+ dt.getUTCMonth() !== mo - 1 ||
+ dt.getUTCDate() !== d ||
+ dt.getUTCHours() !== h ||
+ dt.getUTCMinutes() !== mi
+ ) {
+ return null;
+ }
+ return dt;
+}
+
+/** Replace the filter bar entirely with this single-field date criterion. */
+function buildDateFilterSearchText(fieldPath, filterValueFragment) {
+ const key = JSON.stringify(fieldPath);
+ return `{ ${key}: ${filterValueFragment} }`;
+}
+
+function buildMongoDateRangeClause(start, end) {
+ const a = start.toISOString();
+ const b = end.toISOString();
+ return `{ $gte: new Date(${JSON.stringify(a)}), $lt: new Date(${JSON.stringify(b)}) }`;
+}
+
+/** @param {'$gt' | '$lt'} op */
+function buildMongoCompareClause(op, instant) {
+ const iso = instant.toISOString();
+ return `{ ${op}: new Date(${JSON.stringify(iso)}) }`;
+}
+
+function parseIso8601Like(raw) {
+ if (typeof raw !== 'string') {
+ return null;
+ }
+ const t = raw.trim();
+ if (!t) {
+ return null;
+ }
+ const d = new Date(t);
+ return Number.isNaN(d.getTime()) ? null : d;
+}
+
+function collectDatePathsFromSchema(schemaPaths) {
+ if (!Array.isArray(schemaPaths)) {
+ return [];
+ }
+ const out = [];
+ for (const p of schemaPaths) {
+ if (!p || typeof p.path !== 'string') {
+ continue;
+ }
+ if (p.instance === 'Date') {
+ out.push(p.path);
+ }
+ if (p.schema && typeof p.schema === 'object') {
+ for (const subKey of Object.keys(p.schema)) {
+ const sub = p.schema[subKey];
+ if (sub && sub.instance === 'Date') {
+ out.push(`${p.path}.${subKey}`);
+ }
+ }
+ }
+ }
+ const scorePath = path => {
+ const p = String(path).toLowerCase();
+ if (p === 'createdat') {
+ return 0;
+ }
+ if (p === 'updatedat') {
+ return 1;
+ }
+ if (p.endsWith('.createdat')) {
+ return 2;
+ }
+ if (p.endsWith('.updatedat')) {
+ return 3;
+ }
+ return 10;
+ };
+ out.sort((a, b) => {
+ const ds = scorePath(a) - scorePath(b);
+ if (ds !== 0) {
+ return ds;
+ }
+ return a.localeCompare(b);
+ });
+ return out;
+}
+
+module.exports = app => app.component('date-range-filter', {
+ template,
+ props: {
+ schemaPaths: {
+ type: Array,
+ default: () => []
+ }
+ },
+ data() {
+ return {
+ panelOpen: false,
+ selectedPath: '',
+ customPath: '',
+ anchorYear: null,
+ anchorMonth: null,
+ anchorDay: null,
+ anchorHour: null,
+ anchorMinute: null,
+ anchorSeededOnce: false,
+ anchorIsoMode: false,
+ isoAnchorInput: '',
+ _onPointerDownOutside: null
+ };
+ },
+ computed: {
+ datePaths() {
+ return collectDatePathsFromSchema(this.schemaPaths);
+ },
+ useCustomPath() {
+ return this.datePaths.length === 0;
+ },
+ effectiveFieldPath() {
+ if (this.useCustomPath) {
+ return typeof this.customPath === 'string' ? this.customPath.trim() : '';
+ }
+ return typeof this.selectedPath === 'string' ? this.selectedPath.trim() : '';
+ }
+ },
+ watch: {
+ datePaths: {
+ handler(paths) {
+ if (paths.length === 0) {
+ return;
+ }
+ if (!paths.includes(this.selectedPath)) {
+ this.selectedPath = paths[0];
+ }
+ },
+ immediate: true
+ },
+ schemaPaths() {
+ if (this.datePaths.length > 0 && !this.selectedPath) {
+ this.selectedPath = this.datePaths[0];
+ }
+ }
+ },
+ methods: {
+ populateAnchorFromDate(dt) {
+ this.anchorYear = dt.getUTCFullYear();
+ this.anchorMonth = dt.getUTCMonth() + 1;
+ this.anchorDay = dt.getUTCDate();
+ this.anchorHour = dt.getUTCHours();
+ this.anchorMinute = dt.getUTCMinutes();
+ },
+ onIsoAnchorPaste(ev) {
+ const text = (ev.clipboardData && ev.clipboardData.getData('text/plain')) || '';
+ const trimmed = text.trim();
+ if (!trimmed) {
+ return;
+ }
+ ev.preventDefault();
+ this.isoAnchorInput = trimmed;
+ const d = parseIso8601Like(trimmed);
+ if (d) {
+ this.populateAnchorFromDate(d);
+ this.anchorSeededOnce = true;
+ }
+ },
+ togglePanel(ev) {
+ ev?.preventDefault?.();
+ this.panelOpen = !this.panelOpen;
+ if (this.panelOpen && !this.anchorSeededOnce) {
+ this.populateAnchorFromDate(new Date());
+ this.anchorSeededOnce = true;
+ }
+ this.attachOutsideListener();
+ },
+ closePanel() {
+ this.panelOpen = false;
+ this.detachOutsideListener();
+ },
+ attachOutsideListener() {
+ this.detachOutsideListener();
+ if (!this.panelOpen) {
+ return;
+ }
+ this._onPointerDownOutside = ev => {
+ const root = this.$refs.root;
+ if (!root || root.contains(ev.target)) {
+ return;
+ }
+ this.closePanel();
+ };
+ document.addEventListener('pointerdown', this._onPointerDownOutside, true);
+ },
+ detachOutsideListener() {
+ if (this._onPointerDownOutside) {
+ document.removeEventListener('pointerdown', this._onPointerDownOutside, true);
+ this._onPointerDownOutside = null;
+ }
+ },
+ resolveAnchorDate() {
+ let dt = null;
+ if (this.anchorIsoMode) {
+ dt = parseIso8601Like(this.isoAnchorInput);
+ if (!dt && this.$toast) {
+ this.$toast.error('Could not parse ISO 8601. Example: 2026-05-08T14:30:00.000Z');
+ }
+ } else {
+ dt = utcDateFromParts(
+ this.anchorYear,
+ this.anchorMonth,
+ this.anchorDay,
+ this.anchorHour,
+ this.anchorMinute
+ );
+ if (!dt && this.$toast) {
+ this.$toast.error('Enter a valid UTC date and time using the number fields.');
+ }
+ }
+ return dt;
+ },
+ validateField() {
+ if (!this.effectiveFieldPath) {
+ if (this.$toast) {
+ this.$toast.error('Choose or enter a date field path.');
+ }
+ return false;
+ }
+ return true;
+ },
+ syncNumbersFromResolvedAnchor(anchor) {
+ if (this.anchorIsoMode && anchor) {
+ this.populateAnchorFromDate(anchor);
+ }
+ },
+ commitDateRange(start, end) {
+ const clause = buildMongoDateRangeClause(start, end);
+ const nextFilter = buildDateFilterSearchText(this.effectiveFieldPath, clause);
+ this.$emit('apply', nextFilter);
+ this.closePanel();
+ },
+ applyAnchorComparison(op) {
+ if (op !== '$gt' && op !== '$lt') {
+ return;
+ }
+ if (!this.validateField()) {
+ return;
+ }
+ const anchor = this.resolveAnchorDate();
+ if (!anchor) {
+ return;
+ }
+ this.syncNumbersFromResolvedAnchor(anchor);
+ const clause = buildMongoCompareClause(op, anchor);
+ const nextFilter = buildDateFilterSearchText(this.effectiveFieldPath, clause);
+ this.$emit('apply', nextFilter);
+ this.closePanel();
+ },
+ presetRangeNextDays(days) {
+ if (!this.validateField()) {
+ return;
+ }
+ const anchor = this.resolveAnchorDate();
+ if (!anchor) {
+ return;
+ }
+ this.syncNumbersFromResolvedAnchor(anchor);
+ const start = anchor;
+ const end = new Date(anchor.getTime() + days * MS_DAY);
+ this.commitDateRange(start, end);
+ },
+ presetRangePreviousDays(days) {
+ if (!this.validateField()) {
+ return;
+ }
+ const anchor = this.resolveAnchorDate();
+ if (!anchor) {
+ return;
+ }
+ this.syncNumbersFromResolvedAnchor(anchor);
+ const start = new Date(anchor.getTime() - days * MS_DAY);
+ const end = anchor;
+ this.commitDateRange(start, end);
+ },
+ presetRangeNextWeeks(weeks) {
+ this.presetRangeNextDays(weeks * 7);
+ },
+ presetRangePreviousWeeks(weeks) {
+ this.presetRangePreviousDays(weeks * 7);
+ }
+ },
+ beforeUnmount() {
+ this.detachOutsideListener();
+ }
+});
diff --git a/frontend/src/models/models.html b/frontend/src/models/models.html
index d0c7b49b..91beffa2 100644
--- a/frontend/src/models/models.html
+++ b/frontend/src/models/models.html
@@ -109,6 +109,10 @@
@search="search"
>
+
Loading ...
{{documents.length}}/{{numDocuments === 1 ? numDocuments + ' document' : numDocuments + ' documents'}}
From af15756a1ef08c4d749c7cfda66c4c0abdb115ee Mon Sep 17 00:00:00 2001
From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com>
Date: Mon, 1 Jun 2026 10:39:05 -0400
Subject: [PATCH 5/7] Revert "new date UI"
This reverts commit 3fae467f98a4aa343cd925c00de9f2341e1aed2e.
---
.../date-range-filter/date-range-filter.html | 205 -----------
.../date-range-filter/date-range-filter.js | 327 ------------------
frontend/src/models/models.html | 4 -
3 files changed, 536 deletions(-)
delete mode 100644 frontend/src/models/date-range-filter/date-range-filter.html
delete mode 100644 frontend/src/models/date-range-filter/date-range-filter.js
diff --git a/frontend/src/models/date-range-filter/date-range-filter.html b/frontend/src/models/date-range-filter/date-range-filter.html
deleted file mode 100644
index a65cbcb0..00000000
--- a/frontend/src/models/date-range-filter/date-range-filter.html
+++ /dev/null
@@ -1,205 +0,0 @@
-
diff --git a/frontend/src/models/date-range-filter/date-range-filter.js b/frontend/src/models/date-range-filter/date-range-filter.js
deleted file mode 100644
index e9946ef3..00000000
--- a/frontend/src/models/date-range-filter/date-range-filter.js
+++ /dev/null
@@ -1,327 +0,0 @@
-'use strict';
-
-const template = require('./date-range-filter.html');
-
-const MS_DAY = 24 * 60 * 60 * 1000;
-
-function intFromNumberInput(v) {
- if (v === '' || v === null || v === undefined || (typeof v === 'number' && !Number.isFinite(v))) {
- return NaN;
- }
- const n = Number(v);
- return Number.isFinite(n) ? Math.trunc(n) : NaN;
-}
-
-/**
- * Anchor from typed parts interpreted as UTC (calendar + clock in Z).
- * The emitted filter uses toISOString(), so the numbers you type match that string (no local TZ shift).
- */
-function utcDateFromParts(year, month, day, hour, minute) {
- const y = intFromNumberInput(year);
- const mo = intFromNumberInput(month);
- const d = intFromNumberInput(day);
- const h = intFromNumberInput(hour);
- const mi = intFromNumberInput(minute);
- if (![y, mo, d, h, mi].every(n => Number.isFinite(n))) {
- return null;
- }
- if (mo < 1 || mo > 12 || d < 1 || d > 31 || h < 0 || h > 23 || mi < 0 || mi > 59) {
- return null;
- }
- const t = Date.UTC(y, mo - 1, d, h, mi, 0, 0);
- const dt = new Date(t);
- if (
- dt.getUTCFullYear() !== y ||
- dt.getUTCMonth() !== mo - 1 ||
- dt.getUTCDate() !== d ||
- dt.getUTCHours() !== h ||
- dt.getUTCMinutes() !== mi
- ) {
- return null;
- }
- return dt;
-}
-
-/** Replace the filter bar entirely with this single-field date criterion. */
-function buildDateFilterSearchText(fieldPath, filterValueFragment) {
- const key = JSON.stringify(fieldPath);
- return `{ ${key}: ${filterValueFragment} }`;
-}
-
-function buildMongoDateRangeClause(start, end) {
- const a = start.toISOString();
- const b = end.toISOString();
- return `{ $gte: new Date(${JSON.stringify(a)}), $lt: new Date(${JSON.stringify(b)}) }`;
-}
-
-/** @param {'$gt' | '$lt'} op */
-function buildMongoCompareClause(op, instant) {
- const iso = instant.toISOString();
- return `{ ${op}: new Date(${JSON.stringify(iso)}) }`;
-}
-
-function parseIso8601Like(raw) {
- if (typeof raw !== 'string') {
- return null;
- }
- const t = raw.trim();
- if (!t) {
- return null;
- }
- const d = new Date(t);
- return Number.isNaN(d.getTime()) ? null : d;
-}
-
-function collectDatePathsFromSchema(schemaPaths) {
- if (!Array.isArray(schemaPaths)) {
- return [];
- }
- const out = [];
- for (const p of schemaPaths) {
- if (!p || typeof p.path !== 'string') {
- continue;
- }
- if (p.instance === 'Date') {
- out.push(p.path);
- }
- if (p.schema && typeof p.schema === 'object') {
- for (const subKey of Object.keys(p.schema)) {
- const sub = p.schema[subKey];
- if (sub && sub.instance === 'Date') {
- out.push(`${p.path}.${subKey}`);
- }
- }
- }
- }
- const scorePath = path => {
- const p = String(path).toLowerCase();
- if (p === 'createdat') {
- return 0;
- }
- if (p === 'updatedat') {
- return 1;
- }
- if (p.endsWith('.createdat')) {
- return 2;
- }
- if (p.endsWith('.updatedat')) {
- return 3;
- }
- return 10;
- };
- out.sort((a, b) => {
- const ds = scorePath(a) - scorePath(b);
- if (ds !== 0) {
- return ds;
- }
- return a.localeCompare(b);
- });
- return out;
-}
-
-module.exports = app => app.component('date-range-filter', {
- template,
- props: {
- schemaPaths: {
- type: Array,
- default: () => []
- }
- },
- data() {
- return {
- panelOpen: false,
- selectedPath: '',
- customPath: '',
- anchorYear: null,
- anchorMonth: null,
- anchorDay: null,
- anchorHour: null,
- anchorMinute: null,
- anchorSeededOnce: false,
- anchorIsoMode: false,
- isoAnchorInput: '',
- _onPointerDownOutside: null
- };
- },
- computed: {
- datePaths() {
- return collectDatePathsFromSchema(this.schemaPaths);
- },
- useCustomPath() {
- return this.datePaths.length === 0;
- },
- effectiveFieldPath() {
- if (this.useCustomPath) {
- return typeof this.customPath === 'string' ? this.customPath.trim() : '';
- }
- return typeof this.selectedPath === 'string' ? this.selectedPath.trim() : '';
- }
- },
- watch: {
- datePaths: {
- handler(paths) {
- if (paths.length === 0) {
- return;
- }
- if (!paths.includes(this.selectedPath)) {
- this.selectedPath = paths[0];
- }
- },
- immediate: true
- },
- schemaPaths() {
- if (this.datePaths.length > 0 && !this.selectedPath) {
- this.selectedPath = this.datePaths[0];
- }
- }
- },
- methods: {
- populateAnchorFromDate(dt) {
- this.anchorYear = dt.getUTCFullYear();
- this.anchorMonth = dt.getUTCMonth() + 1;
- this.anchorDay = dt.getUTCDate();
- this.anchorHour = dt.getUTCHours();
- this.anchorMinute = dt.getUTCMinutes();
- },
- onIsoAnchorPaste(ev) {
- const text = (ev.clipboardData && ev.clipboardData.getData('text/plain')) || '';
- const trimmed = text.trim();
- if (!trimmed) {
- return;
- }
- ev.preventDefault();
- this.isoAnchorInput = trimmed;
- const d = parseIso8601Like(trimmed);
- if (d) {
- this.populateAnchorFromDate(d);
- this.anchorSeededOnce = true;
- }
- },
- togglePanel(ev) {
- ev?.preventDefault?.();
- this.panelOpen = !this.panelOpen;
- if (this.panelOpen && !this.anchorSeededOnce) {
- this.populateAnchorFromDate(new Date());
- this.anchorSeededOnce = true;
- }
- this.attachOutsideListener();
- },
- closePanel() {
- this.panelOpen = false;
- this.detachOutsideListener();
- },
- attachOutsideListener() {
- this.detachOutsideListener();
- if (!this.panelOpen) {
- return;
- }
- this._onPointerDownOutside = ev => {
- const root = this.$refs.root;
- if (!root || root.contains(ev.target)) {
- return;
- }
- this.closePanel();
- };
- document.addEventListener('pointerdown', this._onPointerDownOutside, true);
- },
- detachOutsideListener() {
- if (this._onPointerDownOutside) {
- document.removeEventListener('pointerdown', this._onPointerDownOutside, true);
- this._onPointerDownOutside = null;
- }
- },
- resolveAnchorDate() {
- let dt = null;
- if (this.anchorIsoMode) {
- dt = parseIso8601Like(this.isoAnchorInput);
- if (!dt && this.$toast) {
- this.$toast.error('Could not parse ISO 8601. Example: 2026-05-08T14:30:00.000Z');
- }
- } else {
- dt = utcDateFromParts(
- this.anchorYear,
- this.anchorMonth,
- this.anchorDay,
- this.anchorHour,
- this.anchorMinute
- );
- if (!dt && this.$toast) {
- this.$toast.error('Enter a valid UTC date and time using the number fields.');
- }
- }
- return dt;
- },
- validateField() {
- if (!this.effectiveFieldPath) {
- if (this.$toast) {
- this.$toast.error('Choose or enter a date field path.');
- }
- return false;
- }
- return true;
- },
- syncNumbersFromResolvedAnchor(anchor) {
- if (this.anchorIsoMode && anchor) {
- this.populateAnchorFromDate(anchor);
- }
- },
- commitDateRange(start, end) {
- const clause = buildMongoDateRangeClause(start, end);
- const nextFilter = buildDateFilterSearchText(this.effectiveFieldPath, clause);
- this.$emit('apply', nextFilter);
- this.closePanel();
- },
- applyAnchorComparison(op) {
- if (op !== '$gt' && op !== '$lt') {
- return;
- }
- if (!this.validateField()) {
- return;
- }
- const anchor = this.resolveAnchorDate();
- if (!anchor) {
- return;
- }
- this.syncNumbersFromResolvedAnchor(anchor);
- const clause = buildMongoCompareClause(op, anchor);
- const nextFilter = buildDateFilterSearchText(this.effectiveFieldPath, clause);
- this.$emit('apply', nextFilter);
- this.closePanel();
- },
- presetRangeNextDays(days) {
- if (!this.validateField()) {
- return;
- }
- const anchor = this.resolveAnchorDate();
- if (!anchor) {
- return;
- }
- this.syncNumbersFromResolvedAnchor(anchor);
- const start = anchor;
- const end = new Date(anchor.getTime() + days * MS_DAY);
- this.commitDateRange(start, end);
- },
- presetRangePreviousDays(days) {
- if (!this.validateField()) {
- return;
- }
- const anchor = this.resolveAnchorDate();
- if (!anchor) {
- return;
- }
- this.syncNumbersFromResolvedAnchor(anchor);
- const start = new Date(anchor.getTime() - days * MS_DAY);
- const end = anchor;
- this.commitDateRange(start, end);
- },
- presetRangeNextWeeks(weeks) {
- this.presetRangeNextDays(weeks * 7);
- },
- presetRangePreviousWeeks(weeks) {
- this.presetRangePreviousDays(weeks * 7);
- }
- },
- beforeUnmount() {
- this.detachOutsideListener();
- }
-});
diff --git a/frontend/src/models/models.html b/frontend/src/models/models.html
index 91beffa2..d0c7b49b 100644
--- a/frontend/src/models/models.html
+++ b/frontend/src/models/models.html
@@ -109,10 +109,6 @@
@search="search"
>
-
Loading ...
{{documents.length}}/{{numDocuments === 1 ? numDocuments + ' document' : numDocuments + ' documents'}}
From 3ad1a04c30911bfa4cf5dc3c80134f1300b14464 Mon Sep 17 00:00:00 2001
From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com>
Date: Mon, 1 Jun 2026 12:31:49 -0400
Subject: [PATCH 6/7] new custom date picker
---
frontend/src/_util/calendar.js | 155 ++++++++++++
.../src/_util/document-search-autocomplete.js | 58 ++++-
.../custom-date-picker.html | 128 ++++++++++
.../custom-date-picker/custom-date-picker.js | 230 ++++++++++++++++++
.../document-details/document-details.html | 8 +-
frontend/src/edit-date/edit-date.html | 12 +-
frontend/src/edit-date/edit-date.js | 20 --
.../document-search/document-search.html | 36 ++-
.../models/document-search/document-search.js | 34 +--
test/document-search-autocomplete.test.js | 83 +++++--
10 files changed, 679 insertions(+), 85 deletions(-)
create mode 100644 frontend/src/_util/calendar.js
create mode 100644 frontend/src/custom-date-picker/custom-date-picker.html
create mode 100644 frontend/src/custom-date-picker/custom-date-picker.js
diff --git a/frontend/src/_util/calendar.js b/frontend/src/_util/calendar.js
new file mode 100644
index 00000000..e2463549
--- /dev/null
+++ b/frontend/src/_util/calendar.js
@@ -0,0 +1,155 @@
+'use strict';
+
+function pad2(n) {
+ return String(n).padStart(2, '0');
+}
+
+function pad3(n) {
+ return String(n).padStart(3, '0');
+}
+
+/**
+ * @param {number} value
+ * @param {number} min
+ * @param {number} max
+ */
+function clampInt(value, min, max) {
+ const n = Math.floor(Number(value));
+ if (Number.isNaN(n)) {
+ return min;
+ }
+ return Math.min(max, Math.max(min, n));
+}
+
+/**
+ * @param {Date} date
+ * @returns {string} YYYY-MM-DDTHH:mm:ss.SSS
+ */
+function dateToDatetimeLocal(date) {
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
+ return '';
+ }
+ return [
+ date.getFullYear(),
+ '-',
+ pad2(date.getMonth() + 1),
+ '-',
+ pad2(date.getDate()),
+ 'T',
+ pad2(date.getHours()),
+ ':',
+ pad2(date.getMinutes()),
+ ':',
+ pad2(date.getSeconds()),
+ '.',
+ pad3(date.getMilliseconds())
+ ].join('');
+}
+
+/**
+ * @param {Date} date
+ * @returns {string} YYYY-MM-DD
+ */
+function dateToDateOnly(date) {
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
+ return '';
+ }
+ return [
+ date.getFullYear(),
+ '-',
+ pad2(date.getMonth() + 1),
+ '-',
+ pad2(date.getDate())
+ ].join('');
+}
+
+/**
+ * @param {unknown} value
+ * @returns {Date|null}
+ */
+function parseValueToDate(value) {
+ if (value == null || value === '') {
+ return null;
+ }
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return null;
+ }
+ return date;
+}
+
+/**
+ * @param {string} localValue - YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss.SSS
+ * @returns {Date|null}
+ */
+function localStringToDate(localValue) {
+ if (!localValue) {
+ return null;
+ }
+ const date = new Date(localValue);
+ if (Number.isNaN(date.getTime())) {
+ return null;
+ }
+ return date;
+}
+
+function isSameDay(a, b) {
+ if (!a || !b) {
+ return false;
+ }
+ return (
+ a.getFullYear() === b.getFullYear()
+ && a.getMonth() === b.getMonth()
+ && a.getDate() === b.getDate()
+ );
+}
+
+function isSameMonth(date, year, month) {
+ return date.getFullYear() === year && date.getMonth() === month;
+}
+
+/**
+ * Build a 6-row calendar grid for the given month (local timezone).
+ * @param {number} year
+ * @param {number} month - 0-indexed
+ * @returns {Array<{ key: string, label: number, date: Date, inMonth: boolean }>}
+ */
+function buildCalendarDays(year, month) {
+ const firstOfMonth = new Date(year, month, 1);
+ const startOffset = firstOfMonth.getDay();
+ const gridStart = new Date(year, month, 1 - startOffset);
+
+ const days = [];
+ for (let i = 0; i < 42; i++) {
+ const date = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + i);
+ days.push({
+ key: `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`,
+ label: date.getDate(),
+ date,
+ inMonth: date.getMonth() === month
+ });
+ }
+ return days;
+}
+
+const WEEKDAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
+
+const MONTH_LABELS = [
+ 'January', 'February', 'March', 'April', 'May', 'June',
+ 'July', 'August', 'September', 'October', 'November', 'December'
+];
+
+module.exports = {
+ pad2,
+ pad3,
+ clampInt,
+ dateToDatetimeLocal,
+ dateToDateOnly,
+ parseValueToDate,
+ localStringToDate,
+ isSameDay,
+ isSameMonth,
+ buildCalendarDays,
+ WEEKDAY_LABELS,
+ MONTH_LABELS
+};
diff --git a/frontend/src/_util/document-search-autocomplete.js b/frontend/src/_util/document-search-autocomplete.js
index 1a8a23d0..ea0fd0a0 100644
--- a/frontend/src/_util/document-search-autocomplete.js
+++ b/frontend/src/_util/document-search-autocomplete.js
@@ -1,5 +1,7 @@
'use strict';
+const { dateToDatetimeLocal } = require('./calendar');
+
const { Trie } = require('../models/trie');
const QUERY_SELECTORS = [
@@ -262,20 +264,61 @@ function dateArgumentSliceToDatetimeLocal(slice) {
if (Number.isNaN(d.getTime())) {
return '';
}
- const pad = n => String(n).padStart(2, '0');
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
+ return dateToDatetimeLocal(d);
}
-function insertQuotedIsoInDateArgument(searchText, range, isoString) {
- const quoted = JSON.stringify(isoString);
+/**
+ * @param {string} slice - existing Date(...) argument text
+ * @returns {'quoted'|'timestamp'}
+ */
+function detectDateArgumentFormat(slice) {
+ const t = slice.trim();
+ if (!t) {
+ return 'timestamp';
+ }
+ if (
+ (t.startsWith('"') && t.endsWith('"'))
+ || (t.startsWith('\'') && t.endsWith('\''))
+ ) {
+ return 'quoted';
+ }
+ return 'timestamp';
+}
+
+/**
+ * @param {Date} date
+ * @param {'quoted'|'timestamp'} format
+ */
+function formatDateArgumentValue(date, format) {
+ if (format === 'quoted') {
+ return JSON.stringify(date.toISOString());
+ }
+ return String(date.getTime());
+}
+
+/**
+ * Inserts a Date argument value, preserving quoted ISO vs unquoted timestamp style.
+ * @param {string} searchText
+ * @param {{ innerStart: number, innerEnd: number, needsClosingParen?: boolean }} range
+ * @param {Date} date
+ */
+function insertDateInDateArgument(searchText, range, date) {
+ const slice = searchText.slice(range.innerStart, range.innerEnd);
+ const format = detectDateArgumentFormat(slice);
+ const insertion = formatDateArgumentValue(date, format);
const { innerStart, innerEnd } = range;
const closing = range.needsClosingParen === true ? ')' : '';
return {
- text: searchText.slice(0, innerStart) + quoted + closing + searchText.slice(innerEnd),
- newCursorPos: innerStart + quoted.length + closing.length
+ text: searchText.slice(0, innerStart) + insertion + closing + searchText.slice(innerEnd),
+ newCursorPos: innerStart + insertion.length + closing.length
};
}
+/** @deprecated Use insertDateInDateArgument */
+function insertQuotedIsoInDateArgument(searchText, range, isoString) {
+ return insertDateInDateArgument(searchText, range, new Date(isoString));
+}
+
module.exports = {
buildAutocompleteTrie,
getAutocompleteContext,
@@ -283,6 +326,9 @@ module.exports = {
applySuggestion,
getDatePickerInsertionRange,
dateArgumentSliceToDatetimeLocal,
+ detectDateArgumentFormat,
+ formatDateArgumentValue,
+ insertDateInDateArgument,
insertQuotedIsoInDateArgument,
QUERY_SELECTORS,
VALUE_HELPERS,
diff --git a/frontend/src/custom-date-picker/custom-date-picker.html b/frontend/src/custom-date-picker/custom-date-picker.html
new file mode 100644
index 00000000..9f8521d4
--- /dev/null
+++ b/frontend/src/custom-date-picker/custom-date-picker.html
@@ -0,0 +1,128 @@
+
+
+
+ {{ monthLabel }}
+
+
+
+
+
{{ mini ? label.charAt(0) : label }}
+
+
+
+
+
+
+
+ Time
+
+ :
+
+ :
+
+ .
+
+
+
+
+
+
+
+
diff --git a/frontend/src/custom-date-picker/custom-date-picker.js b/frontend/src/custom-date-picker/custom-date-picker.js
new file mode 100644
index 00000000..3cb93320
--- /dev/null
+++ b/frontend/src/custom-date-picker/custom-date-picker.js
@@ -0,0 +1,230 @@
+'use strict';
+
+const template = require('./custom-date-picker.html');
+const {
+ dateToDatetimeLocal,
+ dateToDateOnly,
+ parseValueToDate,
+ buildCalendarDays,
+ clampInt,
+ isSameDay,
+ WEEKDAY_LABELS,
+ MONTH_LABELS
+} = require('../_util/calendar');
+
+function timePartsFromDate(date) {
+ if (!date) {
+ return { hour: 0, minute: 0, second: 0, millisecond: 0 };
+ }
+ return {
+ hour: date.getHours(),
+ minute: date.getMinutes(),
+ second: date.getSeconds(),
+ millisecond: date.getMilliseconds()
+ };
+}
+
+module.exports = app => app.component('custom-date-picker', {
+ template,
+ props: {
+ value: {
+ default: null
+ },
+ mode: {
+ type: String,
+ default: 'datetime' // 'date' | 'datetime'
+ },
+ compact: {
+ type: Boolean,
+ default: false
+ },
+ mini: {
+ type: Boolean,
+ default: false
+ },
+ immediate: {
+ type: Boolean,
+ default: true
+ }
+ },
+ emits: ['input', 'commit'],
+ data() {
+ const selected = parseValueToDate(this.value);
+ const anchor = selected || new Date();
+ const time = timePartsFromDate(selected);
+ return {
+ viewYear: anchor.getFullYear(),
+ viewMonth: anchor.getMonth(),
+ selectedDate: selected,
+ hour: time.hour,
+ minute: time.minute,
+ second: time.second,
+ millisecond: time.millisecond,
+ pendingValue: null
+ };
+ },
+ watch: {
+ value() {
+ this.syncFromValue();
+ }
+ },
+ computed: {
+ weekdayLabels() {
+ return WEEKDAY_LABELS;
+ },
+ monthLabel() {
+ return `${MONTH_LABELS[this.viewMonth]} ${this.viewYear}`;
+ },
+ calendarDays() {
+ return buildCalendarDays(this.viewYear, this.viewMonth);
+ },
+ today() {
+ const now = new Date();
+ return new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ },
+ showTime() {
+ return this.mode === 'datetime';
+ },
+ timeInputClass() {
+ return [
+ 'w-10 min-w-0 rounded border border-edge-strong bg-surface px-0.5 font-mono text-center outline-edge-strong focus:ring-1 focus:ring-primary-subtle',
+ this.mini ? 'h-6 text-[10px]' : 'h-8 text-sm'
+ ];
+ }
+ },
+ methods: {
+ syncFromValue() {
+ const selected = parseValueToDate(this.value);
+ this.selectedDate = selected;
+ if (selected) {
+ this.viewYear = selected.getFullYear();
+ this.viewMonth = selected.getMonth();
+ const time = timePartsFromDate(selected);
+ this.hour = time.hour;
+ this.minute = time.minute;
+ this.second = time.second;
+ this.millisecond = time.millisecond;
+ }
+ },
+ clampedTimeParts() {
+ return {
+ hour: clampInt(this.hour, 0, 23),
+ minute: clampInt(this.minute, 0, 59),
+ second: clampInt(this.second, 0, 59),
+ millisecond: clampInt(this.millisecond, 0, 999)
+ };
+ },
+ dayClasses(day) {
+ const selected = this.selectedDate;
+ const isSelected = selected && isSameDay(day.date, selected);
+ const isToday = isSameDay(day.date, this.today);
+ return {
+ 'text-content-tertiary': !day.inMonth,
+ 'bg-teal-600 text-white hover:bg-teal-700': isSelected,
+ 'hover:bg-surface-hover': day.inMonth && !isSelected,
+ 'ring-1 ring-teal-600 ring-inset': isToday && !isSelected && day.inMonth,
+ 'font-medium': day.inMonth
+ };
+ },
+ prevMonth() {
+ if (this.viewMonth === 0) {
+ this.viewMonth = 11;
+ this.viewYear -= 1;
+ } else {
+ this.viewMonth -= 1;
+ }
+ },
+ nextMonth() {
+ if (this.viewMonth === 11) {
+ this.viewMonth = 0;
+ this.viewYear += 1;
+ } else {
+ this.viewMonth += 1;
+ }
+ },
+ goToToday() {
+ const now = new Date();
+ this.viewYear = now.getFullYear();
+ this.viewMonth = now.getMonth();
+ this.selectDay({
+ date: new Date(now.getFullYear(), now.getMonth(), now.getDate()),
+ inMonth: true
+ });
+ },
+ selectDay(day) {
+ const { hour, minute, second, millisecond } = this.clampedTimeParts();
+ const next = new Date(day.date);
+ if (this.showTime) {
+ next.setHours(hour, minute, second, millisecond);
+ } else {
+ next.setHours(0, 0, 0, 0);
+ }
+ this.selectedDate = next;
+ if (!day.inMonth) {
+ this.viewYear = next.getFullYear();
+ this.viewMonth = next.getMonth();
+ }
+ this.emitValue(next);
+ },
+ applyTimeToSelection() {
+ const { hour, minute, second, millisecond } = this.clampedTimeParts();
+ this.hour = hour;
+ this.minute = minute;
+ this.second = second;
+ this.millisecond = millisecond;
+
+ if (!this.selectedDate) {
+ const now = new Date();
+ this.selectedDate = new Date(
+ now.getFullYear(),
+ now.getMonth(),
+ now.getDate(),
+ hour,
+ minute,
+ second,
+ millisecond
+ );
+ this.viewYear = this.selectedDate.getFullYear();
+ this.viewMonth = this.selectedDate.getMonth();
+ } else {
+ this.selectedDate = new Date(
+ this.selectedDate.getFullYear(),
+ this.selectedDate.getMonth(),
+ this.selectedDate.getDate(),
+ hour,
+ minute,
+ second,
+ millisecond
+ );
+ }
+ this.emitValue(this.selectedDate);
+ },
+ valuePayload(date) {
+ if (this.mode === 'date') {
+ return dateToDateOnly(date);
+ }
+ return dateToDatetimeLocal(date);
+ },
+ emitValue(date) {
+ const payload = this.valuePayload(date);
+ if (!this.immediate) {
+ this.pendingValue = payload;
+ return;
+ }
+ this.$emit('input', payload);
+ },
+ commit() {
+ const payload = this.pendingValue != null
+ ? this.pendingValue
+ : (this.selectedDate ? this.valuePayload(this.selectedDate) : null);
+ if (payload == null) {
+ return;
+ }
+ this.$emit('commit', payload);
+ this.pendingValue = null;
+ },
+ isSelectedDay(day) {
+ return this.selectedDate && isSameDay(day.date, this.selectedDate);
+ }
+ }
+});
diff --git a/frontend/src/document-details/document-details.html b/frontend/src/document-details/document-details.html
index 19f5e9a2..b5cafc77 100644
--- a/frontend/src/document-details/document-details.html
+++ b/frontend/src/document-details/document-details.html
@@ -224,13 +224,13 @@
Add New Field
-
diff --git a/frontend/src/edit-date/edit-date.html b/frontend/src/edit-date/edit-date.html
index dfe29520..ee5474e4 100644
--- a/frontend/src/edit-date/edit-date.html
+++ b/frontend/src/edit-date/edit-date.html
@@ -1,14 +1,14 @@
-
+ :value="value"
+ mode="datetime"
+ @input="$emit('input', $event)"
+ />
-
\ No newline at end of file
+
diff --git a/frontend/src/edit-date/edit-date.js b/frontend/src/edit-date/edit-date.js
index 692a6ee7..3f7493c3 100644
--- a/frontend/src/edit-date/edit-date.js
+++ b/frontend/src/edit-date/edit-date.js
@@ -6,9 +6,6 @@ module.exports = app => app.component('edit-date', {
template: template,
props: ['value', 'format'],
emits: ['input'],
- data: () => ({
- inputType: ''
- }),
methods: {
updateFromISO($event) {
const value = $event.target.value;
@@ -28,23 +25,6 @@ module.exports = app => app.component('edit-date', {
}
},
computed: {
- valueAsLocalString() {
- if (this.value == null) {
- return this.value;
- }
- const date = new Date(this.value);
- return [
- date.getFullYear(),
- '-',
- (date.getMonth() + 1).toString().padStart(2, '0'),
- '-',
- date.getDate().toString().padStart(2, '0'),
- 'T',
- date.getHours().toString().padStart(2, '0'),
- ':',
- date.getMinutes().toString().padStart(2, '0')
- ].join('');
- },
valueAsISOString() {
if (this.value == null) {
return '' + this.value;
diff --git a/frontend/src/models/document-search/document-search.html b/frontend/src/models/document-search/document-search.html
index 9a67828d..1ee812ff 100644
--- a/frontend/src/models/document-search/document-search.html
+++ b/frontend/src/models/document-search/document-search.html
@@ -16,16 +16,38 @@
class="absolute z-[9999] bg-surface border border-edge-strong rounded mt-1 w-full shadow"
>
Date
- Open calendar
+
+
diff --git a/frontend/src/models/document-search/document-search.js b/frontend/src/models/document-search/document-search.js
index 7adc8871..9368c7c6 100644
--- a/frontend/src/models/document-search/document-search.js
+++ b/frontend/src/models/document-search/document-search.js
@@ -7,7 +7,7 @@ const {
applySuggestion,
getDatePickerInsertionRange,
dateArgumentSliceToDatetimeLocal,
- insertQuotedIsoInDateArgument
+ insertDateInDateArgument
} = require('../../_util/document-search-autocomplete');
module.exports = app => app.component('document-search', {
@@ -29,7 +29,8 @@ module.exports = app => app.component('document-search', {
autocompleteTrie: null,
searchText: this.value || '',
datePickerContext: null,
- datePickerLocalValue: ''
+ datePickerLocalValue: '',
+ showDateCalendar: false
};
},
watch: {
@@ -48,25 +49,8 @@ module.exports = app => app.component('document-search', {
},
mounted() {
this.$refs.searchInput.focus();
- this._onDocPointerDownCloseDatePicker = this.onDocumentPointerDownCloseDatePicker.bind(this);
- document.addEventListener('pointerdown', this._onDocPointerDownCloseDatePicker, true);
- },
- beforeUnmount() {
- if (this._onDocPointerDownCloseDatePicker) {
- document.removeEventListener('pointerdown', this._onDocPointerDownCloseDatePicker, true);
- }
},
methods: {
- onDocumentPointerDownCloseDatePicker(ev) {
- const drop = this.$refs.autocompleteDropdown;
- if (!drop || drop.contains(ev.target)) {
- return;
- }
- const dateEl = this.$refs.datePickerInput;
- if (dateEl) {
- dateEl.blur();
- }
- },
focusInput() {
const input = this.$refs.searchInput;
if (input && typeof input.focus === 'function') {
@@ -102,6 +86,9 @@ module.exports = app => app.component('document-search', {
const cursorPos = input ? input.selectionStart : 0;
const dateRange = getDatePickerInsertionRange(this.searchText, cursorPos);
+ if (!dateRange) {
+ this.showDateCalendar = false;
+ }
this.datePickerContext = dateRange;
if (dateRange) {
const argSlice = this.searchText.slice(dateRange.innerStart, dateRange.innerEnd);
@@ -122,6 +109,9 @@ module.exports = app => app.component('document-search', {
this.autocompleteSuggestions = [];
}
},
+ toggleDateCalendar() {
+ this.showDateCalendar = !this.showDateCalendar;
+ },
applyDateFromPicker(localDateTime) {
if (!localDateTime || !this.datePickerContext) {
return;
@@ -133,14 +123,14 @@ module.exports = app => app.component('document-search', {
}
return;
}
- const iso = picked.toISOString();
- const result = insertQuotedIsoInDateArgument(
+ const result = insertDateInDateArgument(
this.searchText,
this.datePickerContext,
- iso
+ picked
);
const input = this.$refs.searchInput;
this.searchText = result.text;
+ this.showDateCalendar = false;
this.autocompleteSuggestions = [];
this.$nextTick(() => {
if (input) {
diff --git a/test/document-search-autocomplete.test.js b/test/document-search-autocomplete.test.js
index 88c3dbe2..c505c1d1 100644
--- a/test/document-search-autocomplete.test.js
+++ b/test/document-search-autocomplete.test.js
@@ -8,6 +8,8 @@ const {
applySuggestion,
getDatePickerInsertionRange,
dateArgumentSliceToDatetimeLocal,
+ detectDateArgumentFormat,
+ insertDateInDateArgument,
insertQuotedIsoInDateArgument,
FUNCTION_HELPERS
} = require('../frontend/src/_util/document-search-autocomplete');
@@ -338,59 +340,100 @@ describe('document-search-autocomplete', function() {
assert.strictEqual(dateArgumentSliceToDatetimeLocal('not-a-date'), '');
});
- it('returns YYYY-MM-DDTHH:mm for a parseable slice', function() {
+ it('returns YYYY-MM-DDTHH:mm:ss.SSS for a parseable slice', function() {
const result = dateArgumentSliceToDatetimeLocal('2020-06-15T14:30');
- assert.ok(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(result));
+ assert.ok(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$/.test(result));
assert.strictEqual(result.slice(0, 10), '2020-06-15');
});
it('strips surrounding quotes before parsing', function() {
const result = dateArgumentSliceToDatetimeLocal('"2021-12-25T08:00:00.000Z"');
- assert.ok(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(result));
+ assert.ok(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$/.test(result));
});
});
- describe('insertQuotedIsoInDateArgument()', function() {
- it('replaces the inner range with JSON-stringified ISO and no extra ) by default', function() {
+ describe('detectDateArgumentFormat()', function() {
+ it('defaults to timestamp for empty argument', function() {
+ assert.strictEqual(detectDateArgumentFormat(''), 'timestamp');
+ assert.strictEqual(detectDateArgumentFormat(' '), 'timestamp');
+ });
+
+ it('detects quoted string arguments', function() {
+ assert.strictEqual(detectDateArgumentFormat('"2020-01-01"'), 'quoted');
+ assert.strictEqual(detectDateArgumentFormat('\'2020-01-01\''), 'quoted');
+ });
+
+ it('detects unquoted numeric arguments as timestamp', function() {
+ assert.strictEqual(detectDateArgumentFormat('1735689600000'), 'timestamp');
+ assert.strictEqual(detectDateArgumentFormat('2020-01-01'), 'timestamp');
+ });
+ });
+
+ describe('insertDateInDateArgument()', function() {
+ it('inserts unquoted timestamp for empty or unquoted arguments', function() {
const searchText = '{ d: Date(OLD) }';
- const prefix = '{ d: Date(';
- const inner = 'OLD';
- const innerStart = prefix.length;
- const innerEnd = innerStart + inner.length;
+ const innerStart = '{ d: Date('.length;
+ const innerEnd = innerStart + 3;
const range = { innerStart, innerEnd, needsClosingParen: false };
- const iso = '2022-03-04T12:00:00.000Z';
+ const date = new Date('2022-03-04T12:00:00.000Z');
- const result = insertQuotedIsoInDateArgument(searchText, range, iso);
- const expectedQuoted = JSON.stringify(iso);
+ const result = insertDateInDateArgument(searchText, range, date);
+
+ assert.strictEqual(result.text, '{ d: Date(' + date.getTime() + ') }');
+ assert.strictEqual(result.newCursorPos, innerStart + String(date.getTime()).length);
+ });
+
+ it('preserves quoted ISO style when replacing a quoted argument', function() {
+ const searchText = '{ d: Date("OLD") }';
+ const innerStart = '{ d: Date('.length;
+ const innerEnd = innerStart + 5;
+ const range = { innerStart, innerEnd, needsClosingParen: false };
+ const date = new Date('2022-03-04T12:00:00.000Z');
+ const expectedQuoted = JSON.stringify(date.toISOString());
+
+ const result = insertDateInDateArgument(searchText, range, date);
assert.strictEqual(result.text, '{ d: Date(' + expectedQuoted + ') }');
- assert.strictEqual(result.newCursorPos, innerStart + expectedQuoted.length);
});
it('appends ) when needsClosingParen is true', function() {
const searchText = '{ d: Date( }';
const innerStart = '{ d: Date('.length;
const range = { innerStart, innerEnd: innerStart, needsClosingParen: true };
- const iso = '2023-01-02T00:00:00.000Z';
+ const date = new Date('2023-01-02T00:00:00.000Z');
- const result = insertQuotedIsoInDateArgument(searchText, range, iso);
- const expectedQuoted = JSON.stringify(iso);
+ const result = insertDateInDateArgument(searchText, range, date);
- assert.ok(result.text.includes('{ d: Date(' + expectedQuoted + ')'));
- assert.strictEqual(result.newCursorPos, innerStart + expectedQuoted.length + 1);
+ assert.ok(result.text.includes('{ d: Date(' + date.getTime() + ')'));
+ assert.strictEqual(result.newCursorPos, innerStart + String(date.getTime()).length + 1);
});
- it('does not append ) when needsClosingParen is omitted (falsy)', function() {
+ it('does not append extra ) when needsClosingParen is false', function() {
const searchText = '{ d: Date(x) }';
const innerStart = '{ d: Date('.length;
const range = { innerStart, innerEnd: innerStart + 1, needsClosingParen: false };
- const result = insertQuotedIsoInDateArgument(searchText, range, '2000-01-01T00:00:00.000Z');
+ const result = insertDateInDateArgument(searchText, range, new Date('2000-01-01T00:00:00.000Z'));
assert.strictEqual(result.text.indexOf('))'), -1);
assert.ok(result.text.endsWith(') }'));
});
});
+ describe('insertQuotedIsoInDateArgument()', function() {
+ it('still inserts quoted ISO when the existing argument was quoted', function() {
+ const searchText = '{ d: Date("OLD") }';
+ const innerStart = '{ d: Date('.length;
+ const innerEnd = innerStart + 5;
+ const range = { innerStart, innerEnd, needsClosingParen: false };
+ const iso = '2022-03-04T12:00:00.000Z';
+
+ const result = insertQuotedIsoInDateArgument(searchText, range, iso);
+ const expectedQuoted = JSON.stringify(iso);
+
+ assert.strictEqual(result.text, '{ d: Date(' + expectedQuoted + ') }');
+ });
+ });
+
describe('FUNCTION_HELPERS', function() {
it('includes Date ObjectId and objectIdRange', function() {
assert.ok(FUNCTION_HELPERS.has('Date'));
From e9ce47c7686020fe236f703eff242d1b9ce16fbf Mon Sep 17 00:00:00 2001
From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com>
Date: Mon, 1 Jun 2026 12:39:43 -0400
Subject: [PATCH 7/7] new custom date picker UI
---
.../src/_util/document-search-autocomplete.js | 9 +++----
.../document-search/document-search.html | 24 ++++++++++++++++---
.../models/document-search/document-search.js | 8 +++++--
test/document-search-autocomplete.test.js | 12 ++++++++++
4 files changed, 44 insertions(+), 9 deletions(-)
diff --git a/frontend/src/_util/document-search-autocomplete.js b/frontend/src/_util/document-search-autocomplete.js
index ea0fd0a0..c695a4e4 100644
--- a/frontend/src/_util/document-search-autocomplete.js
+++ b/frontend/src/_util/document-search-autocomplete.js
@@ -297,15 +297,16 @@ function formatDateArgumentValue(date, format) {
}
/**
- * Inserts a Date argument value, preserving quoted ISO vs unquoted timestamp style.
+ * Inserts a Date argument value as timestamp or quoted ISO string.
* @param {string} searchText
* @param {{ innerStart: number, innerEnd: number, needsClosingParen?: boolean }} range
* @param {Date} date
+ * @param {'quoted'|'timestamp'} [format] - when omitted, inferred from the existing argument
*/
-function insertDateInDateArgument(searchText, range, date) {
+function insertDateInDateArgument(searchText, range, date, format) {
const slice = searchText.slice(range.innerStart, range.innerEnd);
- const format = detectDateArgumentFormat(slice);
- const insertion = formatDateArgumentValue(date, format);
+ const resolvedFormat = format || detectDateArgumentFormat(slice);
+ const insertion = formatDateArgumentValue(date, resolvedFormat);
const { innerStart, innerEnd } = range;
const closing = range.needsClosingParen === true ? ')' : '';
return {
diff --git a/frontend/src/models/document-search/document-search.html b/frontend/src/models/document-search/document-search.html
index 1ee812ff..43a9427e 100644
--- a/frontend/src/models/document-search/document-search.html
+++ b/frontend/src/models/document-search/document-search.html
@@ -17,9 +17,17 @@
>
Date
+