feat(bases): add MiniCalendar view and fix DateValue conversion

- Add new MiniCalendarBasesView for visualizing notes by date property
  - Monthly calendar grid with dot indicators on days with notes
  - Configurable date property (file.ctime, note.dateCreated, etc.)
  - Fuzzy selector modal for selecting notes on clicked days
  - UTC anchor compliance for timezone-independent date handling
  - Reuses existing mini-calendar CSS styling

- Fix BasesDataAdapter.convertValueToNative() to properly handle DateValue
  - DateValue objects have {date: Date, time: boolean} structure
  - Now checks for value.date instanceof Date before legacy methods
  - Fixes property-based events in CalendarView as well

- Register MiniCalendar view in bases registration
  - View name: "TaskNotes Mini Calendar"
  - Default property: file.ctime
  - Property filter for date-type properties

Files changed:
- src/bases/MiniCalendarView.ts (new)
- src/bases/BasesDataAdapter.ts (fix DateValue conversion)
- src/bases/registration.ts (register view)
This commit is contained in:
callumalpass 2025-11-04 14:35:22 +11:00
parent 96e01ae58b
commit 15a68ce965
4 changed files with 596 additions and 3 deletions

View file

@ -10,7 +10,7 @@ TaskNotes ships with a lightweight i18n system that keeps interface copy separat
## Adding a New Language
1. Duplicate `src/i18n/resources/en.ts` to a new file with the ISO language code, e.g. `de.ts`.
2. Duplicate `src/i18n/resources/en.ts` to a new file with the ISO language code, e.g. `de.ts`.
2. Translate only the string values. Keep keys identical so TypeScripts type-checking continues to work.
3. Import the new resource in `src/i18n/index.ts` and add it to `translationResources`.
4. (Optional) Add a human-readable label under `common.languages.<code>` so the language shows up nicely in the UI picker.

View file

@ -110,7 +110,13 @@ export class BasesDataAdapter {
return result;
}
// DateValue
// DateValue - check for date property (more reliable than constructor check)
if (value.date instanceof Date) {
// Return the date as ISO string for consistency
return value.date.toISOString();
}
// DateValue - legacy check with toISOString method
if (value.constructor?.name === "DateValue" && value.toISOString) {
return value.toISOString();
}

View file

@ -0,0 +1,565 @@
import { TFile, FuzzySuggestModal, FuzzyMatch, setTooltip } from "obsidian";
import TaskNotesPlugin from "../main";
import { BasesViewBase } from "./BasesViewBase";
import { TaskInfo } from "../types";
import { format } from "date-fns";
import {
formatDateForStorage,
getTodayLocal,
createUTCDateFromLocalCalendarDate,
convertUTCToLocalCalendarDate,
createSafeUTCDate,
parseDateToUTC,
getDatePart,
} from "../utils/dateUtils";
import { isSameDay } from "../utils/helpers";
interface NoteEntry {
file: TFile;
title: string;
path: string;
dateValue: string; // The date string from the property
basesEntry?: any; // Reference to Bases entry for additional data
}
export class MiniCalendarView extends BasesViewBase {
type = "tasknotesMiniCalendar";
private calendarEl: HTMLElement | null = null;
private basesViewContext?: any;
// View options
private dateProperty: string | null = null; // e.g., "note.dueDate", "file.ctime", "note.scheduled"
private displayedMonth: number;
private displayedYear: number;
private selectedDate: Date; // UTC-anchored
// Data
private notesByDate: Map<string, NoteEntry[]> = new Map();
private monthCalculationCache: Map<string, { actualMonth: number; dateObj: Date; dateKey: string }> = new Map();
constructor(controller: any, containerEl: HTMLElement, plugin: TaskNotesPlugin) {
super(controller, containerEl, plugin);
// Initialize with today
const todayLocal = getTodayLocal();
const todayUTC = createUTCDateFromLocalCalendarDate(todayLocal);
this.selectedDate = todayUTC;
this.displayedMonth = todayUTC.getUTCMonth();
this.displayedYear = todayUTC.getUTCFullYear();
}
setBasesViewContext(context: any): void {
this.basesViewContext = context;
(this.dataAdapter as any).basesView = context;
// Read view options
this.readViewOptions();
}
private readViewOptions(): void {
if (!this.basesViewContext?.config) return;
const config = this.basesViewContext.config;
if (typeof config.get !== 'function') return;
try {
this.dateProperty = config.get('dateProperty') || 'file.ctime';
} catch (e) {
console.error("[TaskNotes][MiniCalendarView] Error reading view options:", e);
}
}
async render(): Promise<void> {
if (!this.calendarEl || !this.rootElement) return;
if (!this.basesViewContext?.data?.data) return;
try {
// Clear calendar
this.calendarEl.empty();
// Use raw Bases data (has getValue() method)
const basesEntries = this.basesViewContext.data.data;
// Index notes by date
this.indexNotesByDate(basesEntries);
// Render calendar grid
this.renderCalendarControls();
this.renderCalendarGrid();
} catch (error: any) {
console.error("[TaskNotes][MiniCalendarView] Error rendering:", error);
this.renderError(error);
}
}
private indexNotesByDate(dataItems: any[]): void {
this.notesByDate.clear();
if (!this.dateProperty) {
return;
}
for (const item of dataItems) {
try {
const file = item.file;
if (!file) continue;
// Get date value from the configured property
const dateValue = this.getDateValueFromProperty(item, this.dateProperty);
if (!dateValue) continue;
// Extract date part (handles both date-only and datetime strings)
const dateKey = getDatePart(dateValue);
if (!dateKey) continue;
// Create note entry
const noteEntry: NoteEntry = {
file: file,
title: file.basename || file.name,
path: file.path,
dateValue: dateValue,
basesEntry: item,
};
// Add to map
if (!this.notesByDate.has(dateKey)) {
this.notesByDate.set(dateKey, []);
}
this.notesByDate.get(dateKey)!.push(noteEntry);
} catch (error) {
console.warn("[TaskNotes][MiniCalendarView] Error indexing note:", error);
}
}
}
private getDateValueFromProperty(item: any, propertyId: string): string | null {
try {
// Use BasesDataAdapter to get the property value (handles all Bases Value types)
const value = this.dataAdapter.getPropertyValue(item, propertyId);
if (!value) {
return null;
}
// Now we have a native JavaScript value
let dateString: string | null = null;
// Handle different value types
if (typeof value === 'string') {
// Could be an ISO date string, date-only string, or filename
// First, try to parse as date
const testDate = new Date(value);
if (!isNaN(testDate.getTime())) {
dateString = getDatePart(value);
} else {
// Maybe it's a filename with embedded date
const dateMatch = value.match(/(\d{4}-\d{2}-\d{2})/);
if (dateMatch) {
dateString = dateMatch[1];
}
}
} else if (typeof value === 'number') {
// Unix timestamp
const date = new Date(value);
if (!isNaN(date.getTime())) {
dateString = format(date, "yyyy-MM-dd");
}
} else if (value instanceof Date) {
// Direct Date object
if (!isNaN(value.getTime())) {
dateString = format(value, "yyyy-MM-dd");
}
}
return dateString;
} catch (error) {
console.warn("[TaskNotes][MiniCalendarView] Error getting date value:", error);
return null;
}
}
private renderCalendarControls(): void {
const controlsContainer = this.calendarEl!.createDiv({ cls: "mini-calendar-view__controls" });
const headerContainer = controlsContainer.createDiv({ cls: "mini-calendar-view__header" });
// Navigation section
const navSection = headerContainer.createDiv({ cls: "mini-calendar-view__navigation" });
// Previous month button
const prevButton = navSection.createEl("button", {
text: "",
cls: "mini-calendar-view__nav-button mini-calendar-view__nav-button--prev tn-btn tn-btn--icon tn-btn--ghost",
attr: {
"aria-label": "Previous month",
title: "Previous month",
},
});
prevButton.addEventListener("click", () => this.navigateToPreviousMonth());
// Current month display
navSection.createDiv({
cls: "mini-calendar-view__month-display",
text: format(convertUTCToLocalCalendarDate(this.selectedDate), "MMMM yyyy"),
});
// Next month button
const nextButton = navSection.createEl("button", {
text: "",
cls: "mini-calendar-view__nav-button mini-calendar-view__nav-button--next tn-btn tn-btn--icon tn-btn--ghost",
attr: {
"aria-label": "Next month",
title: "Next month",
},
});
nextButton.addEventListener("click", () => this.navigateToNextMonth());
// Today button
const todayButton = headerContainer.createEl("button", {
text: "Today",
cls: "mini-calendar-view__today-button tn-btn tn-btn--ghost tn-btn--sm",
attr: {
"aria-label": "Go to today",
title: "Go to today",
},
});
todayButton.addEventListener("click", () => this.navigateToToday());
}
private renderCalendarGrid(): void {
const gridContainer = this.calendarEl!.createDiv({ cls: "mini-calendar-view__grid-container" });
// Get current month/year from displayed date
const currentMonth = this.displayedMonth;
const currentYear = this.displayedYear;
// Get first and last day of month
const firstDayOfMonth = new Date(Date.UTC(currentYear, currentMonth, 1));
const lastDayOfMonth = new Date(Date.UTC(currentYear, currentMonth + 1, 0));
const firstDaySetting = this.plugin.settings.calendarViewSettings.firstDay || 0;
const firstDayOfWeek = (firstDayOfMonth.getUTCDay() - firstDaySetting + 7) % 7;
// Create calendar grid
const calendarGrid = gridContainer.createDiv({
cls: "mini-calendar-view__grid",
attr: {
role: "grid",
"aria-label": `Calendar for ${format(convertUTCToLocalCalendarDate(new Date(Date.UTC(currentYear, currentMonth, 1))), "MMMM yyyy")}`,
},
});
// Day names header
const calendarHeader = calendarGrid.createDiv({
cls: "mini-calendar-view__grid-header",
attr: { role: "row" },
});
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const reorderedDayNames = [
...dayNames.slice(firstDaySetting),
...dayNames.slice(0, firstDaySetting),
];
reorderedDayNames.forEach((dayName) => {
calendarHeader.createDiv({
text: dayName,
cls: "mini-calendar-view__day-header",
attr: { role: "columnheader", "aria-label": dayName },
});
});
// Calculate grid layout
const daysFromPrevMonth = firstDayOfWeek;
const totalCells = 42; // 6 rows of 7 days
const daysThisMonth = lastDayOfMonth.getUTCDate();
const daysFromNextMonth = totalCells - daysThisMonth - daysFromPrevMonth;
const lastDayOfPrevMonth = new Date(Date.UTC(currentYear, currentMonth, 0)).getUTCDate();
// Today for comparison
const todayLocal = getTodayLocal();
const today = createUTCDateFromLocalCalendarDate(todayLocal);
// Render days
let currentWeekRow = calendarGrid.createDiv({
cls: "mini-calendar-view__week",
attr: { role: "row" },
});
// Previous month days
for (let i = 0; i < daysFromPrevMonth; i++) {
const dayNum = lastDayOfPrevMonth - daysFromPrevMonth + i + 1;
const dayDate = new Date(Date.UTC(currentYear, currentMonth - 1, dayNum));
this.renderDay(currentWeekRow, dayDate, dayNum, true);
}
// Current month days
for (let i = 1; i <= daysThisMonth; i++) {
if ((i + daysFromPrevMonth - 1) % 7 === 0 && i > 1) {
currentWeekRow = calendarGrid.createDiv({
cls: "mini-calendar-view__week",
attr: { role: "row" },
});
}
const dayDate = new Date(Date.UTC(currentYear, currentMonth, i));
this.renderDay(currentWeekRow, dayDate, i, false);
}
// Next month days
for (let i = 1; i <= daysFromNextMonth; i++) {
if ((i + daysFromPrevMonth + daysThisMonth - 1) % 7 === 0 && i > 1) {
currentWeekRow = calendarGrid.createDiv({
cls: "mini-calendar-view__week",
attr: { role: "row" },
});
}
const dayDate = new Date(Date.UTC(currentYear, currentMonth + 1, i));
this.renderDay(currentWeekRow, dayDate, i, true);
}
}
private renderDay(weekRow: HTMLElement, dayDate: Date, dayNum: number, isOutsideMonth: boolean): void {
const todayLocal = getTodayLocal();
const today = createUTCDateFromLocalCalendarDate(todayLocal);
const isToday = isSameDay(dayDate, today);
const isSelected = isSameDay(dayDate, this.selectedDate);
let classNames = "mini-calendar-view__day";
if (isToday) classNames += " mini-calendar-view__day--today";
if (isSelected) classNames += " mini-calendar-view__day--selected";
if (isOutsideMonth) classNames += " mini-calendar-view__day--outside-month";
const dayEl = weekRow.createDiv({
cls: classNames,
text: dayNum.toString(),
attr: {
role: "gridcell",
tabindex: isSelected ? "0" : "-1",
"aria-label": format(convertUTCToLocalCalendarDate(dayDate), "EEEE, MMMM d, yyyy") + (isToday ? " (Today)" : ""),
"aria-selected": isSelected ? "true" : "false",
"aria-current": isToday ? "date" : null,
},
});
// Add dot indicator if notes exist for this date
const dateKey = formatDateForStorage(dayDate);
const notesForDay = this.notesByDate.get(dateKey);
if (notesForDay && notesForDay.length > 0) {
const indicator = dayEl.createDiv({ cls: "note-indicator" });
// Style based on count
if (notesForDay.length >= 5) {
indicator.addClass("many-notes");
dayEl.addClass("mini-calendar-view__day--has-notes-many");
} else if (notesForDay.length >= 2) {
indicator.addClass("some-notes");
dayEl.addClass("mini-calendar-view__day--has-notes-some");
} else {
indicator.addClass("few-notes");
dayEl.addClass("mini-calendar-view__day--has-notes-few");
}
setTooltip(indicator, `${notesForDay.length} note${notesForDay.length > 1 ? "s" : ""}`, {
placement: "top",
});
}
// Click handler - select date or show fuzzy selector
dayEl.addEventListener("click", () => {
this.handleDayClick(dayDate);
});
// Keyboard handler
dayEl.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
this.handleDayClick(dayDate);
}
});
}
private handleDayClick(date: Date): void {
// Update selected date
this.selectedDate = date;
// Check if date has notes
const dateKey = formatDateForStorage(date);
const notesForDay = this.notesByDate.get(dateKey);
if (notesForDay && notesForDay.length > 0) {
// Show fuzzy selector with notes
const modal = new NoteSelectionModal(
this.plugin.app,
this.plugin,
notesForDay,
(selectedNote) => {
if (selectedNote) {
// Open the selected note
this.plugin.app.workspace.getLeaf(false).openFile(selectedNote.file);
}
}
);
modal.open();
} else {
// Just update selection visually
this.refresh();
}
}
private navigateToPreviousMonth(): void {
const newDate = new Date(this.selectedDate.getTime());
newDate.setUTCMonth(this.selectedDate.getUTCMonth() - 1);
this.selectedDate = newDate;
this.displayedMonth = newDate.getUTCMonth();
this.displayedYear = newDate.getUTCFullYear();
this.monthCalculationCache.clear();
this.refresh();
}
private navigateToNextMonth(): void {
const newDate = new Date(this.selectedDate.getTime());
newDate.setUTCMonth(this.selectedDate.getUTCMonth() + 1);
this.selectedDate = newDate;
this.displayedMonth = newDate.getUTCMonth();
this.displayedYear = newDate.getUTCFullYear();
this.monthCalculationCache.clear();
this.refresh();
}
private navigateToToday(): void {
const todayLocal = getTodayLocal();
const todayUTC = createUTCDateFromLocalCalendarDate(todayLocal);
this.selectedDate = todayUTC;
this.displayedMonth = todayUTC.getUTCMonth();
this.displayedYear = todayUTC.getUTCFullYear();
this.monthCalculationCache.clear();
this.refresh();
}
protected setupContainer(): void {
super.setupContainer();
const calendar = document.createElement("div");
calendar.className = "mini-calendar-bases-view";
this.rootElement?.appendChild(calendar);
this.calendarEl = calendar;
}
protected async handleTaskUpdate(task: TaskInfo): Promise<void> {
// For mini calendar, refresh on any data change
this.debouncedRefresh();
}
private renderError(error: Error): void {
if (!this.calendarEl) return;
const errorEl = document.createElement("div");
errorEl.className = "tn-bases-error";
errorEl.style.cssText =
"padding: 20px; color: #d73a49; background: #ffeaea; border-radius: 4px; margin: 10px 0;";
errorEl.textContent = `Error loading mini calendar: ${error.message || "Unknown error"}`;
this.calendarEl.appendChild(errorEl);
}
protected cleanup(): void {
super.cleanup();
this.calendarEl = null;
this.notesByDate.clear();
this.monthCalculationCache.clear();
}
}
// Fuzzy selector modal for notes
class NoteSelectionModal extends FuzzySuggestModal<NoteEntry> {
private notes: NoteEntry[];
private onChooseNote: (note: NoteEntry | null) => void;
private plugin: TaskNotesPlugin;
constructor(
app: any,
plugin: TaskNotesPlugin,
notes: NoteEntry[],
onChooseNote: (note: NoteEntry | null) => void
) {
super(app);
this.plugin = plugin;
this.notes = notes;
this.onChooseNote = onChooseNote;
this.setPlaceholder("Select a note to open");
this.setInstructions([
{ command: "↑↓", purpose: "Navigate" },
{ command: "↵", purpose: "Open note" },
{ command: "esc", purpose: "Dismiss" },
]);
}
getItems(): NoteEntry[] {
// Sort by title
return this.notes.sort((a, b) => a.title.localeCompare(b.title));
}
getItemText(note: NoteEntry): string {
return note.title;
}
renderSuggestion(item: FuzzyMatch<NoteEntry>, el: HTMLElement): void {
const note = item.item;
const container = el.createDiv({ cls: "note-selector-modal__suggestion" });
// Title
container.createDiv({
cls: "note-selector-modal__title",
text: note.title,
});
// Path (if not same as title)
if (note.path !== note.title) {
container.createDiv({
cls: "note-selector-modal__path",
text: note.path,
});
}
}
onChooseItem(item: NoteEntry, evt: MouseEvent | KeyboardEvent): void {
this.onChooseNote(item);
}
}
// Factory function
export function buildMiniCalendarViewFactory(plugin: TaskNotesPlugin) {
return function (basesContainer: any, containerEl?: HTMLElement) {
const viewContainerEl = containerEl || basesContainer.viewContainerEl;
const controller = basesContainer;
if (!viewContainerEl) {
console.error("[TaskNotes][MiniCalendarView] No viewContainerEl found");
return { destroy: () => {} } as any;
}
const view = new MiniCalendarView(controller, viewContainerEl, plugin);
return {
load: () => view.load(),
unload: () => view.unload(),
refresh() { view.render(); },
onDataUpdated: function(this: any) {
view.setBasesViewContext(this);
view.onDataUpdated();
},
onResize: () => {
// Handle resize if needed
},
getEphemeralState: () => view.getEphemeralState(),
setEphemeralState: (state: any) => view.setEphemeralState(state),
focus: () => view.focus(),
destroy() {
view.unload();
},
};
};
}

View file

@ -4,6 +4,7 @@ import { requireApiVersion } from "obsidian";
import { buildTaskListViewFactory } from "./TaskListView";
import { buildKanbanViewFactory } from "./KanbanView";
import { buildCalendarViewFactory } from "./CalendarView";
import { buildMiniCalendarViewFactory } from "./MiniCalendarView";
import { registerBasesView, unregisterBasesView } from "./api";
/**
@ -385,8 +386,28 @@ export async function registerBasesTaskList(plugin: TaskNotesPlugin): Promise<vo
},
});
// Register Mini Calendar view using public API
const miniCalendarSuccess = registerBasesView(plugin, "tasknotesMiniCalendar", {
name: "TaskNotes Mini Calendar",
icon: "calendar-days",
factory: buildMiniCalendarViewFactory(plugin),
options: () => [
{
type: "property",
key: "dateProperty",
displayName: "Date Property",
placeholder: "Select property to show on calendar",
default: "file.ctime",
filter: (prop: string) => {
// Show date-type properties from all sources
return prop.startsWith("note.") || prop.startsWith("file.") || prop.startsWith("task.");
},
},
],
});
// Consider it successful if any view registered successfully
if (!taskListSuccess && !kanbanSuccess && !calendarSuccess) {
if (!taskListSuccess && !kanbanSuccess && !calendarSuccess && !miniCalendarSuccess) {
console.debug("[TaskNotes][Bases] Bases plugin not available for registration");
return false;
}
@ -440,6 +461,7 @@ export function unregisterBasesViews(plugin: TaskNotesPlugin): void {
unregisterBasesView(plugin, "tasknotesTaskList");
unregisterBasesView(plugin, "tasknotesKanban");
unregisterBasesView(plugin, "tasknotesCalendar");
unregisterBasesView(plugin, "tasknotesMiniCalendar");
} catch (error) {
console.error("[TaskNotes][Bases] Error during view unregistration:", error);
}