Add time support to date mentions (#13222)

This commit is contained in:
Tom Moor
2026-08-01 11:47:46 -04:00
committed by GitHub
parent a6b5898283
commit bb520666ba
11 changed files with 377 additions and 58 deletions
+10 -3
View File
@@ -20,6 +20,7 @@ import {
dateToRelativeReadable,
parseISODate,
toISODate,
toISODateTime,
} from "@shared/utils/date";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { parseNaturalLanguageDate } from "@shared/utils/parseNaturalLanguageDate";
@@ -68,7 +69,7 @@ function MentionMenu({ search = "", isActive, ...rest }: Props) {
const maxResultsInSection = search ? 25 : 5;
// Surface a date suggestion when the search query parses as a natural
// language date (e.g. "tomorrow", "next friday", "jan 2"). Parsing is
// language date (e.g. "tomorrow", "next friday", "jan 2", "1pm"). Parsing is
// asynchronous as chrono-node is loaded lazily, so the result is held in
// state and applied once resolved.
const [parsedISODate, setParsedISODate] = useState<string | undefined>();
@@ -80,9 +81,15 @@ function MentionMenu({ search = "", isActive, ...rest }: Props) {
}
let cancelled = false;
void parseNaturalLanguageDate(search)
.then((date) => {
.then((parsed) => {
if (!cancelled) {
setParsedISODate(date ? toISODate(date) : undefined);
setParsedISODate(
parsed
? parsed.hasTime
? toISODateTime(parsed.date)
: toISODate(parsed.date)
: undefined
);
}
})
.catch(() => {
+21 -3
View File
@@ -1,19 +1,22 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { Slot } from "@radix-ui/react-slot";
import { set } from "date-fns";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { RemoveScroll } from "react-remove-scroll";
import styled from "styled-components";
import { Calendar } from "../../components/Calendar";
import { depths, s } from "../../styles";
import { dateLocale, toISODate } from "../../utils/date";
import { dateLocale, toISODate, toISODateTime } from "../../utils/date";
type Props = {
/** The currently selected date, if any. */
selectedDate?: Date;
/** Whether the selected date is time-specific. */
includeTime?: boolean;
/** The user's language, used to localise the calendar. */
language?: Parameters<typeof dateLocale>[0];
/** Called with the new date-only ISO string when a day is picked. */
/** Called with the new ISO string when a day is picked. */
onChange: (modelId: string) => void;
/** The trigger element the calendar popover is anchored to. */
children: React.ReactNode;
@@ -29,6 +32,7 @@ type Props = {
*/
export default function DateMentionPicker({
selectedDate,
includeTime,
language,
onChange,
children,
@@ -39,9 +43,23 @@ export default function DateMentionPicker({
const handleSelect = React.useCallback(
(date: Date) => {
setOpen(false);
// The calendar only changes the day, so an existing time is carried over.
if (includeTime && selectedDate) {
onChange(
toISODateTime(
set(date, {
hours: selectedDate.getHours(),
minutes: selectedDate.getMinutes(),
})
)
);
return;
}
onChange(toISODate(date));
},
[onChange]
[onChange, includeTime, selectedDate]
);
return (
+6 -1
View File
@@ -10,7 +10,11 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { dateToRelativeReadable, parseISODate } from "../../utils/date";
import {
dateToRelativeReadable,
hasTimeComponent,
parseISODate,
} from "../../utils/date";
import { Backticks } from "../../components/Backticks";
import Flex from "../../components/Flex";
import Icon from "../../components/Icon";
@@ -554,6 +558,7 @@ export const MentionDate = observer(function MentionDate_(props: DateProps) {
<React.Suspense fallback={content}>
<DateMentionPicker
selectedDate={selectedDate}
includeTime={hasTimeComponent(iso)}
language={language}
onChange={onChangeDate}
>
+28 -5
View File
@@ -14,6 +14,7 @@ import { v4 as uuidv4 } from "uuid";
import env from "../../env";
import type { UnfurlResponse } from "../../types";
import { MentionType, UnfurlResourceType } from "../../types";
import { dateToReadable } from "../../utils/date";
import {
MentionCollection,
MentionDocument,
@@ -34,16 +35,35 @@ import mentionRule from "../rules/mention";
import type { ComponentProps } from "../types";
import Node from "./Node";
/**
* Formats a date mention's stored value (a date-only or time-specific ISO
* string) into a human-readable label for display and serialization.
*
* @param node the date mention node.
* @returns the readable label, e.g. "February 3rd at 1:00 PM".
*/
function dateMentionLabel(node: ProsemirrorNode): string {
const modelId = node.attrs.modelId;
return typeof modelId === "string"
? dateToReadable(modelId)
: node.attrs.label;
}
export default class Mention extends Node {
get name() {
return "mention";
}
get schema(): NodeSpec {
const toPlainText = (node: ProsemirrorNode) =>
node.attrs.type === MentionType.User
? `@${node.attrs.label}`
: node.attrs.label;
const toPlainText = (node: ProsemirrorNode) => {
if (node.attrs.type === MentionType.User) {
return `@${node.attrs.label}`;
}
if (node.attrs.type === MentionType.Date) {
return dateMentionLabel(node);
}
return node.attrs.label;
};
return {
attrs: {
@@ -341,7 +361,10 @@ export default class Mention extends Node {
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
const mType = node.attrs.type;
const mId = node.attrs.modelId;
const label = node.attrs.label;
// Date mentions store a machine-readable value, so the label is derived to
// keep the serialized output legible outside of the editor.
const label =
mType === MentionType.Date ? dateMentionLabel(node) : node.attrs.label;
const id = node.attrs.id;
// Use regular links for document and collection mentions
+13
View File
@@ -111,6 +111,19 @@ describe("mention rule", () => {
expect(mentions[0].modelId).toBe("2024-02-03");
expect(mentions[0].label).toBe("February 3rd, 2024");
});
it("should parse a date mention with a time-specific modelId", () => {
const result = md.parse(
"@[February 3rd, 2024 at 1:00 PM](mention://a1b2c3d4-e5f6-7890-abcd-ef1234567890/date/2024-02-03T13:00)",
{}
);
const mentions = findMentionTokens(result);
expect(mentions).toHaveLength(1);
expect(mentions[0].type).toBe("date");
expect(mentions[0].modelId).toBe("2024-02-03T13:00");
expect(mentions[0].label).toBe("February 3rd, 2024 at 1:00 PM");
});
});
describe("mixed content", () => {
+48 -1
View File
@@ -1,8 +1,10 @@
import {
dateToReadable,
dateToRelativeReadable,
hasTimeComponent,
parseISODate,
toISODate,
toISODateTime,
} from "./date";
describe("toISODate / parseISODate", () => {
@@ -17,8 +19,9 @@ describe("toISODate / parseISODate", () => {
expect(parseISODate("not-a-date")).toBeNull();
});
it("rejects strings carrying a time component", () => {
it("rejects strings carrying seconds or a timezone", () => {
expect(parseISODate("2024-02-03T10:00:00Z")).toBeNull();
expect(parseISODate("2024-02-03T10:00:00")).toBeNull();
});
it("parses a date-only string to local midnight", () => {
@@ -28,6 +31,21 @@ describe("toISODate / parseISODate", () => {
});
});
describe("toISODateTime / hasTimeComponent", () => {
it("round-trips a date and time through its ISO representation", () => {
const date = new Date(2024, 1, 3, 13, 5); // Feb 3, 2024 at 1:05pm
const iso = toISODateTime(date);
expect(iso).toBe("2024-02-03T13:05");
expect(parseISODate(iso)).toEqual(date);
});
it("detects whether a value is time-specific", () => {
expect(hasTimeComponent("2024-02-03T13:05")).toBe(true);
expect(hasTimeComponent("2024-02-03")).toBe(false);
expect(hasTimeComponent("nonsense")).toBe(false);
});
});
describe("dateToReadable", () => {
it("includes the year outside the current year", () => {
expect(dateToReadable("2020-02-03")).toBe("February 3rd, 2020");
@@ -44,6 +62,24 @@ describe("dateToReadable", () => {
it("returns the original string when invalid", () => {
expect(dateToReadable("nonsense")).toBe("nonsense");
});
it("appends the time when the value is time-specific", () => {
expect(dateToReadable("2020-02-03T13:00")).toBe(
"February 3rd, 2020 at 1:00 PM"
);
});
it("separates the time using the locale's own connector", () => {
expect(dateToReadable("2020-02-03T13:00", "de_DE")).toContain(" um ");
expect(dateToReadable("2020-02-03T13:00", "ja_JP")).not.toContain(" at ");
});
it("does not leak the date's own suffix into the separator", () => {
// Ukrainian places "р." (an abbreviation of "року") after the year, which
// must not be mistaken for part of the separator.
expect(dateToReadable("2020-02-03T13:00", "uk_UA")).toContain(" о ");
expect(dateToReadable("2020-02-03T13:00", "uk_UA")).not.toContain("р.");
});
});
describe("dateToRelativeReadable", () => {
@@ -77,4 +113,15 @@ describe("dateToRelativeReadable", () => {
it("includes the year for a date in a different year", () => {
expect(dateToRelativeReadable("2020-02-03", t)).toBe("February 3rd, 2020");
});
it("appends the time when the value is time-specific", () => {
const today = new Date();
today.setHours(13, 0);
expect(dateToRelativeReadable(toISODateTime(today), t)).toBe(
"Today at 1:00 PM"
);
expect(dateToRelativeReadable("2020-02-03T13:00", t)).toBe(
"February 3rd, 2020 at 1:00 PM"
);
});
});
+128 -26
View File
@@ -321,29 +321,122 @@ export function toISODate(date: Date): string {
}
/**
* Parses a date-only ISO string (yyyy-MM-dd) into a Date at local midnight.
* Strings carrying a time component are rejected so the date-only contract
* (and the day-granular comparisons that depend on it) cannot be violated.
* Formats a Date into a date and time ISO string (yyyy-MM-dd'T'HH:mm) in the
* local timezone. Used as the stored value for time-specific date mentions.
*
* @param iso The date-only ISO string.
* @returns the parsed Date at local midnight, or null when the string is not a
* valid date-only value.
* @param date The date to format.
* @returns the date and time ISO string.
*/
export function toISODateTime(date: Date): string {
return format(date, "yyyy-MM-dd'T'HH:mm");
}
const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/;
const isoDateTimeRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
/**
* Whether a date mention's stored ISO value carries a time component.
*
* @param iso The stored ISO string.
* @returns true when the value is time-specific.
*/
export function hasTimeComponent(iso: string): boolean {
return isoDateTimeRegex.test(iso);
}
/**
* Parses a date mention's stored ISO string into a Date in the local timezone.
* Accepts both the date-only (yyyy-MM-dd) and time-specific
* (yyyy-MM-dd'T'HH:mm) forms, the former resolving to local midnight. Any other
* shape including values carrying seconds or a timezone offset is rejected
* so the local, minute-granular contract cannot be violated.
*
* @param iso The stored ISO string.
* @returns the parsed Date, or null when the string is not a valid value.
*/
export function parseISODate(iso: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
if (!isoDateRegex.test(iso) && !isoDateTimeRegex.test(iso)) {
return null;
}
const date = parseISO(iso);
return isValid(date) ? date : null;
}
const separators = new Map<string, string>();
/**
* Returns the separator a locale places between the date and time halves of a
* formatted datetime, e.g. " at " in English or " um " in German. It is read
* from the platform's own formatter so the label needs no translation.
*/
function dateTimeSeparator(language?: keyof typeof locales | null): string {
// Without a language the date and time halves fall back to date-fns' English
// locale, so the separator must be English too rather than the platform's.
const tag = language ? language.replace("_", "-") : "en-US";
let separator = separators.get(tag);
if (separator !== undefined) {
return separator;
}
separator = " ";
if (typeof Intl !== "undefined") {
const sample = new Date(2000, 11, 25, 13, 0);
const parts = new Intl.DateTimeFormat(tag, {
dateStyle: "long",
timeStyle: "short",
}).formatToParts(sample);
const timeIndex = parts.findIndex((part) =>
["hour", "minute", "dayPeriod"].includes(part.type)
);
const preceding = parts[timeIndex - 1];
if (preceding?.type === "literal") {
// The literal can begin with a suffix belonging to the date itself (the
// "日" in Japanese, the " р." in Ukrainian). It shows up as the trailing
// literal of the date-only format, so it can be stripped off.
const dateParts = new Intl.DateTimeFormat(tag, {
dateStyle: "long",
}).formatToParts(sample);
const dateSuffix = dateParts[dateParts.length - 1];
let value = preceding.value;
if (
dateSuffix?.type === "literal" &&
value.startsWith(dateSuffix.value)
) {
value = value.slice(dateSuffix.value.length);
}
const whitespace = value.search(/\s/);
separator = whitespace === -1 ? " " : value.slice(whitespace);
}
}
separators.set(tag, separator);
return separator;
}
/**
* Combines the readable date and time halves of a label with the separator
* appropriate for the locale.
*/
function joinDateAndTime(
dateString: string,
timeString: string,
language?: keyof typeof locales | null
): string {
return `${dateString}${dateTimeSeparator(language)}${timeString}`;
}
/**
* Formats a date mention's stored ISO value into an absolute, localized,
* human-readable label. The year is omitted within the current year (e.g.
* "January 2nd") and included otherwise (e.g. "February 3rd, 2024"). Suitable
* for plaintext and markdown serialization.
* "January 2nd") and included otherwise (e.g. "February 3rd, 2024"). The time
* is appended when the value is time-specific (e.g. "January 2nd at 1:00 PM").
* Suitable for plaintext and markdown serialization.
*
* @param iso The date-only ISO string.
* @param iso The stored ISO string.
* @param language The user's language preference.
* @returns the absolute human-readable date, or the original string when invalid.
*/
@@ -356,19 +449,24 @@ export function dateToReadable(
return iso;
}
const locale = dateLocale(language);
if (isSameYear(date, new Date())) {
return format(date, "MMMM do", { locale });
const dateString = isSameYear(date, new Date())
? format(date, "MMMM do", { locale })
: format(date, "MMMM do, yyyy", { locale });
if (!hasTimeComponent(iso)) {
return dateString;
}
return format(date, "MMMM do, yyyy", { locale });
return joinDateAndTime(dateString, format(date, "p", { locale }), language);
}
/**
* Formats a date mention's stored ISO value into a relative, localized,
* human-readable label with increasing granularity. Returns "Today",
* "Tomorrow" or "Yesterday" where applicable, "January 2nd" within the
* current year, and "February 3rd, 2024" otherwise.
* current year, and "February 3rd, 2024" otherwise. The time is appended when
* the value is time-specific (e.g. "Tomorrow at 1:00 PM").
*
* @param iso The date-only ISO string.
* @param iso The stored ISO string.
* @param t The translation function.
* @param language The user's language preference.
* @returns the relative human-readable date, or the original string when invalid.
@@ -383,19 +481,23 @@ export function dateToRelativeReadable(
return iso;
}
const locale = dateLocale(language);
let dateString;
if (isToday(date)) {
return t("Today");
}
if (isTomorrow(date)) {
return t("Tomorrow");
}
if (isYesterday(date)) {
return t("Yesterday");
dateString = t("Today");
} else if (isTomorrow(date)) {
dateString = t("Tomorrow");
} else if (isYesterday(date)) {
dateString = t("Yesterday");
} else if (isSameYear(date, new Date())) {
dateString = format(date, "MMMM do", { locale });
} else {
dateString = format(date, "MMMM do, yyyy", { locale });
}
const locale = dateLocale(language);
if (isSameYear(date, new Date())) {
return format(date, "MMMM do", { locale });
if (!hasTimeComponent(iso)) {
return dateString;
}
return format(date, "MMMM do, yyyy", { locale });
return joinDateAndTime(dateString, format(date, "p", { locale }), language);
}
+29
View File
@@ -48,6 +48,35 @@ describe("parseMentionUrl", () => {
});
});
it("should parse a date modelId", () => {
expect(
parseMentionUrl(
"mention://9a17c1c8-d178-4350-9001-203a73070fcb/date/2024-02-03"
)
).toEqual({
id: "9a17c1c8-d178-4350-9001-203a73070fcb",
mentionType: "date",
modelId: "2024-02-03",
});
});
it("should parse a datetime modelId", () => {
expect(
parseMentionUrl(
"mention://9a17c1c8-d178-4350-9001-203a73070fcb/date/2024-02-03T13:00"
)
).toEqual({
id: "9a17c1c8-d178-4350-9001-203a73070fcb",
mentionType: "date",
modelId: "2024-02-03T13:00",
});
expect(parseMentionUrl("mention://date/2024-02-03T13:00")).toEqual({
mentionType: "date",
modelId: "2024-02-03T13:00",
});
});
it("should return empty object for invalid URL", () => {
expect(parseMentionUrl("https://example.com")).toEqual({});
});
+5 -2
View File
@@ -10,15 +10,18 @@
const parseMentionUrl = (
url: string
): { id?: string; mentionType?: string; modelId?: string } => {
// The modelId is a UUID, a date (2024-02-03) or a datetime (2024-02-03T13:00).
const match3 = url.match(
/^mention:\/\/([a-z0-9-]+)\/([a-z_]+)\/([a-z0-9-]+)$/
/^mention:\/\/([a-z0-9-]+)\/([a-z_]+)\/([a-z0-9-]+(?:T\d{2}:\d{2})?)$/
);
if (match3) {
const [id, mentionType, modelId] = match3.slice(1);
return { id, mentionType, modelId };
}
const match2 = url.match(/^mention:\/\/([a-z_]+)\/([a-z0-9-]+)$/);
const match2 = url.match(
/^mention:\/\/([a-z_]+)\/([a-z0-9-]+(?:T\d{2}:\d{2})?)$/
);
if (match2) {
const [mentionType, modelId] = match2.slice(1);
return { mentionType, modelId };
+61 -8
View File
@@ -14,32 +14,85 @@ describe("parseNaturalLanguageDate", () => {
it("parses 'today'", async () => {
const result = await parseNaturalLanguageDate("today", reference);
expect(result).toEqual(new Date(2024, 0, 1));
expect(result).toEqual({ date: new Date(2024, 0, 1), hasTime: false });
});
it("parses 'tomorrow'", async () => {
const result = await parseNaturalLanguageDate("tomorrow", reference);
expect(result).toEqual(new Date(2024, 0, 2));
expect(result).toEqual({ date: new Date(2024, 0, 2), hasTime: false });
});
it("parses 'yesterday'", async () => {
const result = await parseNaturalLanguageDate("yesterday", reference);
expect(result).toEqual(new Date(2023, 11, 31));
expect(result).toEqual({ date: new Date(2023, 11, 31), hasTime: false });
});
it("parses 'in 3 days'", async () => {
const result = await parseNaturalLanguageDate("in 3 days", reference);
expect(result).toEqual(new Date(2024, 0, 4));
expect(result).toEqual({ date: new Date(2024, 0, 4), hasTime: false });
});
it("parses an explicit month and day", async () => {
const result = await parseNaturalLanguageDate("February 3", reference);
expect(result).toEqual(new Date(2024, 1, 3));
expect(result).toEqual({ date: new Date(2024, 1, 3), hasTime: false });
});
it("normalizes the time component to local midnight", async () => {
it("normalizes the time component to local midnight when none is given", async () => {
const result = await parseNaturalLanguageDate("next friday", reference);
expect(result?.hasTime).toBe(false);
expect(result?.date.getHours()).toBe(0);
expect(result?.date.getMinutes()).toBe(0);
});
it("keeps the time component when one is given", async () => {
const result = await parseNaturalLanguageDate("tomorrow at 5pm", reference);
expect(result?.getHours()).toBe(0);
expect(result?.getMinutes()).toBe(0);
expect(result).toEqual({
date: new Date(2024, 0, 2, 17, 0),
hasTime: true,
});
});
it("parses a time on its own", async () => {
const result = await parseNaturalLanguageDate("1pm", reference);
expect(result).toEqual({
date: new Date(2024, 0, 1, 13, 0),
hasTime: true,
});
});
it("parses a morning time", async () => {
const result = await parseNaturalLanguageDate("9am", reference);
expect(result).toEqual({
date: new Date(2024, 0, 1, 9, 0),
hasTime: true,
});
});
it("parses a morning time with minutes", async () => {
const result = await parseNaturalLanguageDate("8:30am tomorrow", reference);
expect(result).toEqual({
date: new Date(2024, 0, 2, 8, 30),
hasTime: true,
});
});
it("rolls a time that has already passed forward to the next day", async () => {
const afternoon = new Date(2024, 0, 1, 15, 0); // Mon Jan 1, 2024 at 3pm
const result = await parseNaturalLanguageDate("9am", afternoon);
expect(result).toEqual({
date: new Date(2024, 0, 2, 9, 0),
hasTime: true,
});
});
it("parses a time on a date in the past", async () => {
const result = await parseNaturalLanguageDate(
"yesterday at 7am",
reference
);
expect(result).toEqual({
date: new Date(2023, 11, 31, 7, 0),
hasTime: true,
});
});
});
+28 -9
View File
@@ -23,25 +23,31 @@ function loadChrono(): Promise<typeof Chrono> {
return chronoPromise;
}
export interface ParsedNaturalLanguageDate {
/** The matched date, at local midnight unless a time was given. */
date: Date;
/** Whether the input named a specific time of day. */
hasTime: boolean;
}
/**
* Parse a natural language string such as "tomorrow", "next friday",
* "jan 2" or "in 3 days" into a calendar date.
* "jan 2", "in 3 days" or "1pm" into a calendar date.
*
* The time component is intentionally discarded as date mentions are
* day-granular; only the year, month and day of the matched date are
* returned. chrono-node is loaded asynchronously the first time this is
* called.
* The time component is only kept when the input actually named one,
* otherwise the matched date is normalized to local midnight. chrono-node is
* loaded asynchronously the first time this is called.
*
* @param input the natural language string to parse.
* @param referenceDate the date relative to which terms like "tomorrow"
* are resolved, defaults to now.
* @returns a promise resolving to the matched date with the time set to
* local midnight, or null when no date could be confidently parsed.
* @returns a promise resolving to the matched date and whether it is
* time-specific, or null when no date could be confidently parsed.
*/
export async function parseNaturalLanguageDate(
input: string,
referenceDate: Date = new Date()
): Promise<Date | null> {
): Promise<ParsedNaturalLanguageDate | null> {
const trimmed = input.trim();
if (!trimmed) {
return null;
@@ -61,5 +67,18 @@ export async function parseNaturalLanguageDate(
}
const date = result.start.date();
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
const hasTime = result.start.isCertain("hour");
return {
date: hasTime
? new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
date.getHours(),
date.getMinutes()
)
: new Date(date.getFullYear(), date.getMonth(), date.getDate()),
hasTime,
};
}