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"