Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ and this project does not follow Semantic Versioning, here's what we do instead:
- Minor version bumps are released on a regular cadence.
- Patch version bumps are for bugfixes and hotfixes.

## [1.8.2] - 2026-08-08

### Added

- `WORKBENCHWEEK` SmartBlocks command - Resolve natural-language dates to links using the configured weekly note title format.

## [1.8.1] - 2026-06-28

### Fixed
Expand Down
18 changes: 18 additions & 0 deletions docs/weekly-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,24 @@ If the template includes SmartBlocks syntax like `<%DATE:In one week%>`, WorkBen

If SmartBlocks is not enabled, WorkBench will show a warning and copy the template blocks without processing the SmartBlocks commands.

# SmartBlocks command

When Weekly Notes and SmartBlocks are enabled, WorkBench registers a `WORKBENCHWEEK` command that returns a link to the configured weekly note containing a resolved date.

```plain text
<%WORKBENCHWEEK%>
<%WORKBENCHWEEK:This week%>
<%WORKBENCHWEEK:Next week%>
<%WORKBENCHWEEK:In three weeks%>
<%WORKBENCHWEEK:Six weeks ago%>
```

The command uses SmartBlocks' natural-language `DATE` resolver and respects the active `DATEBASIS`. For example:

```plain text
<%DATEBASIS:DNP%><%WORKBENCHWEEK:In one week%>
```

# Auto Tagging

When a new weekly page is created, the weekly page will be tagged in all of the daily pages that are part of the week. The tag will be added as the top block on the page. This could be toggled on and off in the `roam/js/weekly-notes` page.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"scripts": {
"prebuild:roam": "npm install",
"build:roam": "samepage build --dry",
"test": "samepage test",
"start": "samepage dev"
},
"dependencies": {
Expand Down
93 changes: 71 additions & 22 deletions src/features/weekly-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,26 +34,26 @@ import {
UnionField,
} from "roamjs-components/components/ConfigPanels/types";
import WeeklyNoteNav from "./WeeklyNoteNav";
import {
formatWeeklyNoteTitle,
resolveSmartBlocksDate,
WEEKLY_NOTE_DATE_REGEX,
WEEKLY_NOTE_DAYS,
} from "../utils/weeklyNotes";

const ID = "weekly-notes";
const DAYS = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
];
const DATE_REGEX = new RegExp(`{(${DAYS.join("|")}):(.*?)}`, "g");
const DAYS = WEEKLY_NOTE_DAYS;
const DATE_REGEX = WEEKLY_NOTE_DATE_REGEX;
const FORMAT_DEFAULT_VALUE = "{monday:MM/dd yyyy} - {sunday:MM/dd yyyy}";
const CONFIG = `roam/js/${ID}`;
const ROAM_TITLE_CONTAINER_CLASS = "rm-title-display-container";
const WEEKLY_NOTE_NAV_ID = "roamjs-weekly-mode-nav";
const SMARTBLOCK_COMMAND = "WORKBENCHWEEK";
const SMARTBLOCKS_LOADED_EVENT = "roamjs:smartblocks:loaded";

const formatCache = { current: "" };
const getFormat = (tree?: TreeNode[]) =>
formatCache.current ||
const getFormat = (tree?: TreeNode[], refresh = false): string =>
(!refresh && formatCache.current) ||
(formatCache.current = getSettingValueFromTree({
key: "format",
defaultValue: FORMAT_DEFAULT_VALUE,
Expand Down Expand Up @@ -91,6 +91,13 @@ const parse = (...args: Parameters<typeof _parse>) => {
}
};

const getWeeklyPageTitle = (date: Date): string | null =>
formatWeeklyNoteTitle({
date,
format: getFormat(undefined, true),
formatDate: dateFnsFormat,
});

const hasNodeContent = (node: InputTextNode | RoamBasicNode): boolean =>
!!node.text.trim() || !!node.children?.some(hasNodeContent);

Expand Down Expand Up @@ -308,6 +315,42 @@ const navigateToPage = (pageName: string) => {
}, timeout);
};

let registeredSmartBlocks:
| typeof window.roamjs.extension.smartblocks
| undefined;

const unregisterSmartBlocksCommand = (): void => {
registeredSmartBlocks?.unregisterCommand(SMARTBLOCK_COMMAND);
registeredSmartBlocks = undefined;
};

const registerSmartBlocksCommand = (): void => {
const smartblocks = window.roamjs?.extension?.smartblocks;
if (!smartblocks || smartblocks === registeredSmartBlocks) return;

unregisterSmartBlocksCommand();
smartblocks.registerCommand({
text: SMARTBLOCK_COMMAND,
help: "Returns a link to the WorkBench weekly note containing a resolved date.\n\n1: Optional natural-language date expression. Defaults to today.",
handler:
({ proccessBlockText }) =>
async (expression = "today") => {
const date = await resolveSmartBlocksDate({
expression,
processBlockText: proccessBlockText,
pageTitleToDate: (title) =>
window.roamAlphaAPI.util.pageTitleToDate(title) || null,
});
const pageTitle = getWeeklyPageTitle(date);
if (!pageTitle) {
throw new Error("Could not apply the configured weekly note format");
}
return `[[${pageTitle}]]`;
},
});
registeredSmartBlocks = smartblocks;
};

const unloads = new Set<() => void>();
export const toggleFeature = (
flag: boolean,
Expand Down Expand Up @@ -360,17 +403,23 @@ export const toggleFeature = (
},
}).then((a) => unloads.add(() => a.observer?.disconnect()));

const handleSmartBlocksLoaded = () => registerSmartBlocksCommand();
document.body.addEventListener(
SMARTBLOCKS_LOADED_EVENT,
handleSmartBlocksLoaded
);
unloads.add(() =>
document.body.removeEventListener(
SMARTBLOCKS_LOADED_EVENT,
handleSmartBlocksLoaded
)
);
unloads.add(unregisterSmartBlocksCommand);
registerSmartBlocksCommand();

const goToThisWeek = () => {
const format = getFormat();
const today = new Date();
const weekStartsOn = DAYS.indexOf(
format.match(new RegExp(DATE_REGEX.source))?.[1] || "sunday"
) as 0 | 1 | 2 | 3 | 4 | 5 | 6;
const pageName = format.replace(DATE_REGEX, (_, day, f) => {
const dayOfWeek = setDay(today, DAYS.indexOf(day), { weekStartsOn });
return dateFnsFormat(dayOfWeek, f) ?? "";
});
navigateToPage(pageName);
const pageName = getWeeklyPageTitle(new Date());
if (pageName) navigateToPage(pageName);
};
const defaultHotkey = window.roamAlphaAPI.platform.isPC
? "alt-w"
Expand Down
79 changes: 79 additions & 0 deletions src/utils/weeklyNotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import setDay from "date-fns/setDay";

export const WEEKLY_NOTE_DAYS = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
] as const;

const WEEKLY_NOTE_DATE_REGEX_SOURCE = `{(${WEEKLY_NOTE_DAYS.join("|")}):(.*?)}`;

export const WEEKLY_NOTE_DATE_REGEX = new RegExp(
WEEKLY_NOTE_DATE_REGEX_SOURCE,
"g"
);

type FormatWeeklyNoteTitleArgs = {
date: Date;
format: string;
formatDate: (date: Date, format: string) => string | null;
};

export const formatWeeklyNoteTitle = ({
date,
format,
formatDate,
}: FormatWeeklyNoteTitleArgs): string | null => {
const firstPlaceholderDay =
new RegExp(WEEKLY_NOTE_DATE_REGEX_SOURCE).exec(format)?.[1] || "sunday";
const weekStartsOn = WEEKLY_NOTE_DAYS.indexOf(
firstPlaceholderDay as (typeof WEEKLY_NOTE_DAYS)[number]
) as 0 | 1 | 2 | 3 | 4 | 5 | 6;
let isValid = true;

const title = format.replace(
WEEKLY_NOTE_DATE_REGEX,
(_, day: string, dateFormat: string) => {
const dayOfWeek = setDay(
date,
WEEKLY_NOTE_DAYS.indexOf(day as (typeof WEEKLY_NOTE_DAYS)[number]),
{ weekStartsOn }
);
const formatted = formatDate(dayOfWeek, dateFormat);
if (formatted === null) {
isValid = false;
return "";
}
return formatted;
}
);

return isValid ? title : null;
};

type ResolveSmartBlocksDateArgs = {
expression: string;
processBlockText: (text: string) => Promise<{ text: string }[]>;
pageTitleToDate: (title: string) => Date | null;
};

export const resolveSmartBlocksDate = async ({
expression,
processBlockText,
pageTitleToDate,
}: ResolveSmartBlocksDateArgs): Promise<Date> => {
const blocks = await processBlockText(`<%DATE:${expression}%>`);
const output = blocks[0]?.text?.trim() || "";
const pageTitle = /^\[\[(.*)\]\]$/.exec(output)?.[1];
const date = pageTitle ? pageTitleToDate(pageTitle) : null;

if (!date || Number.isNaN(date.valueOf())) {
throw new Error(`Could not resolve "${expression}" to a date`);
}

return date;
};
75 changes: 75 additions & 0 deletions tests/weeklyNotes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { expect, test } from "@playwright/test";
import dateFnsFormat from "date-fns/format";
import {
formatWeeklyNoteTitle,
resolveSmartBlocksDate,
} from "../src/utils/weeklyNotes";

const formatDate = (date: Date, format: string): string =>
dateFnsFormat(date, format, { useAdditionalWeekYearTokens: true });

test("formats the configured Monday-to-Sunday week containing a date", () => {
expect(
formatWeeklyNoteTitle({
date: new Date(2026, 6, 30, 12),
format: "{monday:MM/dd yyyy} - {sunday:MM/dd yyyy}",
formatDate,
})
).toBe("07/27 2026 - 08/02 2026");
});

test("uses the first placeholder as the configured start of the week", () => {
expect(
formatWeeklyNoteTitle({
date: new Date(2026, 7, 7, 12),
format: "Week {wednesday:yyyy-MM-dd} to {tuesday:yyyy-MM-dd}",
formatDate,
})
).toBe("Week 2026-08-05 to 2026-08-11");
});

test("formats weeks across year boundaries", () => {
expect(
formatWeeklyNoteTitle({
date: new Date(2026, 11, 31, 12),
format: "{sunday:MM/dd yyyy} - {saturday:MM/dd yyyy}",
formatDate,
})
).toBe("12/27 2026 - 01/02 2027");
});

test("returns null when a configured placeholder cannot be formatted", () => {
expect(
formatWeeklyNoteTitle({
date: new Date(2026, 6, 30, 12),
format: "{monday:invalid}",
formatDate: () => null,
})
).toBeNull();
});

test("resolves dates through the SmartBlocks DATE command", async () => {
const expected = new Date(2026, 6, 30, 12);
let processed = "";
const result = await resolveSmartBlocksDate({
expression: "In one week",
processBlockText: async (text) => {
processed = text;
return [{ text: "[[July 30th, 2026]]" }];
},
pageTitleToDate: (title) => (title === "July 30th, 2026" ? expected : null),
});

expect(processed).toBe("<%DATE:In one week%>");
expect(result).toBe(expected);
});

test("rejects output that SmartBlocks did not resolve to a date", async () => {
await expect(
resolveSmartBlocksDate({
expression: "not a date",
processBlockText: async () => [{ text: "Could not resolve date" }],
pageTitleToDate: () => null,
})
).rejects.toThrow('Could not resolve "not a date" to a date');
});
Loading