Date
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();
+ }
+ },
emitSearch() {
this.$emit('input', this.searchText);
this.$emit('search', this.searchText);
diff --git a/test/document-search-autocomplete.test.js b/test/document-search-autocomplete.test.js
index 5ede496f..88c3dbe2 100644
--- a/test/document-search-autocomplete.test.js
+++ b/test/document-search-autocomplete.test.js
@@ -5,7 +5,11 @@ const {
buildAutocompleteTrie,
getAutocompleteContext,
getAutocompleteSuggestions,
- applySuggestion
+ applySuggestion,
+ getDatePickerInsertionRange,
+ dateArgumentSliceToDatetimeLocal,
+ insertQuotedIsoInDateArgument,
+ FUNCTION_HELPERS
} = require('../frontend/src/_util/document-search-autocomplete');
describe('document-search-autocomplete', function() {
@@ -277,4 +281,122 @@ describe('document-search-autocomplete', function() {
assert.strictEqual(result.text, '{ "name":');
});
});
+
+ describe('getDatePickerInsertionRange()', function() {
+ it('returns null when the cursor is not inside Date(...)', function() {
+ assert.strictEqual(getDatePickerInsertionRange('{ x: 1 }', 5), null);
+ assert.strictEqual(getDatePickerInsertionRange('{ d: 1 }', '{ d: '.length), null);
+ });
+
+ it('detects new Date( at end of before', function() {
+ const searchText = '{ x: new Date(';
+ const cursorPos = searchText.length;
+ const range = getDatePickerInsertionRange(searchText, cursorPos);
+
+ assert.ok(range);
+ assert.strictEqual(searchText.slice(0, range.innerStart).endsWith('new Date('), true);
+ assert.strictEqual(range.needsClosingParen, true);
+ assert.strictEqual(range.innerEnd, cursorPos);
+ });
+
+ it('sets needsClosingParen false when ) is present after the cursor', function() {
+ const searchText = '{ createdAt: Date() }';
+ const cursorPos = '{ createdAt: Date('.length;
+ const range = getDatePickerInsertionRange(searchText, cursorPos);
+
+ assert.ok(range);
+ assert.strictEqual(range.needsClosingParen, false);
+ assert.strictEqual(searchText.slice(range.innerStart, range.innerEnd), '');
+ assert.strictEqual(searchText[range.innerEnd], ')');
+ });
+
+ it('sets needsClosingParen true when no ) appears after the cursor', function() {
+ const searchText = '{ createdAt: Date( }';
+ const cursorPos = '{ createdAt: Date('.length;
+ const range = getDatePickerInsertionRange(searchText, cursorPos);
+
+ assert.ok(range);
+ assert.strictEqual(range.needsClosingParen, true);
+ assert.strictEqual(range.innerEnd, cursorPos);
+ });
+
+ it('finds inner slice when cursor is before the closing quote of a literal', function() {
+ const searchText = '{ createdAt: Date("2020-01-01") }';
+ const cursorPos = '{ createdAt: Date("2020-01-01'.length;
+ const range = getDatePickerInsertionRange(searchText, cursorPos);
+
+ assert.ok(range);
+ assert.strictEqual(searchText.slice(range.innerStart, range.innerEnd), '"2020-01-01"');
+ assert.strictEqual(range.needsClosingParen, false);
+ });
+ });
+
+ describe('dateArgumentSliceToDatetimeLocal()', function() {
+ it('returns empty string for blank or invalid input', function() {
+ assert.strictEqual(dateArgumentSliceToDatetimeLocal(''), '');
+ assert.strictEqual(dateArgumentSliceToDatetimeLocal(' '), '');
+ assert.strictEqual(dateArgumentSliceToDatetimeLocal('not-a-date'), '');
+ });
+
+ it('returns YYYY-MM-DDTHH:mm 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.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));
+ });
+ });
+
+ describe('insertQuotedIsoInDateArgument()', function() {
+ it('replaces the inner range with JSON-stringified ISO and no extra ) by default', function() {
+ const searchText = '{ d: Date(OLD) }';
+ const prefix = '{ d: Date(';
+ const inner = 'OLD';
+ const innerStart = prefix.length;
+ const innerEnd = innerStart + inner.length;
+ 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 + ') }');
+ 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 result = insertQuotedIsoInDateArgument(searchText, range, iso);
+ const expectedQuoted = JSON.stringify(iso);
+
+ assert.ok(result.text.includes('{ d: Date(' + expectedQuoted + ')'));
+ assert.strictEqual(result.newCursorPos, innerStart + expectedQuoted.length + 1);
+ });
+
+ it('does not append ) when needsClosingParen is omitted (falsy)', 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');
+
+ assert.strictEqual(result.text.indexOf('))'), -1);
+ assert.ok(result.text.endsWith(') }'));
+ });
+ });
+
+ describe('FUNCTION_HELPERS', function() {
+ it('includes Date ObjectId and objectIdRange', function() {
+ assert.ok(FUNCTION_HELPERS.has('Date'));
+ assert.ok(FUNCTION_HELPERS.has('ObjectId'));
+ assert.ok(FUNCTION_HELPERS.has('objectIdRange'));
+ assert.ok(!FUNCTION_HELPERS.has('Math'));
+ });
+ });
});
From 8cd1e7326d928b2d9366559c866f89b7274359a3 Mon Sep 17 00:00:00 2001
From: Daniel Diaz <39510674+IslandRhythms@users.noreply.github.com>
Date: Fri, 8 May 2026 15:53:53 -0400
Subject: [PATCH 3/4] copilot suggestions
---
.../document-search/document-search.html | 4 ++--
.../models/document-search/document-search.js | 18 +++++++++++++++++-
2 files changed, 19 insertions(+), 3 deletions(-)
diff --git a/frontend/src/models/document-search/document-search.html b/frontend/src/models/document-search/document-search.html
index feda0dc5..9a67828d 100644
--- a/frontend/src/models/document-search/document-search.html
+++ b/frontend/src/models/document-search/document-search.html
@@ -5,9 +5,9 @@
type="text"
placeholder="Filter (supports JS, dates, ObjectIds, and more)"
v-model="searchText"
- @click="initFilter(); updateAutocomplete()"
+ @click="initFilter($event); updateAutocomplete()"
@input="updateAutocomplete"
- @keyup="updateAutocomplete"
+ @keyup="onSearchKeyup"
@keydown="handleKeyDown"
/>
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/4] 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'}}