date navigator

This commit is contained in:
stfrigerio 2025-04-05 16:50:18 +02:00
parent cba7bda90a
commit 9c1020b8d3
19 changed files with 713 additions and 236 deletions

19
main.ts
View file

@ -9,11 +9,12 @@ import {
import { DBService } from "./src/DBService";
import { SQLiteDBSettingTab } from "./src/settingTab";
import { inspectTableStructure, convertEntriesInNotes } from "./src/commands";
import { processSqlBlock, processSqlChartBlock, renderDatePicker } from "./src/codeblocks";
import { processSqlBlock, processSqlChartBlock, DateNavigatorRenderer } from "./src/codeblocks";
import { pickTableName } from "./src/helpers";
import { SQLiteDBSettings, DEFAULT_SETTINGS } from "./src/types";
import { injectDatePickerStyles } from "src/styles/datePickerInject";
import { injectDateNavigatorStyles, removeDateNavigatorStyles } from './src/styles/dateNavigationInject';
import { registerHabitCounter } from "./src/webcomponents/HabitCounter/registerHabitCounter";
@ -28,6 +29,7 @@ export default class SQLiteDBPlugin extends Plugin {
await this.openDatabase();
injectDatePickerStyles();
injectDateNavigatorStyles();
this.registerMarkdownPostProcessor((el, ctx) => {
registerHabitCounter(el, this.dbService);
@ -73,18 +75,19 @@ export default class SQLiteDBPlugin extends Plugin {
}
);
this.registerMarkdownCodeBlockProcessor(
"date-picker",
async (source: string, el: HTMLElement) => {
renderDatePicker(el, this.app);
}
);
this.registerMarkdownCodeBlockProcessor(
"date-header",
(source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
ctx.addChild(new DateNavigatorRenderer(el, this.app));
}
);
this.addSettingTab(new SQLiteDBSettingTab(this.app, this));
}
onunload() {
console.log("Unloading SQLiteDBPlugin...");
removeDateNavigatorStyles();
}
private async openDatabase(forceReload = true) {

View file

@ -33,24 +33,19 @@ export class DBService {
// Only reload if forced or not already loaded
if (!this.db || forceReload) {
try {
console.log("DBService: Initializing SQL.js and loading DB...");
this.SQL = await initSqlJs({
locateFile: (file) => `${basePath}/${this.app.vault.configDir}/plugins/sqlite-db/${file}`,
});
console.log(`DBService: Reading DB file from: ${settings.dbFilePath}`);
const fileBuffer = readFileSync(settings.dbFilePath);
this.db = new this.SQL.Database(fileBuffer);
console.log("DBService: Database loaded into memory.");
// Check if we have at least one table
const result = this.db.exec("SELECT name FROM sqlite_master WHERE type='table' LIMIT 1;");
if (result?.[0]?.values?.[0]?.[0]) {
new Notice(`DB Loaded.`);
console.log("DBService: DB Loaded successfully.");
} else {
new Notice("DB Loaded, but found no tables.");
console.log("DBService: DB Loaded, but found no tables.");
}
return true; // Indicate success
} catch (err) {
@ -61,8 +56,7 @@ export class DBService {
return false; // Indicate failure
}
} else {
console.log("DBService: Database already loaded.");
return true; // Already loaded
return true; // Already loaded
}
}

View file

@ -0,0 +1,58 @@
import { App } from "obsidian";
import { PluginState } from "src/pluginState";
import { DateNavigatorOptions, DateNavigatorDOMElements, NavigationPeriod } from "./dateNavigator.types";
import { createPrevHandler, createNextHandler, createOpenModalHandler } from "./eventHandlers/dateNavigationHandlers";
import { buildDateNavigatorDOM, updateDateNavigatorDisplay } from "./dom/buildDateNavigatorDOM";
/**
* Creates and manages the interactive Date Navigator header.
* Uses pluginState to get/set the current date.
* Updates its display when the date changes.
*/
export class DateNavigator {
private app: App;
private pluginState: PluginState;
private containerEl: HTMLElement;
private period: NavigationPeriod;
private elements: DateNavigatorDOMElements | null = null;
constructor(options: DateNavigatorOptions) {
this.app = options.app;
this.pluginState = options.pluginState;
this.containerEl = options.containerEl;
this.period = options.period ?? 'day'; // Default to day navigation
if (!this.app || !this.pluginState || !this.containerEl) {
throw new Error("DateNavigator missing required options (app, pluginState, containerEl).");
}
this._buildUI();
}
/** Builds the initial UI */
private _buildUI(): void {
//~ Create bound handlers first
const handlers = {
handlePrev: createPrevHandler(this.pluginState, this.period, this.app, this._updateDisplay),
handleNext: createNextHandler(this.pluginState, this.period, this.app, this._updateDisplay),
handleOpenModal: createOpenModalHandler(this.pluginState, this.app, this._updateDisplay),
};
//~ Build DOM and store element references
this.elements = buildDateNavigatorDOM(this.containerEl, this.pluginState.selectedDate, handlers);
}
/** Updates the displayed date. Bound method for callbacks. */
private _updateDisplay = (newIsoDate: string): void => {
if (this.elements?.dateDisplay) {
updateDateNavigatorDisplay(this.elements.dateDisplay, newIsoDate);
} else {
console.error("[DateNavigator] Cannot update display, dateDisplay element reference is missing.");
}
};
/** Cleans up the component (e.g., remove listeners, clear container) */
public destroy(): void {
this.containerEl.empty();
this.elements = null;
}
}

View file

@ -0,0 +1,25 @@
// src/components/dateNavigator/dateNavigator.types.ts
import { App } from "obsidian";
import { pluginState } from "src/pluginState";
//? Defines the period types the navigator can handle (currently only 'day')
export type NavigationPeriod = 'day'; //todo: Add 'week', 'month', 'year' later
//? Configuration options for the DateNavigator
export interface DateNavigatorOptions {
app: App;
pluginState: typeof pluginState;
containerEl: HTMLElement; // Where to render the navigator
period?: NavigationPeriod; // Defaults to 'day'
dateFormat?: string; // todo: Allow custom date formats later
}
//? References to the DOM elements created by the navigator builder
export interface DateNavigatorDOMElements {
wrapper: HTMLElement;
navRow: HTMLElement;
prevButton: HTMLButtonElement;
nextButton: HTMLButtonElement;
dateDisplay: HTMLElement; // The H1 or span showing the date
openModalButton: HTMLButtonElement;
}

View file

@ -0,0 +1,53 @@
// src/components/dateNavigator/dom/buildDateNavigatorDOM.ts
import { formatDateForDisplay, parseDateISO } from "../../datePicker/datePickerUtils";
import type { DateNavigatorDOMElements } from "../dateNavigator.types"; // Type only
//? Structure for the handlers needed by the builder
export interface DateNavigatorHandlers {
handlePrev: () => void;
handleNext: () => void;
handleOpenModal: () => void;
}
/** //? Builds the DOM for the date navigator header. */
export function buildDateNavigatorDOM(
containerEl: HTMLElement,
initialIsoDate: string,
handlers: DateNavigatorHandlers
): DateNavigatorDOMElements {
containerEl.empty(); // Clear container first
const wrapper = containerEl.createDiv({ cls: "date-navigator-wrapper" });
const navRow = wrapper.createDiv({ cls: "date-nav-row" });
// --- Navigation Buttons ---
const prevButton = navRow.createEl("button", { text: "←", cls: "clickable-icon date-nav-button date-nav-prev" });
prevButton.setAttribute("aria-label", "Previous Day"); //todo: Make label dynamic based on period
prevButton.addEventListener("click", handlers.handlePrev);
// --- Date Display (H1) ---
const dateDisplay = navRow.createEl("h1", { cls: "date-navigator-display" });
const initialDateObj = parseDateISO(initialIsoDate);
dateDisplay.textContent = initialDateObj ? formatDateForDisplay(initialDateObj) : "Invalid Date";
const nextButton = navRow.createEl("button", { text: "→", cls: "clickable-icon date-nav-button date-nav-next" });
nextButton.setAttribute("aria-label", "Next Day"); //todo: Make label dynamic
nextButton.addEventListener("click", handlers.handleNext);
// --- Open Modal Button ---
//? Placed below the H1 for structure
const openModalButton = wrapper.createEl("button", { text: "Select Date", cls: "date-nav-open-modal" });
openModalButton.addEventListener("click", handlers.handleOpenModal);
return { wrapper, navRow, prevButton, nextButton, dateDisplay, openModalButton };
}
/** Updates the displayed date text in the navigator. */
export function updateDateNavigatorDisplay(dateDisplayElement: HTMLElement, newIsoDate: string): void {
const dateObj = parseDateISO(newIsoDate);
const displayString = dateObj ? formatDateForDisplay(dateObj) : "Invalid Date";
if (dateDisplayElement) {
dateDisplayElement.textContent = displayString;
} else {
console.error("[DateNavDOM] Cannot update display, element not found.");
}
}

View file

@ -0,0 +1,80 @@
// src/components/dateNavigator/eventHandlers/dateNavigationHandlers.ts
import { App } from "obsidian";
import { DatePickerModal } from "../../datePicker/DatePickerModal";
import { PluginState } from "src/pluginState";
import { parseDateISO, formatDateISO } from "../../datePicker/datePickerUtils";
import { NavigationPeriod } from "../dateNavigator.types";
/** Updates the global plugin state with the new date and triggers necessary updates */
function updateGlobalDate(newDate: Date, pluginState: PluginState, app: App): void {
const newIsoDate = formatDateISO(newDate);
if (pluginState.selectedDate !== newIsoDate) {
pluginState.selectedDate = newIsoDate;
// Optional: Force re-render if needed, maybe make this configurable
// app.workspace.getActiveViewOfType(MarkdownView)?.previewMode?.rerender(true);
}
//todo: Could add dispatchEvent here for more reactive updates elsewhere
}
/** //? Creates the handler for the "previous" button click */
export function createPrevHandler(
pluginState: PluginState,
period: NavigationPeriod,
app: App,
//? Callback to update the display text after changing the date
updateDisplayCallback: (newIsoDate: string) => void
): () => void {
return () => {
const currentDate = parseDateISO(pluginState.selectedDate) ?? new Date(); // Default to today on parse error
switch (period) {
case 'day':
currentDate.setUTCDate(currentDate.getUTCDate() - 1);
break;
//todo: Implement 'week', 'month', etc.
default:
console.warn(`[DateNavHandlers] Unsupported period for prev navigation: ${period}`);
return;
}
updateGlobalDate(currentDate, pluginState, app);
updateDisplayCallback(formatDateISO(currentDate)); // Update local display
};
}
/** Creates the handler for the "next" button click */
export function createNextHandler(
pluginState: PluginState,
period: NavigationPeriod,
app: App,
updateDisplayCallback: (newIsoDate: string) => void
): () => void {
return () => {
const currentDate = parseDateISO(pluginState.selectedDate) ?? new Date();
//& console.log(`[DateNavHandlers] Next clicked. Current: ${formatDateISO(currentDate)}, Period: ${period}`);
switch (period) {
case 'day':
currentDate.setUTCDate(currentDate.getUTCDate() + 1);
break;
//todo: Implement 'week', 'month', etc.
default:
console.warn(`[DateNavHandlers] Unsupported period for next navigation: ${period}`);
return;
}
updateGlobalDate(currentDate, pluginState, app);
updateDisplayCallback(formatDateISO(currentDate));
};
}
/** Creates the handler for the "open modal" button click */
export function createOpenModalHandler(
pluginState: PluginState,
app: App,
updateDisplayCallback: (newIsoDate: string) => void
): () => void {
return () => {
new DatePickerModal(app, pluginState.selectedDate, (newIsoDate) => {
//? This is the callback executed when the modal confirms a selection
//? No need to call updateGlobalDate here, modal already did via pluginState setter
updateDisplayCallback(newIsoDate);
}).open();
};
}

View file

@ -0,0 +1,101 @@
import { Modal, App, Notice } from "obsidian";
import { buildDatePickerHeader } from "./dom/buildDatePickerHeader";
import { buildWeekdayHeaders } from "./dom/buildWeekdayHeaders";
import { buildCalendarGrid } from "./dom/buildCalendarGrid";
import { buildDatePickerFooter } from "./dom/buildDatePickerFooter";
import { formatDateISO, parseDateISO } from "./datePickerUtils";
export class DatePickerModal extends Modal {
private selectedDateObj: Date; //? Internal state as Date object
private currentDisplayMonth: Date; //? Month being displayed in the calendar
private today: Date; //? Today's date (normalized)
//? Callback to notify the parent component of the selection
private onSelectCallback: (newIsoDate: string) => void;
constructor(app: App, currentIsoDate: string, onSelect: (newIsoDate: string) => void) {
super(app);
this.onSelectCallback = onSelect;
const initialDate = parseDateISO(currentIsoDate);
//? Ensure selectedDateObj is always a valid Date object, default to today if parse fails
this.selectedDateObj = initialDate && !isNaN(initialDate.getTime()) ? initialDate : new Date();
//? Set time to 00:00:00 UTC to compare dates easily
this.selectedDateObj.setUTCHours(0, 0, 0, 0);
this.today = new Date();
this.today.setUTCHours(0, 0, 0, 0);
//? Start display month from the selected date's month
this.currentDisplayMonth = new Date(Date.UTC(this.selectedDateObj.getUTCFullYear(), this.selectedDateObj.getUTCMonth(), 1));
}
onOpen() {
this.contentEl.addClass("custom-datepicker-modal-content");
this.render();
}
private render() {
const { contentEl } = this;
contentEl.empty(); // Clear previous content
// --- Build Header ---
const headerEl = buildDatePickerHeader(
this.currentDisplayMonth,
this._handlePrevMonth, // Pass handler references
this._handleNextMonth
);
contentEl.appendChild(headerEl);
// --- Build Weekday Headers ---
const weekdaysEl = buildWeekdayHeaders(true); // Include week number header
contentEl.appendChild(weekdaysEl);
// --- Build Calendar Grid ---
const gridEl = buildCalendarGrid(
this.currentDisplayMonth,
this.selectedDateObj,
this.today,
this._handleDateSelect // Pass date selection handler
);
contentEl.appendChild(gridEl);
// --- Build Footer ---
const footerEl = buildDatePickerFooter(
this.selectedDateObj,
this._confirmSelection // Pass confirmation handler
);
contentEl.appendChild(footerEl);
}
// --- Handlers passed to builders ---
private _handlePrevMonth = (): void => {
this.currentDisplayMonth.setUTCMonth(this.currentDisplayMonth.getUTCMonth() - 1);
this.render(); // Re-render the whole modal
};
private _handleNextMonth = (): void => {
this.currentDisplayMonth.setUTCMonth(this.currentDisplayMonth.getUTCMonth() + 1);
this.render(); // Re-render the whole modal
};
private _handleDateSelect = (selected: Date): void => {
this.selectedDateObj = selected; // Update internal state
//? Re-render to update selection highlight and footer display
//? Alternatively, could update only specific elements for performance
this.render();
};
private _confirmSelection = (): void => {
const isoDate = formatDateISO(this.selectedDateObj);
this.onSelectCallback(isoDate); // Notify parent component
new Notice(`Date set to ${isoDate}`);
this.close();
};
onClose() {
this.contentEl.empty();
}
}

View file

@ -0,0 +1,47 @@
/** Calculates the ISO 8601 week number for a given date. */
export function getISOWeekNumber(date: Date): number {
const temp = new Date(date.getTime());
temp.setHours(0, 0, 0, 0);
// Thursday in current week decides the year.
temp.setDate(temp.getDate() + 3 - ((temp.getDay() + 6) % 7));
const week1 = new Date(temp.getFullYear(), 0, 4);
return (
1 +
Math.round(
((temp.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7 // Adjusted calculation slightly
)
);
}
/** Formats a Date object into YYYY-MM-DD string */
export function formatDateISO(date: Date): string {
if (!date || isNaN(date.getTime())) {
return ""; //? Handle invalid date
}
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
/** Parses a YYYY-MM-DD string into a Date object (at UTC midnight) */
export function parseDateISO(dateString: string): Date | null {
if (!dateString || !/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
return null;
}
//? Parse as UTC to avoid timezone issues affecting the date part
const parts = dateString.split('-').map(Number);
const date = new Date(Date.UTC(parts[0], parts[1] - 1, parts[2]));
if (isNaN(date.getTime())) {
return null;
}
return date;
}
/** Formats a date for display (e.g., "January 15, 2024") */
export function formatDateForDisplay(date: Date): string {
if (!date || isNaN(date.getTime())) return "Invalid Date";
return date.toLocaleDateString(undefined, { // Use user's locale
year: 'numeric',
month: 'long',
day: 'numeric',
// timeZone: 'UTC'
});
}

View file

@ -0,0 +1,94 @@
import { getISOWeekNumber } from "../datePickerUtils";
/**
* Builds the main grid of days for the calendar month.
* @param currentDisplayMonth - The month/year being displayed (Date object, 1st of month UTC).
* @param selectedDateObj - The currently selected date (Date object, normalized to UTC midnight).
* @param today - Today's date (Date object, normalized to UTC midnight).
* @param onDateSelect - Callback function executed when a day button is clicked, passing the selected Date object.
* @returns The HTMLElement representing the calendar grid.
*/
export function buildCalendarGrid(
currentDisplayMonth: Date,
selectedDateObj: Date,
today: Date,
onDateSelect: (selected: Date) => void
): HTMLElement {
const grid = document.createElement("div");
grid.className = "calendar-grid"; // Grid container
const year = currentDisplayMonth.getUTCFullYear();
const month = currentDisplayMonth.getUTCMonth();
//? Day of the week for the 1st of the month (0=Sun, 6=Sat). Use UTC methods.
const firstDayWeekday = new Date(Date.UTC(year, month, 1)).getUTCDay();
//? Calculate offset for Monday start (0=Mon, 6=Sun)
const startOffset = (firstDayWeekday + 6) % 7;
//? Last day of the *current* month. Use UTC month+1, day 0.
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
let dayCounter = 1 - startOffset; // Start day counter including offset for leading empty cells
let currentWeekNumber = -1; // Track week number to avoid duplicates per row
while (dayCounter <= daysInMonth) {
// --- Add Week Number Cell (at the start of each visual row) ---
const dateForWeekNum = new Date(Date.UTC(year, month, dayCounter > 0 ? dayCounter : 1));
const weekNumber = getISOWeekNumber(dateForWeekNum);
//? Add week number cell at the start of grid row (every 8th element: 1 week# + 7 days)
if (grid.children.length % 8 === 0) {
const weekEl = document.createElement("div");
weekEl.className = "week-number";
weekEl.textContent = `${String(weekNumber).padStart(2, "0")}`;
grid.appendChild(weekEl);
currentWeekNumber = weekNumber;
}
// --- Add 7 Day Cells for the current week ---
for (let i = 0; i < 7; i++) {
const cell = document.createElement("div");
cell.className = "calendar-day-cell"; // Container for potential button
if (dayCounter < 1 || dayCounter > daysInMonth) {
//? Empty cell (before start or after end of month)
cell.classList.add("empty");
} else {
//? Valid day within the month
const currentDate = new Date(Date.UTC(year, month, dayCounter));
//? No need to normalize time, UTC dates are already at midnight
const dayButton = document.createElement("button");
dayButton.textContent = String(dayCounter);
dayButton.className = "calendar-day";
dayButton.setAttribute("aria-label", currentDate.toLocaleDateString(undefined, { // Use local format for aria-label
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC'
}));
//? Check if this is the currently selected date
if (currentDate.getTime() === selectedDateObj.getTime()) {
dayButton.classList.add("selected");
dayButton.setAttribute("aria-selected", "true");
} else {
dayButton.setAttribute("aria-selected", "false");
}
//? Check if this is today's date
if (currentDate.getTime() === today.getTime()) {
dayButton.classList.add("today");
}
//? Attach click handler
dayButton.onclick = () => {
onDateSelect(currentDate); // Pass the Date object back to the modal's handler
};
cell.appendChild(dayButton);
}
grid.appendChild(cell);
dayCounter++;
}
}
return grid;
}

View file

@ -0,0 +1,26 @@
// src/components/datePicker/dom/buildDatePickerFooter.ts
import { formatDateForDisplay } from "../datePickerUtils"; // Use consistent display formatting
/**
* Builds the footer section of the date picker modal (Selected Date Display, Confirm Button).
* @param selectedDateObj - The currently selected date (Date object).
* @param onConfirm - Callback function to execute when the 'Select' button is clicked.
* @returns The HTMLElement representing the footer.
*/
export function buildDatePickerFooter(
selectedDateObj: Date,
onConfirm: () => void
): HTMLElement {
const footer = document.createElement("div");
footer.className = "calendar-footer";
// --- Display Currently Selected Date ---
const selectedDateDisplay = footer.createEl("span", { cls: "selected-date-display" });
selectedDateDisplay.textContent = `Selected: ${formatDateForDisplay(selectedDateObj)}`;
// --- Confirm Button ---
const confirmBtn = footer.createEl("button", { text: "Select", cls: "mod-cta" }); // Use Obsidian's primary button style
confirmBtn.onclick = onConfirm; // Assign the provided handler
return footer;
}

View file

@ -0,0 +1,38 @@
/**
* Builds the header section of the date picker modal (Prev, Title, Next).
* @param currentDisplayMonth - The Date object representing the month/year being displayed.
* @param onPrevMonth - Callback function to execute when the 'Previous' button is clicked.
* @param onNextMonth - Callback function to execute when the 'Next' button is clicked.
* @returns The HTMLElement representing the header.
*/
export function buildDatePickerHeader(
currentDisplayMonth: Date,
onPrevMonth: () => void,
onNextMonth: () => void
): HTMLElement {
const header = document.createElement("div");
header.className = "calendar-header";
// --- Previous Month Button ---
const prevBtn = header.createEl("button", { text: "←", cls: "clickable-icon" });
prevBtn.setAttribute("aria-label", "Previous month");
prevBtn.onclick = onPrevMonth; // Assign the provided handler
// --- Month/Year Title ---
const title = header.createEl("span", {
cls: "calendar-title",
text: currentDisplayMonth.toLocaleString("default", { // Use user's default locale
month: "long",
year: "numeric",
timeZone: 'UTC' // Ensure month/year is based on UTC date
}),
});
// --- Next Month Button ---
const nextBtn = header.createEl("button", { text: "→", cls: "clickable-icon" });
nextBtn.setAttribute("aria-label", "Next month");
nextBtn.onclick = onNextMonth; // Assign the provided handler
return header;
}

View file

@ -0,0 +1,20 @@
/**
* Builds the row displaying weekday abbreviations (Mo, Tu, etc.).
* @param includeWeekNumber - If true, adds a "W#" header cell at the beginning.
* @returns The HTMLElement representing the weekday header row.
*/
export function buildWeekdayHeaders(includeWeekNumber: boolean = false): HTMLElement {
const weekdaysContainer = document.createElement("div");
weekdaysContainer.className = "calendar-weekdays";
//? Header for Week# column (optional)
if (includeWeekNumber) {
weekdaysContainer.createDiv({ cls: "week-number-header", text: "W#" });
}
//? Standard Monday-first weekdays
const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
weekdays.forEach(day => weekdaysContainer.createDiv({ cls: "calendar-weekday", text: day }));
return weekdaysContainer;
}

View file

@ -0,0 +1,43 @@
import { MarkdownRenderChild, App } from 'obsidian';
import { DateNavigator } from './components/dateNavigator/DateNavigator';
import { pluginState } from '../../pluginState';
/**
* An Obsidian Render Child responsible for rendering and managing the lifecycle
* of a DateNavigator instance within a Markdown code block.
*/
export class DateNavigatorRenderer extends MarkdownRenderChild {
private navigator: DateNavigator | null = null;
constructor(
containerEl: HTMLElement,
private appInstance: App
) {
super(containerEl);
}
/** Called by Obsidian when the component should be loaded/rendered. */
onload() {
this.containerEl.empty();
this.containerEl.addClass('date-navigator-container-wrapper');
try {
this.navigator = new DateNavigator({
app: this.appInstance,
pluginState: pluginState,
containerEl: this.containerEl
});
} catch (error) {
console.error("[DateNavigatorRenderer] Failed to create DateNavigator:", error);
this.containerEl.setText('Error loading date navigator component.');
}
}
/** Called by Obsidian when the component should be unloaded/cleaned up. */
onunload() {
this.navigator?.destroy();
this.navigator = null;
}
}

View file

@ -1,197 +0,0 @@
import { Modal, App, Notice, MarkdownView } from "obsidian";
import { pluginState } from "../../pluginState"; // Assuming this exists and works
function getISOWeekNumber(date: Date): number {
const temp = new Date(date.getTime());
temp.setHours(0, 0, 0, 0);
// Thursday in current week decides the year.
temp.setDate(temp.getDate() + 3 - ((temp.getDay() + 6) % 7));
const week1 = new Date(temp.getFullYear(), 0, 4);
return (
1 +
Math.round(
((temp.getTime() - week1.getTime()) / 86400000 - ((week1.getDay() + 6) % 7)) /
7
)
);
}
export class DatePickerModal extends Modal {
private selectedDate: Date;
private currentMonth: Date;
private today: Date = new Date();
constructor(app: App) {
super(app);
// Ensure selectedDate is always a Date object
const initialDate = pluginState.selectedDate ? new Date(pluginState.selectedDate) : new Date();
// Validate if the parsed date is valid, otherwise default to today
this.selectedDate = isNaN(initialDate.getTime()) ? new Date() : initialDate;
// Set time to 00:00:00 to compare dates easily
this.selectedDate.setHours(0, 0, 0, 0);
this.today.setHours(0, 0, 0, 0);
this.currentMonth = new Date(this.selectedDate.getFullYear(), this.selectedDate.getMonth(), 1);
}
onOpen() {
// Add a class to the modal content for easier styling
this.contentEl.addClass("custom-datepicker-modal-content");
this.render();
}
private render() {
const { contentEl } = this;
contentEl.empty(); // Clear previous content
// --- Header ---
const header = contentEl.createEl("div", { cls: "calendar-header" });
const prevBtn = header.createEl("button", { text: "←", cls: "clickable-icon" });
prevBtn.setAttribute("aria-label", "Previous month");
prevBtn.onclick = () => {
this.currentMonth.setMonth(this.currentMonth.getMonth() - 1);
this.render();
};
const title = header.createEl("span", {
cls: "calendar-title",
text: this.currentMonth.toLocaleString("default", { month: "long", year: "numeric" }),
});
const nextBtn = header.createEl("button", { text: "→", cls: "clickable-icon" });
nextBtn.setAttribute("aria-label", "Next month");
nextBtn.onclick = () => {
this.currentMonth.setMonth(this.currentMonth.getMonth() + 1);
this.render();
};
// --- Weekday Headers ---
const weekdaysContainer = contentEl.createDiv({ cls: "calendar-weekdays" });
const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
weekdaysContainer.createDiv({ cls: "week-number-header", text: "W#" }); // Header for Week#
weekdays.forEach(day => weekdaysContainer.createDiv({ cls: "calendar-weekday", text: day }));
// --- Calendar Grid ---
// buildCalendar now returns the grid itself
const calendarGrid = this.buildCalendar();
contentEl.appendChild(calendarGrid);
// --- Footer ---
const footer = contentEl.createDiv({ cls: "calendar-footer" });
// Display currently selected date
const selectedDateDisplay = footer.createEl("span", { cls: "selected-date-display" });
this.updateSelectedDateDisplay(selectedDateDisplay); // Initial display
const confirmBtn = footer.createEl("button", { text: "Select", cls: "mod-cta" }); // Use Obsidian's primary button style
confirmBtn.onclick = () => {
const iso = `${this.selectedDate.getFullYear()}-${String(this.selectedDate.getMonth() + 1).padStart(2, '0')}-${String(this.selectedDate.getDate()).padStart(2, '0')}`;
pluginState.selectedDate = iso; // Update global state
new Notice(`Date set to ${iso}`);
// Optional: Force re-render if your plugin uses the date reactively in preview
this.app.workspace.getActiveViewOfType(MarkdownView)?.previewMode?.rerender(true);
this.close();
};
}
private buildCalendar(): HTMLElement {
const grid = document.createElement("div");
grid.className = "calendar-grid"; // This will be the actual grid container
const year = this.currentMonth.getFullYear();
const month = this.currentMonth.getMonth();
const firstDayOfMonth = new Date(year, month, 1);
const daysInMonth = new Date(year, month + 1, 0).getDate();
// Calculate offset for Monday start (0=Mon, 6=Sun)
const startOffset = (firstDayOfMonth.getDay() + 6) % 7;
// --- Add Week Numbers and Empty Cells before the 1st ---
let day = 1 - startOffset; // Start day counter considering offset
let currentWeekNumber = -1;
while (day <= daysInMonth) {
// Calculate and add week number cell for the current row
const dateForWeekNum = new Date(year, month, day > 0 ? day : 1); // Use day 1 if current day is placeholder
const weekNumber = getISOWeekNumber(dateForWeekNum);
// Only add week number if it changed (start of a new row essentially)
if (weekNumber !== currentWeekNumber) {
const weekEl = document.createElement("div");
weekEl.className = "week-number";
weekEl.textContent = `${String(weekNumber).padStart(2, "0")}`;
grid.appendChild(weekEl);
currentWeekNumber = weekNumber;
} else if (grid.children.length % 8 === 0) {
// If week number didn't change but we are at start of grid row, add it
// This handles month starts that continue week number from previous month correctly
const weekEl = document.createElement("div");
weekEl.className = "week-number";
weekEl.textContent = `${String(weekNumber).padStart(2, "0")}`;
grid.appendChild(weekEl);
}
// Add the 7 day cells for the week
for (let i = 0; i < 7; i++) {
const cell = document.createElement("div"); // Use a div as container for button
cell.className = "calendar-day-cell";
if (day < 1 || day > daysInMonth) {
// Empty cell (before start or after end of month)
cell.classList.add("empty");
} else {
const currentDate = new Date(year, month, day);
currentDate.setHours(0, 0, 0, 0); // Normalize time for comparison
const btn = document.createElement("button");
btn.textContent = String(day);
btn.className = "calendar-day";
btn.setAttribute("aria-label", currentDate.toDateString());
// Check if this is the currently selected date
if (currentDate.getTime() === this.selectedDate.getTime()) {
btn.classList.add("selected");
btn.setAttribute("aria-selected", "true");
} else {
btn.setAttribute("aria-selected", "false");
}
// Check if this is today's date
if (currentDate.getTime() === this.today.getTime()) {
btn.classList.add("today");
}
btn.onclick = () => {
// Update selected date state
this.selectedDate = currentDate;
const previouslySelected = grid.querySelector(".calendar-day.selected");
if (previouslySelected) {
previouslySelected.classList.remove("selected");
previouslySelected.setAttribute("aria-selected", "false");
}
btn.classList.add("selected");
btn.setAttribute("aria-selected", "true");
this.updateSelectedDateDisplay();
};
cell.appendChild(btn);
}
grid.appendChild(cell);
day++;
}
}
return grid;
}
private updateSelectedDateDisplay(element?: HTMLElement) {
const displayEl = element ?? this.contentEl.querySelector(".selected-date-display");
if (displayEl) {
displayEl.textContent = `Selected: ${this.selectedDate.toLocaleDateString()}`;
}
}
onClose() {
this.contentEl.empty();
}
}

View file

@ -1,11 +0,0 @@
import { App } from "obsidian";
import { DatePickerModal } from "./DatePickerModal";
export function renderDatePicker(container: HTMLElement, app: App) {
const wrapper = container.createDiv({ cls: "datepicker" });
const button = wrapper.createEl("button", { text: "Select Date" });
button.onclick = () => {
new DatePickerModal(app).open();
};
}

View file

@ -1,5 +1,5 @@
import { processSqlBlock } from "./processSqlBlock";
import { processSqlChartBlock } from "./processSqlChartBlock";
import { renderDatePicker } from "./datePicker";
import { DateNavigatorRenderer } from "./dateHeader";
export { processSqlBlock, processSqlChartBlock, renderDatePicker };
export { processSqlBlock, processSqlChartBlock, DateNavigatorRenderer };

View file

@ -1,17 +1,26 @@
export const DATE_CHANGED_EVENT_NAME = 'plugin-date-changed';
export class PluginState {
private _selectedDate: string;
private _selectedDate: string;
constructor() {
this._selectedDate = new Date().toISOString().split("T")[0];
}
constructor() {
this._selectedDate = new Date().toISOString().split("T")[0];
}
get selectedDate(): string {
return this._selectedDate;
}
get selectedDate(): string {
return this._selectedDate;
}
set selectedDate(newDate: string) {
this._selectedDate = newDate;
}
set selectedDate(newIsoDate: string) {
if (this._selectedDate !== newIsoDate) {
this._selectedDate = newIsoDate;
document.dispatchEvent(new CustomEvent(DATE_CHANGED_EVENT_NAME, {
detail: { newIsoDate: this._selectedDate } // Pass the new date
}));
} else {
//& console.log(`[PluginState] Date unchanged: ${newIsoDate}`);
}
}
}
export const pluginState = new PluginState();
export const pluginState = new PluginState();

View file

@ -0,0 +1,59 @@
//? Define the unique class used by the DateNavigatorRenderer container
//? Define a unique ID for the style tag specific to the navigator
const STYLE_ID = "sqlitedb-date-navigator-styles";
const NAVIGATOR_CONTAINER_WRAPPER_CLASS = "date-navigator-container-wrapper";
/** Returns the CSS string specifically for the Date Navigator component. */
function getDateNavigatorStyles(): string {
return `
.${NAVIGATOR_CONTAINER_WRAPPER_CLASS} {
padding-bottom: var(--size-4-2);
border-bottom: 1px solid var(--background-modifier-border);
}
.${NAVIGATOR_CONTAINER_WRAPPER_CLASS} .date-navigator-wrapper {
display: flex;
flex-direction: column; /* Stack nav row and button vertically */
align-items: center;
gap: var(--size-4-2); /* Space between nav row and button */
}
.${NAVIGATOR_CONTAINER_WRAPPER_CLASS} .date-nav-row {
display: flex;
align-items: center;
justify-content: center;
gap: var(--size-4-3);
width: 100%;
}
.${NAVIGATOR_CONTAINER_WRAPPER_CLASS} h1.date-navigator-display {
margin: 0;
text-align: center;
flex-grow: 1;
font-size: var(--font-heading-1);
color: var(--text-error);
white-space: nowrap;
min-width: 15ch;
}
`;
}
/** Injects CSS for the Date Navigator component into the document head if not already present. */
export function injectDateNavigatorStyles(): void {
if (document.getElementById(STYLE_ID)) {
return;
}
const styleEl = document.createElement('style');
styleEl.id = STYLE_ID;
styleEl.textContent = getDateNavigatorStyles();
document.head.appendChild(styleEl);
}
/** Removes the injected Date Navigator styles from the document head. */
export function removeDateNavigatorStyles(): void {
const styleEl = document.getElementById(STYLE_ID);
if (styleEl) {
styleEl.remove();
}
}

View file

@ -12,6 +12,7 @@ import {
clearComponentErrorState,
HabitCounterUIElements
} from "./dom/uiUpdaters";
import { DATE_CHANGED_EVENT_NAME, pluginState } from "../../pluginState";
//? Main Web Component class, acts as an orchestrator.
export class HabitCounter extends HTMLElement {
@ -36,6 +37,8 @@ export class HabitCounter extends HTMLElement {
wrapper: null, valueDisplay: null, labelElement: null, minusButton: null, plusButton: null
};
private _isListeningForDateChanges: boolean = false; // Prevent multiple listeners
// --- Lifecycle ---
connectedCallback() {
if (this._isInitialized) return;
@ -48,9 +51,15 @@ export class HabitCounter extends HTMLElement {
this.uiElements = buildHabitCounterDOM(this.attachShadow({ mode: "open" }), handlers); //~ Build DOM via helper
applyHabitCounterStyles(this.shadowRoot!); //~ Apply styles via helper
this._setupDateChangeListener(); //^ Call setup listener
this._isInitialized = true;
}
disconnectedCallback() {
document.removeEventListener(DATE_CHANGED_EVENT_NAME, this._handleGlobalDateChange);
this._isListeningForDateChanges = false;
}
// --- Public Methods ---
public setDbService(service: DBService): void {
if (!service) {
@ -98,6 +107,32 @@ export class HabitCounter extends HTMLElement {
});
}
private _setupDateChangeListener(): void {
//? Only add listener if component uses dynamic date and not already listening
if (this.getAttribute("date") === "@date" && !this._isListeningForDateChanges) {
document.addEventListener(DATE_CHANGED_EVENT_NAME, this._handleGlobalDateChange);
this._isListeningForDateChanges = true;
} else {
//& console.log(`[HabitCounter Component ${this.habitKey}] Not adding listener (date=${this.getAttribute("date")}, listening=${this._isListeningForDateChanges})`);
}
}
private _handleGlobalDateChange = (event: Event): void => {
//? Type guard for CustomEvent
if (!(event instanceof CustomEvent)) return;
const newIsoDate = event.detail?.newIsoDate;
//? Double-check this component should react (uses @date)
if (this.initialDate === "@date") {
//? Reload data using the same helper
loadHabitData(this).catch(err => {
console.error(`[HabitCounter Component ${this.habitKey}] Error reloading data after date change:`, err);
});
}
};
//? Public method called by click handlers via instance reference
public async _updateData(delta: number): Promise<void> {
await updateHabitData(this, delta); //~ Update data via helper