From 31e111e4d83bc4d3c4da00b553cc2c1f010295d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 7 Jun 2026 18:43:22 +0000 Subject: [PATCH] Add date mentions to the editor Introduce a new "date" mention type alongside the existing user, document and collection mentions. Typing @ with a natural language date (e.g. "tomorrow", "next friday", "jan 2") surfaces a date suggestion, parsed via chrono-node. Dates are stored as date-only ISO strings and displayed with increasing granularity (Today / Tomorrow / January 2nd / February 3rd, 2024), recomputed dynamically so relative labels stay fresh. Clicking a date mention opens a Radix popover calendar to change it. --- app/actions/sections.ts | 4 + app/editor/components/MentionMenu.tsx | 40 +++++- package.json | 1 + shared/editor/components/Mentions.tsx | 127 +++++++++++++++++- shared/editor/nodes/Mention.tsx | 39 +++++- shared/editor/rules/mention.test.ts | 15 +++ shared/types.ts | 1 + shared/utils/date.test.ts | 62 +++++++++ shared/utils/date.ts | 86 ++++++++++++ shared/utils/parseNaturalLanguageDate.test.ts | 45 +++++++ shared/utils/parseNaturalLanguageDate.ts | 40 ++++++ yarn.lock | 8 ++ 12 files changed, 463 insertions(+), 5 deletions(-) create mode 100644 shared/utils/date.test.ts create mode 100644 shared/utils/parseNaturalLanguageDate.test.ts create mode 100644 shared/utils/parseNaturalLanguageDate.ts diff --git a/app/actions/sections.ts b/app/actions/sections.ts index fa5190d736..a192f97f46 100644 --- a/app/actions/sections.ts +++ b/app/actions/sections.ts @@ -13,6 +13,10 @@ ActiveCollectionSection.priority = 0.8; export const DeveloperSection = ({ t }: ActionContext) => t("Debug"); +export const DateSection = ({ t }: ActionContext) => t("Date"); + +DateSection.priority = 1; + export const DocumentSection = ({ t }: ActionContext) => t("Document"); export const SearchResultsSection = ({ t }: ActionContext) => diff --git a/app/editor/components/MentionMenu.tsx b/app/editor/components/MentionMenu.tsx index f8026bdf8e..bcb4f31f76 100644 --- a/app/editor/components/MentionMenu.tsx +++ b/app/editor/components/MentionMenu.tsx @@ -2,6 +2,7 @@ import { isEmail } from "class-validator"; import { observer } from "mobx-react"; import { v4 as uuidv4 } from "uuid"; import { + CalendarIcon, DocumentIcon, PlusIcon, NewDocumentIcon, @@ -14,11 +15,18 @@ import { toast } from "sonner"; import Icon from "@shared/components/Icon"; import type { MenuItem } from "@shared/editor/types"; import { MentionType } from "@shared/types"; +import { + dateToReadable, + dateToRelativeReadable, + toISODate, +} from "@shared/utils/date"; import parseDocumentSlug from "@shared/utils/parseDocumentSlug"; +import { parseNaturalLanguageDate } from "@shared/utils/parseNaturalLanguageDate"; import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar"; import DocumentBreadcrumb from "~/components/DocumentBreadcrumb"; import Flex from "~/components/Flex"; import { + DateSection, DocumentsSection, UserSection, CollectionsSection, @@ -26,6 +34,7 @@ import { } from "~/actions/sections"; import useRequest from "~/hooks/useRequest"; import useStores from "~/hooks/useStores"; +import useUserLocale from "~/hooks/useUserLocale"; import { client } from "~/utils/ApiClient"; import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu"; import SuggestionsMenu from "./SuggestionsMenu"; @@ -54,8 +63,34 @@ function MentionMenu({ search, isActive, ...rest }: Props) { const actorId = auth.currentUserId; const location = useLocation(); const documentId = parseDocumentSlug(location.pathname); + const userLocale = useUserLocale(); 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"). + const parsedDate = search ? parseNaturalLanguageDate(search) : null; + const parsedISODate = parsedDate ? toISODate(parsedDate) : undefined; + const dateItems: MentionItem[] = + actorId && parsedISODate + ? [ + { + name: "mention", + icon: , + title: dateToRelativeReadable(parsedISODate, t, userLocale), + subtitle: dateToReadable(parsedISODate, userLocale), + section: DateSection, + appendSpace: true, + attrs: { + id: uuidv4(), + type: MentionType.Date, + modelId: parsedISODate, + actorId, + label: dateToReadable(parsedISODate, userLocale), + }, + } as MentionItem, + ] + : []; + const { loading, request } = useRequest( useCallback(async () => { const res = await client.post("/suggestions.mention", { @@ -87,7 +122,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) { // Computed in the render body so MobX observer can track store access // (e.g. searchSuppressed). Previously this lived inside a useEffect which // runs outside the reactive context and triggered MobX warnings. - const items: MentionItem[] = actorId + const mentionItems: MentionItem[] = actorId ? users .findByQuery(search, { maxResults: maxResultsInSection }) .map( @@ -253,9 +288,12 @@ function MentionMenu({ search, isActive, ...rest }: Props) { ]) : []; + const items: MentionItem[] = [...dateItems, ...mentionItems]; + const handleSelect = useCallback( async (item: MentionItem) => { if ( + item.attrs.type === MentionType.Date || item.attrs.type === MentionType.Document || item.attrs.type === MentionType.Collection ) { diff --git a/package.json b/package.json index 4068371052..ee3c78289e 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "addressparser": "^1.0.1", "async-sema": "^3.1.1", "bull": "^4.16.5", + "chrono-node": "^2.9.1", "class-validator": "^0.15.1", "command-score": "^0.1.2", "compressorjs": "^1.3.0", diff --git a/shared/editor/components/Mentions.tsx b/shared/editor/components/Mentions.tsx index 76daae135c..6117278cd2 100644 --- a/shared/editor/components/Mentions.tsx +++ b/shared/editor/components/Mentions.tsx @@ -1,5 +1,7 @@ +import * as PopoverPrimitive from "@radix-ui/react-popover"; import { observer } from "mobx-react"; import { + CalendarIcon, DocumentIcon, EmailIcon, CollectionIcon, @@ -7,9 +9,18 @@ import { } from "outline-icons"; import type { Node } from "prosemirror-model"; import * as React from "react"; +import { DayPicker } from "react-day-picker"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; -import styled from "styled-components"; +import styled, { useTheme } from "styled-components"; +import { depths, s } from "../../styles"; +import { + dateLocale, + dateToReadable, + dateToRelativeReadable, + parseISODate, + toISODate, +} from "../../utils/date"; import { Backticks } from "../../components/Backticks"; import Flex from "../../components/Flex"; import Icon from "../../components/Icon"; @@ -510,6 +521,93 @@ export const MentionPullRequest = observer((props: IssuePrProps) => { ); }); +type DateProps = ComponentProps & { + onChangeDate: (modelId: string, label: string) => void; +}; + +export const MentionDate = observer(function MentionDate_(props: DateProps) { + const { isSelected, isEditable, node, onChangeDate } = props; + const { t } = useTranslation(); + const { auth } = useStores(); + const theme = useTheme(); + const [open, setOpen] = React.useState(false); + const { className, unfurl, ...attrs } = getAttributesFromNode(node); + + const language = auth.user?.language; + const iso = typeof node.attrs.modelId === "string" ? node.attrs.modelId : ""; + const display = dateToRelativeReadable(iso, t, language); + const selectedDate = parseISODate(iso) ?? undefined; + + const styles = React.useMemo( + () => + ({ + "--rdp-caption-font-size": "16px", + "--rdp-cell-size": "34px", + "--rdp-selected-text": theme.accentText, + "--rdp-accent-color": theme.accent, + "--rdp-accent-color-dark": theme.accent, + "--rdp-background-color": theme.listItemHoverBackground, + "--rdp-background-color-dark": theme.listItemHoverBackground, + }) as React.CSSProperties, + [theme] + ); + + const handleSelect = React.useCallback( + (date: Date) => { + setOpen(false); + const newIso = toISODate(date); + onChangeDate(newIso, dateToReadable(newIso, language)); + }, + [onChangeDate, language] + ); + + const trigger = ( + + + {display} + + ); + + if (!isEditable) { + return trigger; + } + + return ( + + e.stopPropagation()} + > + {trigger} + + + e.preventDefault()} + > + + + + + ); +}); + const MentionLoading = ({ className }: { className: string }) => { const { t } = useTranslation(); @@ -532,6 +630,33 @@ const MentionError = ({ className }: { className: string }) => { ); }; +const DateMention = styled.span<{ $editable: boolean }>` + cursor: ${(props) => (props.$editable ? "pointer" : "default")}; + user-select: none; +`; + +const DatePopoverContent = styled(PopoverPrimitive.Content)` + z-index: ${depths.modal}; + background: ${s("menuBackground")}; + box-shadow: ${s("menuShadow")}; + border-radius: 6px; + outline: none; + padding: 6px 12px; + + &[data-state="open"] { + animation: fadeIn 150ms ease; + } + + @keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } + } +`; + const StyledWarningIcon = styled(WarningIcon)` margin: 0 -2px; `; diff --git a/shared/editor/nodes/Mention.tsx b/shared/editor/nodes/Mention.tsx index 86b6a11720..18a1e9c045 100644 --- a/shared/editor/nodes/Mention.tsx +++ b/shared/editor/nodes/Mention.tsx @@ -21,6 +21,7 @@ import { MentionIssue, MentionProject, MentionPullRequest, + MentionDate, MentionURL, MentionUser, } from "../components/Mentions"; @@ -95,12 +96,21 @@ export default class Mention extends Node { }, ], toDOM: (node) => [ - node.attrs.type === MentionType.User ? "span" : "a", + node.attrs.type === MentionType.User || + node.attrs.type === MentionType.Date + ? "span" + : "a", { - class: `${node.type.name} use-hover-preview`, + // Date mentions are self-contained and have nothing to unfurl, so + // they opt out of the hover preview behaviour. + class: + node.attrs.type === MentionType.Date + ? node.type.name + : `${node.type.name} use-hover-preview`, id: node.attrs.id, href: - node.attrs.type === MentionType.User + node.attrs.type === MentionType.User || + node.attrs.type === MentionType.Date ? undefined : node.attrs.type === MentionType.Document ? `${env.URL}/doc/${node.attrs.modelId}` @@ -162,6 +172,10 @@ export default class Mention extends Node { onChangeUnfurl={this.handleChangeUnfurl(props)} /> ); + case MentionType.Date: + return ( + + ); default: return null; } @@ -341,6 +355,25 @@ export default class Mention extends Node { }; } + handleChangeDate = + ({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) => + (modelId: string, label: string) => { + const { view } = this.editor; + const { tr } = view.state; + const pos = getPos(); + + if (node.attrs.modelId === modelId && node.attrs.label === label) { + return; + } + + const transaction = tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + modelId, + label, + }); + view.dispatch(transaction); + }; + handleChangeUnfurl = ({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) => (unfurl: UnfurlResponse[keyof UnfurlResponse]) => { diff --git a/shared/editor/rules/mention.test.ts b/shared/editor/rules/mention.test.ts index 5806668b68..cd1e162ca0 100644 --- a/shared/editor/rules/mention.test.ts +++ b/shared/editor/rules/mention.test.ts @@ -98,6 +98,21 @@ describe("mention rule", () => { }); }); + describe("date format", () => { + it("should parse a date mention with an ISO date modelId", () => { + const result = md.parse( + "@[February 3rd, 2024](mention://a1b2c3d4-e5f6-7890-abcd-ef1234567890/date/2024-02-03)", + {} + ); + const mentions = findMentionTokens(result); + + expect(mentions).toHaveLength(1); + expect(mentions[0].type).toBe("date"); + expect(mentions[0].modelId).toBe("2024-02-03"); + expect(mentions[0].label).toBe("February 3rd, 2024"); + }); + }); + describe("mixed content", () => { it("should parse mention within text", () => { const result = md.parse( diff --git a/shared/types.ts b/shared/types.ts index 6a7d17a4bd..5df4ddfc90 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -121,6 +121,7 @@ export enum MentionType { PullRequest = "pull_request", Project = "project", URL = "url", + Date = "date", } export type PublicEnv = { diff --git a/shared/utils/date.test.ts b/shared/utils/date.test.ts new file mode 100644 index 0000000000..43b2ec5a68 --- /dev/null +++ b/shared/utils/date.test.ts @@ -0,0 +1,62 @@ +import { + dateToReadable, + dateToRelativeReadable, + parseISODate, + toISODate, +} from "./date"; + +describe("toISODate / parseISODate", () => { + it("round-trips a date through its ISO representation", () => { + const date = new Date(2024, 1, 3); // Feb 3, 2024 + const iso = toISODate(date); + expect(iso).toBe("2024-02-03"); + expect(parseISODate(iso)).toEqual(date); + }); + + it("returns null for an invalid ISO string", () => { + expect(parseISODate("not-a-date")).toBeNull(); + }); +}); + +describe("dateToReadable", () => { + it("formats an absolute, human-readable date", () => { + expect(dateToReadable("2024-02-03")).toBe("February 3rd, 2024"); + }); + + it("returns the original string when invalid", () => { + expect(dateToReadable("nonsense")).toBe("nonsense"); + }); +}); + +describe("dateToRelativeReadable", () => { + const t = (key: string) => key; + + it("returns Today for the current date", () => { + const iso = toISODate(new Date()); + expect(dateToRelativeReadable(iso, t)).toBe("Today"); + }); + + it("returns Tomorrow for the next day", () => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + expect(dateToRelativeReadable(toISODate(tomorrow), t)).toBe("Tomorrow"); + }); + + it("returns Yesterday for the previous day", () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + expect(dateToRelativeReadable(toISODate(yesterday), t)).toBe("Yesterday"); + }); + + it("omits the year within the current year", () => { + const date = new Date(); + date.setMonth(date.getMonth() === 0 ? 6 : 0); // a different month, same year + date.setDate(15); + const result = dateToRelativeReadable(toISODate(date), t); + expect(result).not.toContain(`${date.getFullYear()}`); + }); + + it("includes the year for a date in a different year", () => { + expect(dateToRelativeReadable("2020-02-03", t)).toBe("February 3rd, 2020"); + }); +}); diff --git a/shared/utils/date.ts b/shared/utils/date.ts index b1df1e49d7..5a70e584f7 100644 --- a/shared/utils/date.ts +++ b/shared/utils/date.ts @@ -2,7 +2,13 @@ import type { Locale } from "date-fns"; import { addSeconds, + format, formatDistanceToNow, + isSameYear, + isToday, + isTomorrow, + isYesterday, + parseISO, subDays, subMonths, subWeeks, @@ -297,3 +303,83 @@ export function dateLocale(language: keyof typeof locales | undefined | null) { } export { locales }; + +/** + * Formats a Date into a date-only ISO string (yyyy-MM-dd) in the local + * timezone. Used as the stored value for date mentions. + * + * @param date The date to format. + * @returns the date-only ISO string. + */ +export function toISODate(date: Date): string { + return format(date, "yyyy-MM-dd"); +} + +/** + * Parses a date-only ISO string (yyyy-MM-dd) into a Date at local midnight. + * + * @param iso The date-only ISO string. + * @returns the parsed Date, or null when the string is not a valid date. + */ +export function parseISODate(iso: string): Date | null { + const date = parseISO(iso); + return isValid(date) ? date : null; +} + +/** + * Formats a date mention's stored ISO value into an absolute, localized, + * human-readable label (e.g. "January 2nd, 2024"). Suitable for plaintext + * and markdown serialization where a stable, unambiguous value is required. + * + * @param iso The date-only ISO string. + * @param language The user's language preference. + * @returns the absolute human-readable date, or the original string when invalid. + */ +export function dateToReadable( + iso: string, + language?: keyof typeof locales | null +): string { + const date = parseISODate(iso); + if (!date) { + return iso; + } + return format(date, "MMMM do, yyyy", { locale: dateLocale(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. + * + * @param iso The date-only 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. + */ +export function dateToRelativeReadable( + iso: string, + t: (key: string) => string, + language?: keyof typeof locales | null +): string { + const date = parseISODate(iso); + if (!date) { + return iso; + } + + if (isToday(date)) { + return t("Today"); + } + if (isTomorrow(date)) { + return t("Tomorrow"); + } + if (isYesterday(date)) { + return t("Yesterday"); + } + + const locale = dateLocale(language); + if (isSameYear(date, new Date())) { + return format(date, "MMMM do", { locale }); + } + return format(date, "MMMM do, yyyy", { locale }); +} diff --git a/shared/utils/parseNaturalLanguageDate.test.ts b/shared/utils/parseNaturalLanguageDate.test.ts new file mode 100644 index 0000000000..669a117779 --- /dev/null +++ b/shared/utils/parseNaturalLanguageDate.test.ts @@ -0,0 +1,45 @@ +import { parseNaturalLanguageDate } from "./parseNaturalLanguageDate"; + +describe("parseNaturalLanguageDate", () => { + const reference = new Date(2024, 0, 1); // Mon Jan 1, 2024 + + it("returns null for empty input", () => { + expect(parseNaturalLanguageDate("", reference)).toBeNull(); + expect(parseNaturalLanguageDate(" ", reference)).toBeNull(); + }); + + it("returns null for non-date input", () => { + expect(parseNaturalLanguageDate("hello world", reference)).toBeNull(); + }); + + it("parses 'today'", () => { + const result = parseNaturalLanguageDate("today", reference); + expect(result).toEqual(new Date(2024, 0, 1)); + }); + + it("parses 'tomorrow'", () => { + const result = parseNaturalLanguageDate("tomorrow", reference); + expect(result).toEqual(new Date(2024, 0, 2)); + }); + + it("parses 'yesterday'", () => { + const result = parseNaturalLanguageDate("yesterday", reference); + expect(result).toEqual(new Date(2023, 11, 31)); + }); + + it("parses 'in 3 days'", () => { + const result = parseNaturalLanguageDate("in 3 days", reference); + expect(result).toEqual(new Date(2024, 0, 4)); + }); + + it("parses an explicit month and day", () => { + const result = parseNaturalLanguageDate("February 3", reference); + expect(result).toEqual(new Date(2024, 1, 3)); + }); + + it("normalizes the time component to local midnight", () => { + const result = parseNaturalLanguageDate("tomorrow at 5pm", reference); + expect(result?.getHours()).toBe(0); + expect(result?.getMinutes()).toBe(0); + }); +}); diff --git a/shared/utils/parseNaturalLanguageDate.ts b/shared/utils/parseNaturalLanguageDate.ts new file mode 100644 index 0000000000..16eb58fc21 --- /dev/null +++ b/shared/utils/parseNaturalLanguageDate.ts @@ -0,0 +1,40 @@ +import * as chrono from "chrono-node"; + +/** + * Parse a natural language string such as "tomorrow", "next friday", + * "jan 2" or "in 3 days" 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. + * + * @param input the natural language string to parse. + * @param referenceDate the date relative to which terms like "tomorrow" + * are resolved, defaults to now. + * @returns the matched date with the time set to local midnight, or null + * when no date could be confidently parsed. + */ +export function parseNaturalLanguageDate( + input: string, + referenceDate: Date = new Date() +): Date | null { + const trimmed = input.trim(); + if (!trimmed) { + return null; + } + + const results = chrono.parse(trimmed, referenceDate, { forwardDate: true }); + const result = results[0]; + if (!result) { + return null; + } + + // Only accept matches that span (roughly) the whole input so that + // unrelated text typed after "@" does not accidentally resolve to a date. + if (result.text.trim().length < trimmed.length) { + return null; + } + + const date = result.start.date(); + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} diff --git a/yarn.lock b/yarn.lock index 55e1e8f000..a2c2d8dbd8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8682,6 +8682,13 @@ __metadata: languageName: node linkType: hard +"chrono-node@npm:^2.9.1": + version: 2.9.1 + resolution: "chrono-node@npm:2.9.1" + checksum: 10c0/2408e4a404ea3e7e8226daae75cdbc4621bc66065d596574440d0f81a7c456c166ca212c9e1c329e653c716300289498794fece2102f9365bfd33bd1a4b24473 + languageName: node + linkType: hard + "ci-info@npm:^3.7.0": version: 3.9.0 resolution: "ci-info@npm:3.9.0" @@ -15178,6 +15185,7 @@ __metadata: babel-plugin-transform-typescript-metadata: "npm:^0.4.0" browserslist-to-esbuild: "npm:^1.2.0" bull: "npm:^4.16.5" + chrono-node: "npm:^2.9.1" class-validator: "npm:^0.15.1" command-score: "npm:^0.1.2" compressorjs: "npm:^1.3.0"