diff --git a/app/javascript/dashboard/helper/snoozeDateParser.js b/app/javascript/dashboard/helper/snoozeDateParser.js index 03ddf897a..4f4c94b42 100644 --- a/app/javascript/dashboard/helper/snoozeDateParser.js +++ b/app/javascript/dashboard/helper/snoozeDateParser.js @@ -190,7 +190,7 @@ const WORD_NUMBERS = Object.keys(WORD_NUMBER_MAP).join('|'); const RELATIVE_DAYS = Object.keys(RELATIVE_DAY_MAP).join('|'); const TIME_OF_DAY_NAMES = 'morning|afternoon|evening|night|noon|midnight'; -const NUM_RE = `(\\d+|${WORD_NUMBERS})`; +const NUM_RE = `(\\d+(?:\\.5)?|${WORD_NUMBERS})`; const UNIT_RE = `(${UNIT_NAMES})`; const TIME_SUFFIX_RE = '(?:\\s+(?:at\\s+)?(\\d{1,2}(?::\\d{2})?\\s*(?:am|pm|a\\.m\\.?|p\\.m\\.?)?|\\d{1,2}:\\d{2}))?'; @@ -297,12 +297,38 @@ const resolveTimeOfDay = (text, now) => { // ─── Pattern Matchers ──────────────────────────────────────────────────────── +const FRACTIONAL_CONVERT = { + hours: { unit: 'minutes', factor: 60 }, + days: { unit: 'hours', factor: 24 }, + weeks: { unit: 'days', factor: 7 }, + months: { unit: 'days', factor: 30 }, + years: { unit: 'months', factor: 12 }, + minutes: { unit: 'seconds', factor: 60 }, +}; + +const addFractionalSafe = (date, unit, amount) => { + if (Number.isInteger(amount)) return add(date, { [unit]: amount }); + if (amount % 1 !== 0.5) return null; + const conv = FRACTIONAL_CONVERT[unit]; + if (conv) return add(date, { [conv.unit]: Math.round(amount * conv.factor) }); + return add(date, { [unit]: Math.round(amount) }); +}; + +const HALF_UNIT_DURATIONS = { + hour: { minutes: 30 }, + day: { hours: 12 }, + week: { days: 3, hours: 12 }, + month: { days: 15 }, + year: { months: 6 }, +}; + const matchRelativeDuration = (text, now) => { - if (text.match(/^(?:in\s+)?half\s+(?:an?\s+)?hour$/)) { - return add(now, { minutes: 30 }); - } - if (text.match(/^(?:in\s+)?half\s+(?:an?\s+)?day$/)) { - return add(now, { hours: 12 }); + const halfMatch = text.match( + /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/ + ); + if (halfMatch) { + const duration = HALF_UNIT_DURATIONS[halfMatch[1]]; + return duration ? add(now, duration) : null; } const match = text.match(new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`)); @@ -312,7 +338,7 @@ const matchRelativeDuration = (text, now) => { const unit = UNIT_MAP[match[2]]; if (amount == null || !unit) return null; - return add(now, { [unit]: amount }); + return addFractionalSafe(now, unit, amount); }; const matchDurationFromNow = (text, now) => { @@ -325,7 +351,7 @@ const matchDurationFromNow = (text, now) => { const unit = UNIT_MAP[match[2]]; if (amount == null || !unit) return null; - return add(now, { [unit]: amount }); + return addFractionalSafe(now, unit, amount); }; const matchRelativeDay = (text, now) => { @@ -792,7 +818,9 @@ export const parseDateFromText = (text, referenceDate = new Date()) => { // ─── Suggestion Candidates (uses maps already defined above) ───────────────── -const CANONICAL_UNITS = [...new Set(Object.values(UNIT_MAP))]; +const SUGGESTION_UNITS = [...new Set(Object.values(UNIT_MAP))].filter( + u => u !== 'seconds' +); const MONTH_NAMES_LONG = Object.keys(MONTH_MAP).filter(k => k.length > 3); @@ -827,9 +855,19 @@ const matchesPrefix = (candidate, text) => { const buildSuggestionCandidates = text => { if (/^\d/.test(text)) { - const num = text.match(/^\d+/)[0]; - return CANONICAL_UNITS.map(u => `${num} ${u}`); + const num = text.match(/^\d+(?:\.5)?/)[0]; + return SUGGESTION_UNITS.map(u => `${num} ${u}`); } + + if ('half'.startsWith(text)) { + return Object.keys(HALF_UNIT_DURATIONS).map(u => `half ${u}`); + } + + const wordNum = WORD_NUMBER_MAP[text]; + if (wordNum != null && wordNum >= 1) { + return SUGGESTION_UNITS.map(u => `${wordNum} ${u}`); + } + return PHRASE_CANDIDATES.filter(c => matchesPrefix(c, text)); }; diff --git a/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js b/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js index 4114f1ac7..f17f88e16 100644 --- a/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js +++ b/app/javascript/dashboard/helper/specs/snoozeDateParser.spec.js @@ -1,4 +1,7 @@ -import { parseDateFromText } from '../snoozeDateParser'; +import { + parseDateFromText, + generateDateSuggestions, +} from '../snoozeDateParser'; const now = new Date('2023-06-16T10:00:00'); @@ -730,3 +733,188 @@ describe('regression: contradictory time-of-day + time rejected', () => { expect(result.date.getHours()).toEqual(14); }); }); + +describe('generateDateSuggestions', () => { + describe('half suggestions', () => { + it('"half" returns half hour/day/week/month/year suggestions', () => { + const results = generateDateSuggestions('half', now); + const labels = results.map(r => r.label); + expect(labels).toContain('half hour'); + expect(labels).toContain('half day'); + expect(labels).toContain('half week'); + expect(labels).toContain('half month'); + expect(labels).toContain('half year'); + }); + + it('"ha" returns half suggestions (partial match)', () => { + const results = generateDateSuggestions('ha', now); + const labels = results.map(r => r.label); + expect(labels).toContain('half hour'); + expect(labels).toContain('half day'); + }); + + it('"hal" returns half suggestions (partial match)', () => { + const results = generateDateSuggestions('hal', now); + expect(results.length).toBeGreaterThan(0); + expect(results[0].label).toMatch(/^half /); + }); + }); + + describe('word number suggestions', () => { + it('"two" returns duration suggestions', () => { + const results = generateDateSuggestions('two', now); + const labels = results.map(r => r.label); + expect(labels).toContain('2 minutes'); + expect(labels).toContain('2 hours'); + expect(labels).toContain('2 days'); + }); + + it('"ten" returns duration suggestions', () => { + const results = generateDateSuggestions('ten', now); + const labels = results.map(r => r.label); + expect(labels).toContain('10 minutes'); + expect(labels).toContain('10 hours'); + }); + + it('"five" returns duration suggestions', () => { + const results = generateDateSuggestions('five', now); + const labels = results.map(r => r.label); + expect(labels).toContain('5 minutes'); + expect(labels).toContain('5 hours'); + expect(labels).toContain('5 days'); + }); + }); + + describe('no seconds in suggestions', () => { + it('"2" does not suggest seconds', () => { + const results = generateDateSuggestions('2', now); + const labels = results.map(r => r.label); + expect(labels).not.toContain('2 seconds'); + expect(labels).toContain('2 minutes'); + }); + + it('"100" does not suggest seconds', () => { + const results = generateDateSuggestions('100', now); + const labels = results.map(r => r.label); + const hasSeconds = labels.some(l => l.includes('seconds')); + expect(hasSeconds).toBe(false); + }); + }); + + describe('decimal number suggestions', () => { + it('"1.5" returns duration suggestions', () => { + const results = generateDateSuggestions('1.5', now); + const labels = results.map(r => r.label); + expect(labels).toContain('1.5 hours'); + expect(labels).toContain('1.5 days'); + }); + }); + + describe('caps at MAX_SUGGESTIONS', () => { + it('returns at most 5 results', () => { + const results = generateDateSuggestions('2', now); + expect(results.length).toBeLessThanOrEqual(5); + }); + }); +}); + +describe('dot-delimited dates', () => { + it('"12.12.2034" parses to Dec 12 2034', () => { + const result = parseDateFromText('12.12.2034', now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2034); + expect(result.date.getMonth()).toEqual(11); + expect(result.date.getDate()).toEqual(12); + }); + + it('"01.06.2025" parses correctly', () => { + const result = parseDateFromText('01.06.2025', now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2025); + }); +}); + +describe('noise word stripping', () => { + it('"snooze this for 5 minutes" parses', () => { + const result = parseDateFromText('snooze this for 5 minutes', now); + expect(result).not.toBeNull(); + }); + + it('"please snooze this for half a day" parses', () => { + const result = parseDateFromText('please snooze this for half a day', now); + expect(result).not.toBeNull(); + }); + + it('"snooze this until tomorrow" parses', () => { + const result = parseDateFromText('snooze this until tomorrow', now); + expect(result).not.toBeNull(); + }); + + it('"schedule this for 2025-01-15" parses', () => { + const result = parseDateFromText('schedule this for 2025-01-15', now); + expect(result).not.toBeNull(); + expect(result.date.getFullYear()).toEqual(2025); + expect(result.date.getMonth()).toEqual(0); + expect(result.date.getDate()).toEqual(15); + }); +}); + +describe('half unit parsing', () => { + it('"half hour" adds 30 minutes', () => { + const result = parseDateFromText('half hour', now); + expect(result).not.toBeNull(); + expect(result.date.getMinutes()).toEqual(30); + }); + + it('"half day" adds 12 hours', () => { + const result = parseDateFromText('half day', now); + expect(result).not.toBeNull(); + expect(result.date.getHours()).toEqual(22); + }); + + it('"half week" parses to a future date', () => { + const result = parseDateFromText('half week', now); + expect(result).not.toBeNull(); + expect(result.date > now).toBe(true); + }); + + it('"half month" parses to a future date', () => { + const result = parseDateFromText('half month', now); + expect(result).not.toBeNull(); + expect(result.date > now).toBe(true); + }); + + it('"half year" parses to ~6 months ahead', () => { + const result = parseDateFromText('half year', now); + expect(result).not.toBeNull(); + expect(result.date.getMonth()).toEqual(11); + }); +}); + +describe('decimal duration parsing (only .5 allowed)', () => { + it('"1.5 hours" parses correctly', () => { + const result = parseDateFromText('1.5 hours', now); + expect(result).not.toBeNull(); + expect(result.date > now).toBe(true); + }); + + it('"1.5 days" parses correctly', () => { + const result = parseDateFromText('1.5 days', now); + expect(result).not.toBeNull(); + expect(result.date > now).toBe(true); + }); + + it('"0.5 hours" parses correctly', () => { + const result = parseDateFromText('0.5 hours', now); + expect(result).not.toBeNull(); + expect(result.date > now).toBe(true); + }); + + it('"1.3 hours" returns null (only .5 allowed)', () => { + expect(parseDateFromText('1.3 hours', now)).toBeNull(); + }); + + it('"2.7 days" returns null (only .5 allowed)', () => { + expect(parseDateFromText('2.7 days', now)).toBeNull(); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue b/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue index 01c1c8d1d..11d5a5a32 100644 --- a/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue +++ b/app/javascript/dashboard/routes/dashboard/commands/commandbar.vue @@ -53,12 +53,12 @@ const placeholder = computed(() => ); const hotKeys = computed(() => [ + ...dynamicSnoozeActions.value, ...inboxHotKeys.value, ...goToCommandHotKeys.value, ...goToAppearanceHotKeys.value, ...bulkActionsHotKeys.value, ...conversationHotKeys.value, - ...dynamicSnoozeActions.value, ]); const setCommandBarData = () => { @@ -156,7 +156,7 @@ const onCommandBarChange = item => { ]; if (uniqueParents.length === 1) { currentCommandRoot.value = uniqueParents[0]; - } else if (uniqueParents.length > 1) { + } else { currentCommandRoot.value = null; } }