diff --git a/main.ts b/main.ts index 5d79ee5..71ca7c0 100644 --- a/main.ts +++ b/main.ts @@ -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) { diff --git a/src/DBService.ts b/src/DBService.ts index f6764d7..7444c1e 100644 --- a/src/DBService.ts +++ b/src/DBService.ts @@ -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 } } diff --git a/src/codeblocks/dateHeader/components/dateNavigator/DateNavigator.ts b/src/codeblocks/dateHeader/components/dateNavigator/DateNavigator.ts new file mode 100644 index 0000000..650088f --- /dev/null +++ b/src/codeblocks/dateHeader/components/dateNavigator/DateNavigator.ts @@ -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; + } +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/components/dateNavigator/dateNavigator.types.ts b/src/codeblocks/dateHeader/components/dateNavigator/dateNavigator.types.ts new file mode 100644 index 0000000..39a0653 --- /dev/null +++ b/src/codeblocks/dateHeader/components/dateNavigator/dateNavigator.types.ts @@ -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; +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/components/dateNavigator/dom/buildDateNavigatorDOM.ts b/src/codeblocks/dateHeader/components/dateNavigator/dom/buildDateNavigatorDOM.ts new file mode 100644 index 0000000..bec0f51 --- /dev/null +++ b/src/codeblocks/dateHeader/components/dateNavigator/dom/buildDateNavigatorDOM.ts @@ -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."); + } +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/components/dateNavigator/eventHandlers/dateNavigationHandlers.ts b/src/codeblocks/dateHeader/components/dateNavigator/eventHandlers/dateNavigationHandlers.ts new file mode 100644 index 0000000..c959969 --- /dev/null +++ b/src/codeblocks/dateHeader/components/dateNavigator/eventHandlers/dateNavigationHandlers.ts @@ -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(); + }; +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/components/datePicker/DatePickerModal.ts b/src/codeblocks/dateHeader/components/datePicker/DatePickerModal.ts new file mode 100644 index 0000000..26f3a26 --- /dev/null +++ b/src/codeblocks/dateHeader/components/datePicker/DatePickerModal.ts @@ -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(); + } +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/components/datePicker/datePickerUtils.ts b/src/codeblocks/dateHeader/components/datePicker/datePickerUtils.ts new file mode 100644 index 0000000..3ac14f3 --- /dev/null +++ b/src/codeblocks/dateHeader/components/datePicker/datePickerUtils.ts @@ -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' + }); +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/components/datePicker/dom/buildCalendarGrid.ts b/src/codeblocks/dateHeader/components/datePicker/dom/buildCalendarGrid.ts new file mode 100644 index 0000000..442e22b --- /dev/null +++ b/src/codeblocks/dateHeader/components/datePicker/dom/buildCalendarGrid.ts @@ -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; +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/components/datePicker/dom/buildDatePickerFooter.ts b/src/codeblocks/dateHeader/components/datePicker/dom/buildDatePickerFooter.ts new file mode 100644 index 0000000..24640e1 --- /dev/null +++ b/src/codeblocks/dateHeader/components/datePicker/dom/buildDatePickerFooter.ts @@ -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; +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/components/datePicker/dom/buildDatePickerHeader.ts b/src/codeblocks/dateHeader/components/datePicker/dom/buildDatePickerHeader.ts new file mode 100644 index 0000000..375620e --- /dev/null +++ b/src/codeblocks/dateHeader/components/datePicker/dom/buildDatePickerHeader.ts @@ -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; +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/components/datePicker/dom/buildWeekdayHeaders.ts b/src/codeblocks/dateHeader/components/datePicker/dom/buildWeekdayHeaders.ts new file mode 100644 index 0000000..16f221e --- /dev/null +++ b/src/codeblocks/dateHeader/components/datePicker/dom/buildWeekdayHeaders.ts @@ -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; +} \ No newline at end of file diff --git a/src/codeblocks/dateHeader/index.ts b/src/codeblocks/dateHeader/index.ts new file mode 100644 index 0000000..f09e2be --- /dev/null +++ b/src/codeblocks/dateHeader/index.ts @@ -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; + } +} \ No newline at end of file diff --git a/src/codeblocks/datePicker/DatePickerModal.ts b/src/codeblocks/datePicker/DatePickerModal.ts deleted file mode 100644 index 81a2b6c..0000000 --- a/src/codeblocks/datePicker/DatePickerModal.ts +++ /dev/null @@ -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(); - } -} \ No newline at end of file diff --git a/src/codeblocks/datePicker/index.ts b/src/codeblocks/datePicker/index.ts deleted file mode 100644 index e4c5b69..0000000 --- a/src/codeblocks/datePicker/index.ts +++ /dev/null @@ -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(); - }; -} diff --git a/src/codeblocks/index.ts b/src/codeblocks/index.ts index d771e66..0172ab5 100644 --- a/src/codeblocks/index.ts +++ b/src/codeblocks/index.ts @@ -1,5 +1,5 @@ import { processSqlBlock } from "./processSqlBlock"; import { processSqlChartBlock } from "./processSqlChartBlock"; -import { renderDatePicker } from "./datePicker"; +import { DateNavigatorRenderer } from "./dateHeader"; -export { processSqlBlock, processSqlChartBlock, renderDatePicker }; \ No newline at end of file +export { processSqlBlock, processSqlChartBlock, DateNavigatorRenderer }; \ No newline at end of file diff --git a/src/pluginState.ts b/src/pluginState.ts index dc106c5..567886e 100644 --- a/src/pluginState.ts +++ b/src/pluginState.ts @@ -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(); \ No newline at end of file diff --git a/src/styles/dateNavigationInject.ts b/src/styles/dateNavigationInject.ts new file mode 100644 index 0000000..74041fc --- /dev/null +++ b/src/styles/dateNavigationInject.ts @@ -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(); + } +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/HabitCounter.ts b/src/webcomponents/HabitCounter/HabitCounter.ts index 031cf00..290e5fa 100644 --- a/src/webcomponents/HabitCounter/HabitCounter.ts +++ b/src/webcomponents/HabitCounter/HabitCounter.ts @@ -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 { await updateHabitData(this, delta); //~ Update data via helper