Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions frontend/src/_util/calendar.js
Original file line number Diff line number Diff line change
@@ -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
};
108 changes: 108 additions & 0 deletions frontend/src/_util/document-search-autocomplete.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict';

const { dateToDatetimeLocal } = require('./calendar');

const { Trie } = require('../models/trie');

const QUERY_SELECTORS = [
Expand Down Expand Up @@ -218,11 +220,117 @@ function applySuggestion(searchText, cursorPos, suggestion) {
return null;
}

/**
* When the cursor is inside a Date(…) or new Date(…) argument, returns the
* slice indices of that argument so a picker can replace it with a quoted ISO string.
*/
function getDatePickerInsertionRange(searchText, cursorPos) {
const before = searchText.slice(0, cursorPos);
const re = /((?:new\s+)?Date\s*\(\s*)([^)]*)$/i;
const m = before.match(re);
if (!m) {
return null;
}
const innerStart = m.index + m[1].length;
const after = searchText.slice(cursorPos);
let closeIdx = -1;
let parenDepth = 0;
for (let k = 0; k < after.length; k++) {
const ch = after[k];
if (ch === '(') {
parenDepth++;
} else if (ch === ')') {
if (parenDepth === 0) {
closeIdx = cursorPos + k;
break;
}
parenDepth--;
}
}
const innerEnd = closeIdx >= 0 ? closeIdx : cursorPos;
Comment thread
IslandRhythms marked this conversation as resolved.
const needsClosingParen = closeIdx < 0;
return { innerStart, innerEnd, needsClosingParen };
}

function dateArgumentSliceToDatetimeLocal(slice) {
const t = slice.trim();
if (!t) {
return '';
}
const unquoted = (t.startsWith('"') && t.endsWith('"')) || (t.startsWith('\'') && t.endsWith('\''))
? t.slice(1, -1)
: t;
const d = new Date(unquoted);
if (Number.isNaN(d.getTime())) {
return '';
}
return dateToDatetimeLocal(d);
}

/**
* @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 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, format) {
const slice = searchText.slice(range.innerStart, range.innerEnd);
const resolvedFormat = format || detectDateArgumentFormat(slice);
const insertion = formatDateArgumentValue(date, resolvedFormat);
const { innerStart, innerEnd } = range;
const closing = range.needsClosingParen === true ? ')' : '';
return {
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,
getAutocompleteSuggestions,
applySuggestion,
getDatePickerInsertionRange,
dateArgumentSliceToDatetimeLocal,
detectDateArgumentFormat,
formatDateArgumentValue,
insertDateInDateArgument,
insertQuotedIsoInDateArgument,
Comment thread
IslandRhythms marked this conversation as resolved.
QUERY_SELECTORS,
VALUE_HELPERS,
FUNCTION_HELPERS
Expand Down
Loading
Loading