# Pull Request Template ## Description This PR introduces a custom, lightweight natural-language date parser (dependency-free except for date-fns) to power snooze actions via the command bar (e.g., “Remind me tomorrow at 6am”). It also adds support for multi-language searches. <details> <summary>Supported Formats</summary> ## Snooze Date Parser — Supported Input Formats ## 1. Durations Specify an amount of time from now. ### Basic - `5 minutes` · `2 hours` · `3 days` · `1 week` · `6 months` · `ten year` - `in 2 hours` · `in 30 minutes` · `in a week` · `in a month` - `5 minutes from now` · `a week from now` · `two weeks from now` ### Half / fractional - `half hour` · `half day` · `half week` · `half month` - `in half a day` · `in half an hour` · `in half a week` - `one and a half hours` · `in one and a half hours` - `1.5 hours` · `2.5 days` ### Compound - `1 hour and 30 minutes` · `2 hours and 15 minutes` - `2 days at 3pm` · `1 week at 9am` ### Shorthand (no spaces) - `2h` · `30m` · `1h30m` · `2h15m` - `1h30minutes` · `2hr15min` · `1hour30min` ### Informal quantities - `couple hours` · `a couple of days` · `in a couple hours` - `a few minutes` · `in a few hours` · `in a few days` - `fortnight` · `in a fortnight` _(= 2 weeks)_ ### Trailing "later" - `2 days later` · `a week later` · `month later` ### Prefix words (`after` / `within`) - `after 2 hours` · `after 3 days` · `after ten year` - `within a week` · `within 2 hours` ### Recognised word-numbers `a` (1) · `an` (1) · `one` – `twenty` · `thirty` · `forty` · `fifty` · `sixty` · `ninety` · `half` (0.5) · `couple` (2) · `few` (3) --- ## 2. Relative Days - `today` · `tonight` · `tomorrow` - `tomorrow morning` · `tomorrow afternoon` · `tomorrow evening` · `tomorrow night` - `tomorrow at 3pm` · `tomorrow 9` · `tonight at 8` · `tonight at 10pm` - `tomorrow same time` · `same time tomorrow` - `day after tomorrow` · `the day after tomorrow` · `day after tomorrow at 2pm` - `later today` · `later this afternoon` · `later this evening` --- ## 3. Weekdays - `monday` · `friday` · `wed` · `thu` - `friday at 3pm` · `monday 9am` · `wednesday 14:30` - `monday morning` · `friday afternoon` · `wednesday evening` - `monday morning 6` · `friday evening 7` - `this friday` · `upcoming monday` · `coming friday` - `same time friday` · `same time wednesday` --- ## 4. "Next" Patterns - `next hour` · `next week` · `next month` · `next year` - `next week at 2pm` · `next month at 9am` - `next monday` · `next friday` · `next friday at 3pm` - `next monday morning` · `next friday evening` - `monday of next week` · `next week monday` - `next january` · `next december` - `next business day` · `next working day` --- ## 5. Time of Day - `morning` · `afternoon` · `evening` · `night` · `noon` · `midnight` - `this morning` · `this afternoon` · `this evening` - `early morning` · `late evening` · `late night` - `morning at 8am` · `evening 6pm` · `afternoon 2pm` - `eod` · `end of day` · `end of the day` --- ## 6. Standalone Time - **12-hour:** `3pm` · `9am` · `at 3pm` · `at 9:30am` - **24-hour:** `14:30` · `at 14:30` --- ## 7. Named Dates (Month + Day) - `jan 15` · `january 15` · `march 20` · `dec 25` - `jan 1st` · `march 3rd` · `april 2nd` · `december 31st` - `15 march` · `25 dec` _(reversed order)_ - `jan 15 2025` · `dec 25 2025` · `march 20 next year` - `jan 15 at 2pm` · `march 5 at 2pm` - `december 2025` · `january 2024` _(month + year only)_ --- ## 8. Month + Ordinal Patterns Target a specific week or day within a month. ### Week of month - `april first week` · `july 2nd week` · `feb 3rd week` - `first week of april` · `2nd week of july` ### Day of month - `april first day` · `march second day` · `march 5th day` - `third day of march` · `5th day of jan at 2pm` ### Supported ordinals - **Digit:** `1st` `2nd` `3rd` `4th` `5th` … (up to 31 for days, 5 for weeks) - **Word:** `first` `second` `third` `fourth` `fifth` `sixth` `seventh` `eighth` `ninth` `tenth` --- ## 9. Formal / Numeric Dates - **ISO:** `2025-01-15` - **Slash (M/D/Y):** `01/15/2025` - **Dash (D-M-Y):** `15-01-2025` - **Dot (D.M.Y):** `15.01.2025` - Any of the above **+ time:** `2025-01-15 at 3pm` --- ## 10. Special Phrases - `this weekend` · `weekend` · `next weekend` - `end of week` · `end of month` - `end of next week` · `end of next month` - `beginning of next week` · `start of next week` - `beginning of next month` --- ## 11. Noise / Filler Stripping The parser silently removes conversational prefixes so all of these work exactly the same as the bare expression: ``` snooze for 2 hours → 2 hours remind me tomorrow → tomorrow please snooze until friday → friday can you set a reminder for next week → next week schedule this for jan 15 → jan 15 postpone to next monday → next monday defer for 2 days → 2 days delay it by 1 hour → 1 hour ``` ### Recognised filler verbs / prefixes `snooze` · `remind` · `remind me` · `set a reminder` · `add a reminder` · `schedule` · `postpone` · `defer` · `delay` · `push` ### Recognised prepositions (stripped) `on` · `to` · `for` · `at` · `until` · `till` · `by` · `from` · `after` · `within` ### Typo corrections `tommorow` / `tommorrow` → `tomorrow` · `nxt` → `next` --- ## 12. Multi-Language Support The parser supports localised input via translations in `snooze.json`. ### Translatable token categories - **Units:** minute, hour, day, week, month, year _(singular + plural)_ - **Relative days:** tomorrow, day after tomorrow, next week / month, this / next weekend - **Time of day:** morning, afternoon, evening, night, noon, midnight - **Word numbers:** one through ten, twelve, fifteen, twenty, thirty - **Ordinals:** first through fifth - **Structural words:** at, in, of, after, week, day, from now, next year - **Meridiem:** am, pm ### Auto-detected from locale Weekday names and month names are resolved automatically via `Intl.DateTimeFormat` for the user's locale — no manual translation needed. </details> ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? **Screenshots** <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/c690d328-a0df-41d2-b531-2b4e6ce6b5fd" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/fa881acc-4fed-4ba3-9166-58bd953bcb26" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/4d9a224b-641c-409c-a7ce-3dec2b5355e2" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/465b9835-d82c-4bc7-a2ae-94976ada2d3b" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/839fe8fc-8943-4b66-83ca-5c61c95f24d8" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/3a9a54f2-7669-40f2-b098-a3f5c183526d" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/7791ab2b-c763-49a9-90a0-e91b0d8f0a26" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/4689390c-0e7f-48ae-acc7-d8e28695452f" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/d0aa5217-d0e1-4f41-b663-72888d028a3a" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/4fa9ff5b-a874-43d5-812f-6abe1a95a5ac" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/2c8199a6-f872-46af-986f-bdf8597248f5" /> <img width="974" height="530" alt="image" src="https://github.com/user-attachments/assets/5bd9effc-7518-4f96-b2f2-7c547f32f500" /> ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules
396 lines
11 KiB
JavaScript
396 lines
11 KiB
JavaScript
/**
|
|
* Shared lookup tables and helper functions used by the parser,
|
|
* suggestions, and localization modules.
|
|
*/
|
|
|
|
import {
|
|
add,
|
|
set,
|
|
isValid,
|
|
isAfter,
|
|
nextMonday,
|
|
nextTuesday,
|
|
nextWednesday,
|
|
nextThursday,
|
|
nextFriday,
|
|
nextSaturday,
|
|
nextSunday,
|
|
} from 'date-fns';
|
|
|
|
// ─── Token Maps ──────────────────────────────────────────────────────────────
|
|
// All keys are lowercase. Short forms and full names both work.
|
|
|
|
/** Weekday name or short form → day index (0 = Sunday). */
|
|
export const WEEKDAY_MAP = {
|
|
sunday: 0,
|
|
sun: 0,
|
|
monday: 1,
|
|
mon: 1,
|
|
tuesday: 2,
|
|
tue: 2,
|
|
tues: 2,
|
|
wednesday: 3,
|
|
wed: 3,
|
|
thursday: 4,
|
|
thu: 4,
|
|
thur: 4,
|
|
thurs: 4,
|
|
friday: 5,
|
|
fri: 5,
|
|
saturday: 6,
|
|
sat: 6,
|
|
};
|
|
|
|
/** Month name or short form → month index (0 = January). */
|
|
export const MONTH_MAP = {
|
|
january: 0,
|
|
jan: 0,
|
|
february: 1,
|
|
feb: 1,
|
|
march: 2,
|
|
mar: 2,
|
|
april: 3,
|
|
apr: 3,
|
|
may: 4,
|
|
june: 5,
|
|
jun: 5,
|
|
july: 6,
|
|
jul: 6,
|
|
august: 7,
|
|
aug: 7,
|
|
september: 8,
|
|
sep: 8,
|
|
sept: 8,
|
|
october: 9,
|
|
oct: 9,
|
|
november: 10,
|
|
nov: 10,
|
|
december: 11,
|
|
dec: 11,
|
|
};
|
|
|
|
/** Words like "today" or "tomorrow" → how many days from now. */
|
|
export const RELATIVE_DAY_MAP = {
|
|
today: 0,
|
|
tonight: 0,
|
|
tonite: 0,
|
|
tomorrow: 1,
|
|
tmr: 1,
|
|
tmrw: 1,
|
|
};
|
|
|
|
/** Unit shorthand → full unit name used by date-fns. */
|
|
export const UNIT_MAP = {
|
|
m: 'minutes',
|
|
min: 'minutes',
|
|
mins: 'minutes',
|
|
minute: 'minutes',
|
|
minutes: 'minutes',
|
|
h: 'hours',
|
|
hr: 'hours',
|
|
hrs: 'hours',
|
|
hour: 'hours',
|
|
hours: 'hours',
|
|
d: 'days',
|
|
day: 'days',
|
|
days: 'days',
|
|
w: 'weeks',
|
|
wk: 'weeks',
|
|
wks: 'weeks',
|
|
week: 'weeks',
|
|
weeks: 'weeks',
|
|
mo: 'months',
|
|
month: 'months',
|
|
months: 'months',
|
|
y: 'years',
|
|
yr: 'years',
|
|
yrs: 'years',
|
|
year: 'years',
|
|
years: 'years',
|
|
};
|
|
|
|
/** English number words → their numeric value. */
|
|
export const WORD_NUMBER_MAP = {
|
|
a: 1,
|
|
an: 1,
|
|
one: 1,
|
|
couple: 2,
|
|
few: 3,
|
|
two: 2,
|
|
three: 3,
|
|
four: 4,
|
|
five: 5,
|
|
six: 6,
|
|
seven: 7,
|
|
eight: 8,
|
|
nine: 9,
|
|
ten: 10,
|
|
eleven: 11,
|
|
twelve: 12,
|
|
thirteen: 13,
|
|
fourteen: 14,
|
|
fifteen: 15,
|
|
sixteen: 16,
|
|
seventeen: 17,
|
|
eighteen: 18,
|
|
nineteen: 19,
|
|
twenty: 20,
|
|
thirty: 30,
|
|
forty: 40,
|
|
fifty: 50,
|
|
sixty: 60,
|
|
ninety: 90,
|
|
half: 0.5,
|
|
};
|
|
|
|
/** Day index → the date-fns function that finds the next occurrence. */
|
|
export const NEXT_WEEKDAY_FN = {
|
|
0: nextSunday,
|
|
1: nextMonday,
|
|
2: nextTuesday,
|
|
3: nextWednesday,
|
|
4: nextThursday,
|
|
5: nextFriday,
|
|
6: nextSaturday,
|
|
};
|
|
|
|
/** Time-of-day label → default hour and minute. */
|
|
export const TIME_OF_DAY_MAP = {
|
|
morning: { hours: 9, minutes: 0 },
|
|
noon: { hours: 12, minutes: 0 },
|
|
afternoon: { hours: 14, minutes: 0 },
|
|
evening: { hours: 18, minutes: 0 },
|
|
night: { hours: 20, minutes: 0 },
|
|
tonight: { hours: 20, minutes: 0 },
|
|
midnight: { hours: 0, minutes: 0 },
|
|
eod: { hours: 17, minutes: 0 },
|
|
'end of day': { hours: 17, minutes: 0 },
|
|
'end of the day': { hours: 17, minutes: 0 },
|
|
};
|
|
|
|
/** Allowed hour range per label — used to pick am or pm when not specified. */
|
|
export const TOD_HOUR_RANGE = {
|
|
morning: [4, 12],
|
|
noon: [11, 13],
|
|
afternoon: [12, 18],
|
|
evening: [16, 22],
|
|
night: [18, 24],
|
|
tonight: [18, 24],
|
|
midnight: [23, 25],
|
|
};
|
|
|
|
/** What "half hour", "half day", etc. actually mean in date-fns terms. */
|
|
export const HALF_UNIT_DURATIONS = {
|
|
hour: { minutes: 30 },
|
|
day: { hours: 12 },
|
|
week: { days: 3, hours: 12 },
|
|
month: { days: 15 },
|
|
year: { months: 6 },
|
|
};
|
|
|
|
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 },
|
|
};
|
|
|
|
// ─── Unicode / Normalization ────────────────────────────────────────────────
|
|
// Turn non-ASCII digits and punctuation into plain ASCII so the
|
|
// parser only has to deal with standard characters.
|
|
|
|
const UNICODE_DIGIT_RANGES = [
|
|
[0x30, 0x39],
|
|
[0x660, 0x669], // Arabic-Indic
|
|
[0x6f0, 0x6f9], // Eastern Arabic-Indic
|
|
[0x966, 0x96f], // Devanagari
|
|
[0x9e6, 0x9ef], // Bengali
|
|
[0xa66, 0xa6f], // Gurmukhi
|
|
[0xae6, 0xaef], // Gujarati
|
|
[0xb66, 0xb6f], // Oriya
|
|
[0xbe6, 0xbef], // Tamil
|
|
[0xc66, 0xc6f], // Telugu
|
|
[0xce6, 0xcef], // Kannada
|
|
[0xd66, 0xd6f], // Malayalam
|
|
];
|
|
|
|
const toAsciiDigit = char => {
|
|
const code = char.codePointAt(0);
|
|
const range = UNICODE_DIGIT_RANGES.find(
|
|
([start, end]) => code >= start && code <= end
|
|
);
|
|
if (!range) return char;
|
|
return String(code - range[0]);
|
|
};
|
|
|
|
/** Turn non-ASCII digits (Arabic, Devanagari, etc.) into 0-9. */
|
|
export const normalizeDigits = text => text.replace(/\p{Nd}/gu, toAsciiDigit);
|
|
|
|
const ARABIC_PUNCT_MAP = {
|
|
'\u061f': '?',
|
|
'\u060c': ',',
|
|
'\u061b': ';',
|
|
'\u066b': '.',
|
|
};
|
|
|
|
const NOISE_RE =
|
|
/^(?:(?:can|could|will|would)\s+you\s+)?(?:(?:please|pls|plz|kindly)\s+)?(?:(?:snooze|remind(?:\s+me)?|set(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze|timer))?|add(?:\s+(?:a|the))?(?:\s+(?:reminder|deadline|snooze))?|schedule|postpone|defer|delay|push)(?:\s+(?:it|this))?\s+)?(?:(?:on|to|for|at|until|till|by|from|after|within)\s+)?/;
|
|
|
|
const APPROX_RE = /^(?:approx(?:imately)?|around|about|roughly|~)\s+/;
|
|
|
|
/** Clean up raw input: lowercase, remove punctuation, collapse spaces. */
|
|
export const sanitize = text =>
|
|
normalizeDigits(
|
|
text
|
|
.normalize('NFKC')
|
|
.toLowerCase()
|
|
.replace(/[\u200f\u200e\u066c\u0640]/g, '')
|
|
.replace(/[\u064b-\u065f]/g, '')
|
|
.replace(/\u00a0/g, ' ')
|
|
.replace(/[\u061f\u060c\u061b\u066b]/g, c => ARABIC_PUNCT_MAP[c])
|
|
)
|
|
.replace(/[,!?;]+/g, ' ')
|
|
.replace(/\.+$/g, '')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
|
|
/** Strip filler words like "please snooze for" and fix typos like "tommorow". */
|
|
export const stripNoise = text => {
|
|
let r = text
|
|
.replace(/\ba\s+fortnight\b/g, '2 weeks')
|
|
.replace(/\bfortnight\b/g, '2 weeks')
|
|
.replace(NOISE_RE, '')
|
|
.replace(APPROX_RE, '')
|
|
.replace(/^the\s+/, '')
|
|
.replace(/\bnxt\b/g, 'next')
|
|
.replace(/\ba\s+couple\s+of\b/g, 'couple')
|
|
.replace(/\bcouple\s+of\b/g, 'couple')
|
|
.replace(/\ba\s+couple\b/g, 'couple')
|
|
.replace(/\ba\s+few\b/g, 'few')
|
|
.replace(
|
|
/\b(\d+)\s*(?:h|hr|hours?)[\s]*(\d+)\s*(?:m|min|minutes?)\b/g,
|
|
(_, h, m) =>
|
|
`${h} ${h === '1' ? 'hour' : 'hours'} ${m} ${m === '1' ? 'minute' : 'minutes'}`
|
|
)
|
|
.replace(/\b(\d+)h\b/g, (_, h) => `${h} ${h === '1' ? 'hour' : 'hours'}`)
|
|
.replace(
|
|
/\b(\d+)m\b/g,
|
|
(_, m) => `${m} ${m === '1' ? 'minute' : 'minutes'}`
|
|
)
|
|
.replace(/\btomm?orow\b/g, 'tomorrow')
|
|
.replace(/\s+later$/, '')
|
|
.trim();
|
|
// bare unit without number: "month later" → "1 month", "week" stays
|
|
r = r.replace(/^(minutes?|hours?|days?|weeks?|months?|years?)$/, '1 $1');
|
|
return r;
|
|
};
|
|
|
|
// ─── Utility Functions ──────────────────────────────────────────────────────
|
|
|
|
/** Turn a string into a number. Works with digits ("5") and words ("five"). */
|
|
export const parseNumber = str => {
|
|
if (!str) return null;
|
|
const lower = normalizeDigits(str.toLowerCase().trim());
|
|
if (WORD_NUMBER_MAP[lower] !== undefined) return WORD_NUMBER_MAP[lower];
|
|
const num = Number(lower);
|
|
return Number.isNaN(num) ? null : num;
|
|
};
|
|
|
|
/** Set the time on a date, clearing seconds and milliseconds. */
|
|
export const applyTimeToDate = (date, hours, minutes = 0) =>
|
|
set(date, { hours, minutes, seconds: 0, milliseconds: 0 });
|
|
|
|
/** Parse "3pm", "14:30", or "2:00am" into { hours, minutes }. Returns null if invalid. */
|
|
export const parseTimeString = timeStr => {
|
|
if (!timeStr) return null;
|
|
const match = timeStr
|
|
.toLowerCase()
|
|
.replace(/\s+/g, '')
|
|
.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.?|p\.m\.?)?$/);
|
|
if (!match) return null;
|
|
|
|
const raw = parseInt(match[1], 10);
|
|
const minutes = match[2] ? parseInt(match[2], 10) : 0;
|
|
const meridiem = match[3]?.replace(/\./g, '');
|
|
if (meridiem && (raw < 1 || raw > 12)) return null;
|
|
|
|
const toHours = (h, m) => {
|
|
if (m === 'pm' && h < 12) return h + 12;
|
|
if (m === 'am' && h === 12) return 0;
|
|
return h;
|
|
};
|
|
const hours = toHours(raw, meridiem);
|
|
if (hours > 23 || minutes > 59) return null;
|
|
return { hours, minutes };
|
|
};
|
|
|
|
/** Apply a time string to a date. Falls back to 9 AM if no time is given. */
|
|
export const applyTimeOrDefault = (date, timeStr, defaultHours = 9) => {
|
|
if (timeStr) {
|
|
const time = parseTimeString(timeStr);
|
|
if (!time) return null;
|
|
return applyTimeToDate(date, time.hours, time.minutes);
|
|
}
|
|
return applyTimeToDate(date, defaultHours, 0);
|
|
};
|
|
|
|
/** Build a Date only if the day actually exists (e.g. rejects Feb 30). */
|
|
export const strictDate = (year, month, day) => {
|
|
const date = new Date(year, month, day);
|
|
if (
|
|
!isValid(date) ||
|
|
date.getFullYear() !== year ||
|
|
date.getMonth() !== month ||
|
|
date.getDate() !== day
|
|
)
|
|
return null;
|
|
return date;
|
|
};
|
|
|
|
/** Try up to 8 years ahead to find a valid future date (handles Feb 29 leap years). */
|
|
export const futureOrNextYear = (year, month, day, timeStr, now) => {
|
|
for (let i = 0; i < 9; i += 1) {
|
|
const base = strictDate(year + i, month, day);
|
|
if (base) {
|
|
const date = applyTimeOrDefault(base, timeStr);
|
|
if (!date) return null;
|
|
if (isAfter(date, now)) return date;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
/** If the date is already past, push it to the next day. */
|
|
export const ensureFutureOrNextDay = (date, now) =>
|
|
isAfter(date, now) ? date : add(date, { days: 1 });
|
|
|
|
/** Figure out am/pm from context: "morning 6" → 6am, "evening 6" → 6pm. */
|
|
export const inferHoursFromTOD = (todLabel, rawHour, rawMinutes) => {
|
|
const h = parseInt(rawHour, 10);
|
|
const m = rawMinutes ? parseInt(rawMinutes, 10) : 0;
|
|
if (Number.isNaN(h) || h < 1 || h > 12 || m > 59) return null;
|
|
const range = TOD_HOUR_RANGE[todLabel];
|
|
if (!range) return { hours: h, minutes: m };
|
|
// Try both am and pm interpretations, pick the one in range
|
|
const am = h === 12 ? 0 : h;
|
|
const pm = h === 12 ? 12 : h + 12;
|
|
const inRange = v => v >= range[0] && v < range[1];
|
|
if (inRange(am)) return { hours: am, minutes: m };
|
|
if (inRange(pm)) return { hours: pm, minutes: m };
|
|
const mid = (range[0] + range[1]) / 2;
|
|
return {
|
|
hours: Math.abs(am - mid) <= Math.abs(pm - mid) ? am : pm,
|
|
minutes: m,
|
|
};
|
|
};
|
|
|
|
/** Add a duration that might be fractional, e.g. 1.5 hours becomes 90 minutes. */
|
|
export 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) });
|
|
};
|