) : null}
diff --git a/src/components/property-value.tsx b/src/components/property-value.tsx
index e9e27aca..dbf36e15 100644
--- a/src/components/property-value.tsx
+++ b/src/components/property-value.tsx
@@ -2,6 +2,7 @@ import { Link } from "@tanstack/react-router"
import { isToday } from "date-fns"
import { useCallback, useRef, useState } from "react"
import { z } from "zod"
+import { useBuildCalendarNoteId } from "../hooks/config"
import {
formatDate,
formatDateDistance,
@@ -23,6 +24,7 @@ type PropertyValueProps = {
}
export function PropertyValue({ property: [key, value], onChange }: PropertyValueProps) {
+ const buildCalendarNoteId = useBuildCalendarNoteId()
// Special keys
switch (key) {
case "isbn":
@@ -131,7 +133,7 @@ export function PropertyValue({ property: [key, value], onChange }: PropertyValu
const dateString = toDateStringUtc(value)
return (
-
+
)
}
diff --git a/src/components/week-link.tsx b/src/components/week-link.tsx
index dca3b017..39853d24 100644
--- a/src/components/week-link.tsx
+++ b/src/components/week-link.tsx
@@ -2,28 +2,33 @@ import { Link } from "@tanstack/react-router"
import { useMemo } from "react"
import { useBacklinksForId, useNoteById } from "../hooks/note"
import { Note } from "../schema"
+import { getCalendarNoteBasename } from "../utils/config"
import { cx } from "../utils/cx"
import { formatWeek } from "../utils/date"
import { NoteHoverCard } from "./note-hover-card"
export type WeekLinkProps = {
- week: string
+ /** Full note ID (e.g., "2025-W04" or "journal/2025-W04") */
+ noteId: string
text?: string
className?: string
}
-export function WeekLink({ week, text, className }: WeekLinkProps) {
- const existingNote = useNoteById(week)
- const backlinks = useBacklinksForId(week)
+export function WeekLink({ noteId, text, className }: WeekLinkProps) {
+ const existingNote = useNoteById(noteId)
+ const backlinks = useBacklinksForId(noteId)
+
+ // Extract the week part for formatting
+ const weekBasename = getCalendarNoteBasename(noteId)
// Create a minimal note object if no note exists
const note: Note = useMemo(() => {
if (existingNote) return existingNote
return {
- id: week,
+ id: noteId,
content: "",
type: "weekly",
- displayName: formatWeek(week),
+ displayName: formatWeek(weekBasename),
frontmatter: {},
title: "",
url: null,
@@ -36,13 +41,13 @@ export function WeekLink({ week, text, className }: WeekLinkProps) {
tasks: [],
backlinks,
}
- }, [existingNote, week, backlinks])
+ }, [existingNote, noteId, weekBasename, backlinks])
const link = (
- {text || formatWeek(week)}
+ {text || formatWeek(weekBasename)}
)
}
diff --git a/src/global-state.ts b/src/global-state.ts
index 05bf6c53..0c5f3bcf 100644
--- a/src/global-state.ts
+++ b/src/global-state.ts
@@ -15,6 +15,14 @@ import {
githubUserSchema,
templateSchema,
} from "./schema"
+import {
+ Config,
+ CONFIG_FILE_PATH,
+ DEFAULT_CONFIG,
+ normalizeDirectoryPath,
+ parseConfigFromJson,
+ serializeConfig,
+} from "./utils/config"
import { fs, fsWipe } from "./utils/fs"
import {
REPO_DIR,
@@ -38,6 +46,7 @@ import { startTimer } from "./utils/timer"
const GITHUB_USER_STORAGE_KEY = "github_user" as const
const MARKDOWN_FILES_STORAGE_KEY = "markdown_files" as const
+const CONFIG_STORAGE_KEY = "lumen_config" as const
type Context = {
githubUser: GitHubUser | null
@@ -461,6 +470,8 @@ function createGlobalStateMachine() {
githubUser: (_, event) => {
switch (event.type) {
case "SIGN_IN":
+ // Save to localStorage when signing in directly (e.g., with PAT)
+ localStorage.setItem(GITHUB_USER_STORAGE_KEY, JSON.stringify(event.githubUser))
return event.githubUser
case "done.invoke.global.resolvingUser:invocation[0]":
return event.data.githubUser
@@ -626,6 +637,67 @@ export const isSignedOutAtom = selectAtom(globalStateMachineAtom, (state) =>
state.matches("signedOut"),
)
+// -----------------------------------------------------------------------------
+// Config
+// -----------------------------------------------------------------------------
+
+/** Get cached config from localStorage */
+function getConfigFromLocalStorage(): Config {
+ try {
+ const stored = localStorage.getItem(CONFIG_STORAGE_KEY)
+ if (stored) {
+ return parseConfigFromJson(stored)
+ }
+ } catch {
+ // Ignore errors
+ }
+ return DEFAULT_CONFIG
+}
+
+/** Save config to localStorage */
+function setConfigToLocalStorage(config: Config) {
+ localStorage.setItem(CONFIG_STORAGE_KEY, serializeConfig(config))
+}
+
+/** Primitive atom to hold the config state */
+const configPrimitiveAtom = atom(getConfigFromLocalStorage())
+
+/** Read-only atom for consuming the config */
+export const configAtom = atom((get) => get(configPrimitiveAtom))
+
+/** Writable atom for updating the config */
+export const setConfigAtom = atom(null, (get, set, config: Config) => {
+ set(configPrimitiveAtom, config)
+ setConfigToLocalStorage(config)
+})
+
+/** Helper atom for the calendar notes directory (normalized) */
+export const calendarNotesDirAtom = atom((get) => {
+ const config = get(configAtom)
+ return normalizeDirectoryPath(config.calendarNotesDir)
+})
+
+/** Function to read config from filesystem and update the atom */
+export async function loadConfigFromFs(): Promise {
+ try {
+ const configPath = `${REPO_DIR}/${CONFIG_FILE_PATH}`
+ const content = await fs.promises.readFile(configPath, "utf8")
+ // fs.promises.readFile can return string or Uint8Array
+ const contentStr = typeof content === "string" ? content : new TextDecoder().decode(content)
+ return parseConfigFromJson(contentStr)
+ } catch {
+ // Config file doesn't exist, return default
+ return DEFAULT_CONFIG
+ }
+}
+
+/** Atom to trigger config loading from filesystem */
+export const loadConfigAtom = atom(null, async (get, set) => {
+ const config = await loadConfigFromFs()
+ set(configPrimitiveAtom, config)
+ setConfigToLocalStorage(config)
+})
+
// -----------------------------------------------------------------------------
// GitHub
// -----------------------------------------------------------------------------
@@ -646,13 +718,16 @@ export const githubRepoAtom = selectAtom(
export const notesAtom = atom((get) => {
const markdownFiles = get(markdownFilesAtom)
+ const config = get(configAtom)
+ const calendarNotesDir = normalizeDirectoryPath(config.calendarNotesDir)
const notes: Map = new Map()
// Parse notes
for (const filepath in markdownFiles) {
+ // Note ID is just the filepath without .md extension
const id = filepath.replace(/\.md$/, "")
const content = markdownFiles[filepath]
- notes.set(id, parseNote(id, content))
+ notes.set(id, parseNote(id, content, calendarNotesDir))
}
// Derive backlinks
diff --git a/src/hooks/config.ts b/src/hooks/config.ts
new file mode 100644
index 00000000..de9e9a96
--- /dev/null
+++ b/src/hooks/config.ts
@@ -0,0 +1,89 @@
+import { useAtomValue, useSetAtom } from "jotai"
+import React from "react"
+import {
+ calendarNotesDirAtom,
+ configAtom,
+ globalStateMachineAtom,
+ isRepoClonedAtom,
+ loadConfigAtom,
+ setConfigAtom,
+} from "../global-state"
+import {
+ Config,
+ CONFIG_FILE_PATH,
+ normalizeDirectoryPath,
+ serializeConfig,
+ buildCalendarNoteId,
+ isCalendarNoteId,
+} from "../utils/config"
+
+export function useConfig() {
+ return useAtomValue(configAtom)
+}
+
+export function useCalendarNotesDirectory() {
+ return useAtomValue(calendarNotesDirAtom)
+}
+
+/**
+ * Returns a function to build calendar note IDs with the configured directory.
+ * e.g., with directory "journal": buildId("2026-01-26") -> "journal/2026-01-26"
+ */
+export function useBuildCalendarNoteId() {
+ const calendarNotesDir = useAtomValue(calendarNotesDirAtom)
+ return React.useCallback(
+ (dateOrWeek: string) => buildCalendarNoteId(dateOrWeek, calendarNotesDir),
+ [calendarNotesDir],
+ )
+}
+
+/**
+ * Returns a function to check if a note ID is a calendar note (with directory config).
+ */
+export function useIsCalendarNoteId() {
+ const calendarNotesDir = useAtomValue(calendarNotesDirAtom)
+ return React.useCallback(
+ (noteId: string) => isCalendarNoteId(noteId, calendarNotesDir),
+ [calendarNotesDir],
+ )
+}
+
+/** Load config from filesystem when repo is cloned */
+export function useLoadConfigOnMount() {
+ const isRepoCloned = useAtomValue(isRepoClonedAtom)
+ const loadConfig = useSetAtom(loadConfigAtom)
+ const hasLoadedRef = React.useRef(false)
+
+ React.useEffect(() => {
+ if (isRepoCloned && !hasLoadedRef.current) {
+ hasLoadedRef.current = true
+ loadConfig()
+ }
+ }, [isRepoCloned, loadConfig])
+}
+
+export function useSaveConfig() {
+ const send = useSetAtom(globalStateMachineAtom)
+ const setConfig = useSetAtom(setConfigAtom)
+
+ return React.useCallback(
+ (config: Config) => {
+ // Normalize the config before saving
+ const normalizedConfig: Config = {
+ ...config,
+ calendarNotesDir: normalizeDirectoryPath(config.calendarNotesDir) || undefined,
+ }
+
+ // Update the config atom (and localStorage)
+ setConfig(normalizedConfig)
+
+ // Write the config file to the repo
+ send({
+ type: "WRITE_FILES",
+ markdownFiles: { [CONFIG_FILE_PATH]: serializeConfig(normalizedConfig) },
+ commitMessage: "Update Lumen config",
+ })
+ },
+ [send, setConfig],
+ )
+}
diff --git a/src/hooks/note.ts b/src/hooks/note.ts
index c9a28d7e..b5a2da8c 100644
--- a/src/hooks/note.ts
+++ b/src/hooks/note.ts
@@ -3,6 +3,7 @@ import { selectAtom, useAtomCallback } from "jotai/utils"
import React from "react"
import {
backlinksIndexAtom,
+ calendarNotesDirAtom,
githubRepoAtom,
githubUserAtom,
globalStateMachineAtom,
@@ -12,9 +13,9 @@ import {
import { Note, NoteId } from "../schema"
import { parseFrontmatter, updateFrontmatterValue } from "../utils/frontmatter"
import { deleteGist, updateGist } from "../utils/gist"
+import { isValidNoteId } from "../utils/note-id"
import { parseNote } from "../utils/parse-note"
import { updateWikilinks } from "../utils/update-wikilinks"
-import { isValidNoteId } from "../utils/note-id"
const EMPTY_BACKLINKS: NoteId[] = []
@@ -54,6 +55,7 @@ export function useSaveNote() {
const send = useSetAtom(globalStateMachineAtom)
const githubUser = useAtomValue(githubUserAtom)
const githubRepo = useAtomValue(githubRepoAtom)
+ const calendarNotesDir = useAtomValue(calendarNotesDirAtom)
const saveNote = React.useCallback(
async ({ id, content }: Pick) => {
@@ -63,9 +65,12 @@ export function useSaveNote() {
properties: { updated_at: new Date() },
})
+ // Filepath is just the note ID with .md extension
+ const filepath = `${id}.md`
+
send({
type: "WRITE_FILES",
- markdownFiles: { [`${id}.md`]: contentWithTimestamp },
+ markdownFiles: { [filepath]: contentWithTimestamp },
})
// If the note has a gist ID, update the gist
@@ -73,13 +78,13 @@ export function useSaveNote() {
if (typeof frontmatter.gist_id === "string" && githubUser && githubRepo) {
await updateGist({
gistId: frontmatter.gist_id,
- note: parseNote(id ?? "", contentWithTimestamp),
+ note: parseNote(id ?? "", contentWithTimestamp, calendarNotesDir),
githubUser,
githubRepo,
})
}
},
- [send, githubUser, githubRepo],
+ [send, githubUser, githubRepo, calendarNotesDir],
)
return saveNote
@@ -120,10 +125,14 @@ export function useRenameNote() {
const updatedMarkdownFiles: Record = {}
// Update wikilinks in all other notes
- for (const [filepath, content] of Object.entries(markdownFiles)) {
+ for (const [filepath, fileContent] of Object.entries(markdownFiles)) {
if (filepath === oldFilepath) continue
- const newContent = updateWikilinks({ fileContent: content, oldId: oldName, newId: newName })
- if (newContent !== content) {
+ const newContent = updateWikilinks({
+ fileContent,
+ oldId: oldName,
+ newId: newName,
+ })
+ if (newContent !== fileContent) {
updatedMarkdownFiles[filepath] = newContent
}
}
@@ -173,7 +182,8 @@ export function useDeleteNote() {
})
}
- send({ type: "DELETE_FILE", filepath: `${id}.md` })
+ const filepath = `${id}.md`
+ send({ type: "DELETE_FILE", filepath })
},
[send, githubUser, getNoteById],
)
diff --git a/src/routes/_appRoot.notes_.$.tsx b/src/routes/_appRoot.notes_.$.tsx
index 2e7963d6..6f286608 100644
--- a/src/routes/_appRoot.notes_.$.tsx
+++ b/src/routes/_appRoot.notes_.$.tsx
@@ -58,12 +58,14 @@ import {
weeklyTemplateAtom,
} from "../global-state"
import { useAttachFile } from "../hooks/attach-file"
+import { useCalendarNotesDirectory } from "../hooks/config"
import { useDeleteNote, useNoteById, useRenameNote, useSaveNote } from "../hooks/note"
import { useSearchNotes } from "../hooks/search-notes"
import { useValueRef } from "../hooks/value-ref"
import { Note, NoteId, Template, Width, fontSchema, widthSchema } from "../schema"
+import { getCalendarNoteBasename, isCalendarNoteId } from "../utils/config"
import { cx } from "../utils/cx"
-import { formatDate, formatWeek, isValidDateString, isValidWeekString } from "../utils/date"
+import { formatDate, formatWeek } from "../utils/date"
import { updateFrontmatterValue } from "../utils/frontmatter"
import { clearNoteDraft, getNoteDraft, setNoteDraft } from "../utils/note-draft"
import { getInvalidNoteIdCharacters } from "../utils/note-id"
@@ -133,12 +135,14 @@ function NotePage() {
const dailyTemplate = useAtomValue(dailyTemplateAtom)
const weeklyTemplate = useAtomValue(weeklyTemplateAtom)
const defaultFont = useAtomValue(defaultFontAtom)
+ const calendarNotesDir = useCalendarNotesDirectory()
const { online } = useNetworkState()
// Note data
const note = useNoteById(noteId)
- const isDailyNote = isValidDateString(noteId ?? "")
- const isWeeklyNote = isValidWeekString(noteId ?? "")
+ const noteBasename = getCalendarNoteBasename(noteId ?? "")
+ // Check if this note ID is a calendar note based on basename AND directory config
+ const isCalendarNote = isCalendarNoteId(noteId ?? "", calendarNotesDir)
const searchNotes = useSearchNotes()
const saveNote = useSaveNote()
const backlinks = React.useMemo(() => {
@@ -153,16 +157,16 @@ function NotePage() {
note,
defaultValue: defaultContent
? defaultContent
- : isDailyNote && dailyTemplate
- ? renderTemplate(dailyTemplate, { date: noteId ?? "" })
- : isWeeklyNote && weeklyTemplate
- ? renderTemplate(weeklyTemplate, { week: noteId ?? "" })
+ : isCalendarNote && noteBasename.includes("-W") && weeklyTemplate
+ ? renderTemplate(weeklyTemplate, { week: noteBasename })
+ : isCalendarNote && !noteBasename.includes("-W") && dailyTemplate
+ ? renderTemplate(dailyTemplate, { date: noteBasename })
: "",
})
const vimMode = useAtomValue(vimModeAtom)
const parsedNote = React.useMemo(
- () => parseNote(noteId ?? "", editorValue),
- [noteId, editorValue],
+ () => parseNote(noteId ?? "", editorValue, calendarNotesDir),
+ [noteId, editorValue, calendarNotesDir],
)
const [isDraggingFile, setIsDraggingFile] = React.useState(false)
@@ -796,7 +800,7 @@ function NotePage() {
resolvedWidth === "fixed" && "mx-auto max-w-[700px]",
)}
>
- {isDailyNote || isWeeklyNote ? (
+ {parsedNote?.type === "daily" || parsedNote?.type === "weekly" ? (
@@ -819,11 +823,12 @@ function NotePage() {
{
// When printing a daily or weekly note without a title,
// insert the date or week number as the title
- (isDailyNote || isWeeklyNote) && !note?.title ? (
+ (parsedNote?.type === "daily" || parsedNote?.type === "weekly") &&
+ !note?.title ? (