From ad87e0e851d2279b850a1cb82a99e124b74cbaed Mon Sep 17 00:00:00 2001 From: stfrigerio Date: Sat, 5 Apr 2025 17:55:00 +0200 Subject: [PATCH] added boolean switch --- main.ts | 5 + .../BooleanSwitch/BooleanSwitch.ts | 172 ++++++++++++++++++ .../BooleanSwitch/BooleanSwitch.types.ts | 19 ++ .../BooleanSwitch/data/loadBooleanValue.ts | 40 ++++ .../BooleanSwitch/data/upsertBooleanValue.ts | 49 +++++ .../dom/booleanSwitchUiUpdaters.ts | 40 ++++ .../dom/buildBooleanSwitchDOM.ts | 46 +++++ .../eventHandlers/changeHandler.ts | 25 +++ .../BooleanSwitch/registerBooleanSwitch.ts | 58 ++++++ .../styles/applyBooleanSwitchStyles.ts | 12 ++ .../styles/getBooleanSwitchStyles.ts | 91 +++++++++ .../HabitCounter/HabitCounter.ts | 8 +- .../HabitCounter/data/loadHabitData.ts | 4 +- .../HabitCounter/data/updateHabitData.ts | 4 +- .../services/BooleanDataService.ts | 94 ++++++++++ .../services/HabitDataService.ts | 27 ++- .../utils}/calculateEffectiveDate.ts | 0 .../services/utils/quoteSqlIdentifier.ts | 0 .../services/utils/validateBooleanArgs.ts | 27 +++ .../services/utils/validateHabitArgs.ts | 0 20 files changed, 705 insertions(+), 16 deletions(-) create mode 100644 src/webcomponents/BooleanSwitch/BooleanSwitch.ts create mode 100644 src/webcomponents/BooleanSwitch/BooleanSwitch.types.ts create mode 100644 src/webcomponents/BooleanSwitch/data/loadBooleanValue.ts create mode 100644 src/webcomponents/BooleanSwitch/data/upsertBooleanValue.ts create mode 100644 src/webcomponents/BooleanSwitch/dom/booleanSwitchUiUpdaters.ts create mode 100644 src/webcomponents/BooleanSwitch/dom/buildBooleanSwitchDOM.ts create mode 100644 src/webcomponents/BooleanSwitch/eventHandlers/changeHandler.ts create mode 100644 src/webcomponents/BooleanSwitch/registerBooleanSwitch.ts create mode 100644 src/webcomponents/BooleanSwitch/styles/applyBooleanSwitchStyles.ts create mode 100644 src/webcomponents/BooleanSwitch/styles/getBooleanSwitchStyles.ts create mode 100644 src/webcomponents/services/BooleanDataService.ts rename src/webcomponents/{HabitCounter => }/services/HabitDataService.ts (74%) rename src/webcomponents/{HabitCounter/data => services/utils}/calculateEffectiveDate.ts (100%) rename src/webcomponents/{HabitCounter => }/services/utils/quoteSqlIdentifier.ts (100%) create mode 100644 src/webcomponents/services/utils/validateBooleanArgs.ts rename src/webcomponents/{HabitCounter => }/services/utils/validateHabitArgs.ts (100%) diff --git a/main.ts b/main.ts index 71ca7c0..68ced88 100644 --- a/main.ts +++ b/main.ts @@ -17,6 +17,7 @@ import { injectDatePickerStyles } from "src/styles/datePickerInject"; import { injectDateNavigatorStyles, removeDateNavigatorStyles } from './src/styles/dateNavigationInject'; import { registerHabitCounter } from "./src/webcomponents/HabitCounter/registerHabitCounter"; +import { registerBooleanSwitch } from "src/webcomponents/BooleanSwitch/registerBooleanSwitch"; export default class SQLiteDBPlugin extends Plugin { settings: SQLiteDBSettings; @@ -31,10 +32,13 @@ export default class SQLiteDBPlugin extends Plugin { injectDatePickerStyles(); injectDateNavigatorStyles(); + //? Components this.registerMarkdownPostProcessor((el, ctx) => { registerHabitCounter(el, this.dbService); + registerBooleanSwitch(el, this.dbService); }); + //? Commands this.addCommand({ id: "inspect-table-structure", name: "Inspect table structure", @@ -61,6 +65,7 @@ export default class SQLiteDBPlugin extends Plugin { }, }); + //? Codeblocks this.registerMarkdownCodeBlockProcessor( "sql", async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => { diff --git a/src/webcomponents/BooleanSwitch/BooleanSwitch.ts b/src/webcomponents/BooleanSwitch/BooleanSwitch.ts new file mode 100644 index 0000000..0011374 --- /dev/null +++ b/src/webcomponents/BooleanSwitch/BooleanSwitch.ts @@ -0,0 +1,172 @@ +import { DBService } from "../../DBService"; +import { BooleanSwitchDataService } from "../services/BooleanDataService"; +import { applyBooleanSwitchStyles } from "./styles/applyBooleanSwitchStyles"; +import { buildBooleanSwitchDOM, BooleanSwitchUIElements } from "./dom/buildBooleanSwitchDOM"; +import { createChangeHandler } from "./eventHandlers/changeHandler"; +import { loadBooleanValue } from "./data/loadBooleanValue"; +import { upsertBooleanValue } from "./data/upsertBooleanValue"; +import { + updateSwitchState, updateStaticBooleanUI, setBooleanSwitchErrorState, + clearBooleanSwitchErrorState +} from "./dom/booleanSwitchUiUpdaters"; +import { DATE_CHANGED_EVENT_NAME } from "../../pluginState"; + +//? Unique ID counter for label/input association +let switchCounter = 0; + +//? Web component for a boolean (0/1) toggle switch linked to database state. +export class BooleanSwitch extends HTMLElement { + // --- State --- + public habitKey: string = ""; + public initialDate: string = ""; + public emoji: string = ""; + public table: string = ""; + public currentValue: 0 | 1 = 0; // Default state is 'off' (0) + private _initialLoadTriggered: boolean = false; + private _isInitialized: boolean = false; + public habitIdCol: string = ""; + public valueCol: string = ""; // Column name for 'value' (0/1) + public dateCol: string = ""; + + // --- Dependencies --- + //? Renamed service property for clarity + public booleanDataService: BooleanSwitchDataService | null = null; + + // --- Shadow DOM Elements --- + private uiElements!: BooleanSwitchUIElements; + + private _isListeningForDateChanges: boolean = false; // Prevent multiple listeners + + // --- Instance specific ID --- + private uniqueId: string; + + constructor() { + super(); + this.uniqueId = `bs-${switchCounter++}`; // Assign unique ID + } + + connectedCallback() { + if (this._isInitialized) return; + + //~ Create bound event handler + const changeHandler = createChangeHandler(this); + + this.uiElements = buildBooleanSwitchDOM(this.attachShadow({ mode: "open" }), this.uniqueId, changeHandler); + applyBooleanSwitchStyles(this.shadowRoot!); + this._isInitialized = true; + } + + disconnectedCallback() { + if (this._isListeningForDateChanges) { + document.removeEventListener(DATE_CHANGED_EVENT_NAME, this._handleGlobalDateChange); + this._isListeningForDateChanges = false; + } + } + + //? Inject DBService and trigger attribute reading/initial load + public setDbService(service: DBService): void { + if (!service) { /* ... error handling ... */ this.showErrorState("Setup Error"); return; } + try { + this.booleanDataService = new BooleanSwitchDataService(service); + } catch (error) { /* ... error handling ... */ this.showErrorState("Setup Error"); return; } + this._readAttributesAndInitLoad(); + } + + private _readAttributesAndInitLoad(): void { + // --- Read ALL attributes --- + this.habitKey = this.getAttribute("data-key") ?? ""; + this.initialDate = this.getAttribute("data-date") ?? "@date"; + this.emoji = this.getAttribute("data-emoji") ?? "❓"; + this.table = this.getAttribute("data-table") ?? ""; + this.habitIdCol = this.getAttribute("data-key-id-col") ?? ""; + this.valueCol = this.getAttribute("data-value-col") ?? ""; + this.dateCol = this.getAttribute("data-date-col") ?? ""; + + this._setupDateChangeListener(); + + requestAnimationFrame(() => { + updateStaticBooleanUI(this.uiElements, this.emoji, this.habitKey); //~ Update static parts of UI + + if (!this.table || !this.habitKey || !this.habitIdCol || !this.valueCol || !this.dateCol) { + console.warn(`[HabitCounter Component ${this.habitKey}] Missing one or more required attributes: table, habit, data-habit-id-col, data-value-col, data-date-col.`); + this.showErrorState("Config Error"); + updateStaticBooleanUI(this.uiElements, this.emoji, this.habitKey || "Config Error"); + return; // Stop initialization if config is bad + } + + if (!this._initialLoadTriggered) { + this._initialLoadTriggered = true; + loadBooleanValue(this).catch(err => console.error("Unhandled error during initial load:", err)); //~ Load data via helper + } else { + console.log(`[BooleanSwitch Component ${this.habitKey}] Initial load already triggered.`); + } + }); + } + + private _setupDateChangeListener(): void { + //? Only add listener if component uses dynamic date and not already listening + if (this.initialDate === "@date" && !this._isListeningForDateChanges) { + document.addEventListener(DATE_CHANGED_EVENT_NAME, this._handleGlobalDateChange); + this._isListeningForDateChanges = true; + } else { + //& console.log(`[BooleanSwitch ${this.habitKey}] Not adding listener (date=${this.initialDate}, listening=${this._isListeningForDateChanges})`); + } + } + + // Inside BooleanSwitch class + private _handleGlobalDateChange = (event: Event): void => { + //? Type guard for CustomEvent + if (!(event instanceof CustomEvent)) { + console.log(`[BooleanSwitch ${this.habitKey}] Event was not CustomEvent.`); + return; + } + + const newIsoDate = event.detail?.newIsoDate; + + //? Double-check this component should react (uses @date) + if (this.initialDate === "@date") { + loadBooleanValue(this) + .catch(err => { + console.error(`[BooleanSwitch ${this.habitKey}] Error reloading data after date change:`, err); + }) + } else { + //& console.log(`[BooleanSwitch ${this.habitKey}] Ignoring date change event (date=${this.initialDate}).`); + } + }; + + //? Public method called by change handler to trigger DB update + public async _updateData(): Promise { + await upsertBooleanValue(this); + } + + //? Public method called by data helpers to update display state + public _updateDisplay(newValue: 0 | 1): void { + this.currentValue = newValue; + updateSwitchState(this.uiElements, newValue === 1); + this.clearErrorState(); + this._enableSwitch(); + } + + //? Public methods for UI state helpers + public showErrorState(message?: string): void { + setBooleanSwitchErrorState(this, this.uiElements, message); // Pass host element + } + public clearErrorState(): void { + clearBooleanSwitchErrorState(this, this.uiElements); + } + + //? Enable/disable the actual input element + private _enableSwitch(): void { + if(this.uiElements.checkboxElement) this.uiElements.checkboxElement.disabled = false; + this.removeAttribute('aria-disabled'); + } + private _disableSwitch(): void { + if(this.uiElements.checkboxElement) this.uiElements.checkboxElement.disabled = true; + this.setAttribute('aria-disabled', 'true'); + } +} + +// --- Custom Element Definition --- +if (!customElements.get("boolean-switch")) { + customElements.define("boolean-switch", BooleanSwitch); +} \ No newline at end of file diff --git a/src/webcomponents/BooleanSwitch/BooleanSwitch.types.ts b/src/webcomponents/BooleanSwitch/BooleanSwitch.types.ts new file mode 100644 index 0000000..b7fe78b --- /dev/null +++ b/src/webcomponents/BooleanSwitch/BooleanSwitch.types.ts @@ -0,0 +1,19 @@ +//? Expected structure from DB fetch (aliased) +export interface BooleanRecord { + value: 0 | 1; //? Expecting 0 or 1 specifically +} + +//? Base arguments for identifying an entry +export interface BooleanSwitchDataArgs { + table: string; + habitKey: string; + date: string; + habitIdCol: string; //? Column name for the 'habitKey' + valueCol: string; //? Column name for the 0/1 value + dateCol: string; +} + +//? Arguments for upserting the boolean value +export interface UpsertBooleanSwitchArgs extends BooleanSwitchDataArgs { + newValue: 0 | 1; //? The new state (0 or 1) +} \ No newline at end of file diff --git a/src/webcomponents/BooleanSwitch/data/loadBooleanValue.ts b/src/webcomponents/BooleanSwitch/data/loadBooleanValue.ts new file mode 100644 index 0000000..9eea6e3 --- /dev/null +++ b/src/webcomponents/BooleanSwitch/data/loadBooleanValue.ts @@ -0,0 +1,40 @@ +import { BooleanSwitchDataService } from "../../services/BooleanDataService"; +import { calculateEffectiveDate } from "../../services/utils/calculateEffectiveDate"; + +//? Interface describing the component instance parts needed for loading +interface LoadBooleanComponentInstance { + booleanDataService: BooleanSwitchDataService | null; + table: string; habitKey: string; initialDate: string; + habitIdCol: string; valueCol: string; dateCol: string; + _updateDisplay: (value: 0 | 1) => void; // Expects 0 or 1 + showErrorState: (message: string) => void; +} + +/** Orchestrates loading the boolean value using the BooleanSwitchDataService. */ +export async function loadBooleanValue(instance: LoadBooleanComponentInstance): Promise { + if (!instance.booleanDataService || !instance.table || !instance.habitKey || !instance.habitIdCol || !instance.valueCol || !instance.dateCol) { + console.error(`[LoadBooleanValue ${instance.habitKey}] Cannot load data: Missing required properties.`); + instance.showErrorState("Load Error - Config"); + return; + } + + const effectiveDate = calculateEffectiveDate(instance.initialDate); + + const args = { + table: instance.table, + habitKey: instance.habitKey, + date: effectiveDate, + habitIdCol: instance.habitIdCol, + valueCol: instance.valueCol, + dateCol: instance.dateCol, + }; + + try { + const value = await instance.booleanDataService.fetchBooleanValue(args); + instance._updateDisplay(value); + + } catch (error) { + console.error(`[LoadBooleanValue ${instance.habitKey}] Failed to load data:`, error); + instance.showErrorState("Load Error - DB"); + } +} \ No newline at end of file diff --git a/src/webcomponents/BooleanSwitch/data/upsertBooleanValue.ts b/src/webcomponents/BooleanSwitch/data/upsertBooleanValue.ts new file mode 100644 index 0000000..f2862d1 --- /dev/null +++ b/src/webcomponents/BooleanSwitch/data/upsertBooleanValue.ts @@ -0,0 +1,49 @@ +import { BooleanSwitchDataService } from "../../services/BooleanDataService"; +import { calculateEffectiveDate } from "../../services/utils/calculateEffectiveDate"; +import { Notice } from "obsidian"; + +//? Interface describing the component instance parts needed for upserting +interface UpsertBooleanComponentInstance { + booleanDataService: BooleanSwitchDataService | null; + table: string; habitKey: string; initialDate: string; + currentValue: 0 | 1; + habitIdCol: string; valueCol: string; dateCol: string; + _updateDisplay: (value: 0 | 1) => void; + showErrorState: (message: string) => void; + clearErrorState: () => void; +} + +/** Orchestrates upserting the boolean value via the BooleanSwitchDataService. */ +export async function upsertBooleanValue(instance: UpsertBooleanComponentInstance): Promise { + if (!instance.booleanDataService || !instance.table || !instance.habitKey || !instance.habitIdCol || !instance.valueCol || !instance.dateCol) { + console.error(`[UpsertBooleanValue ${instance.habitKey}] Cannot update data: Missing required properties.`); + instance.showErrorState("Save Error - Config"); + new Notice("Cannot update switch: Configuration incomplete."); + return; + } + + //^ Flip the current value (0 becomes 1, 1 becomes 0) + const newValue = instance.currentValue === 1 ? 0 : 1; + + const args = { + table: instance.table, + habitKey: instance.habitKey, + date: calculateEffectiveDate(instance.initialDate), + newValue: newValue as 0 | 1, + habitIdCol: instance.habitIdCol, + valueCol: instance.valueCol, + dateCol: instance.dateCol, + }; + + try { + await instance.booleanDataService.upsertBooleanValue(args); + instance._updateDisplay(newValue); // Update UI with the new value + instance.clearErrorState(); + } catch (error) { + console.error(`[UpsertBooleanValue ${instance.habitKey}] Failed to upsert data:`, error); + instance.showErrorState("Save Error - DB"); + new Notice(`Error saving switch state for ${instance.habitKey}`); + //? Optional: Revert display on error? + // instance._updateDisplay(instance.currentValue); + } +} \ No newline at end of file diff --git a/src/webcomponents/BooleanSwitch/dom/booleanSwitchUiUpdaters.ts b/src/webcomponents/BooleanSwitch/dom/booleanSwitchUiUpdaters.ts new file mode 100644 index 0000000..7a3db8e --- /dev/null +++ b/src/webcomponents/BooleanSwitch/dom/booleanSwitchUiUpdaters.ts @@ -0,0 +1,40 @@ +import type { BooleanSwitchUIElements } from "./buildBooleanSwitchDOM"; + +/** Updates the checked state of the switch input. */ +export function updateSwitchState(elements: BooleanSwitchUIElements, isChecked: boolean): void { + if (elements.checkboxElement) { + elements.checkboxElement.checked = isChecked; + elements.checkboxElement.setAttribute("aria-checked", String(isChecked)); + } else { + console.error("[BooleanSwitchUI] checkboxElement not found for state update."); + } +} + +/** Updates label text based on component state. */ +export function updateStaticBooleanUI(elements: BooleanSwitchUIElements, emoji: string, key: string): void { + if (elements.labelElement) { + elements.labelElement.textContent = `${emoji} ${key}:`; + } else { + console.error("[BooleanSwitchUI] labelElement not found for static update."); + } +} + +/** Sets the component to visually indicate an error state and disables the switch. */ +export function setBooleanSwitchErrorState(hostElement: HTMLElement, elements: BooleanSwitchUIElements, message: string = "Error"): void { + if (elements.checkboxElement) { + elements.checkboxElement.disabled = true; + } + hostElement.classList.add('error'); // Add error class to host for potential styling + hostElement.setAttribute('aria-disabled', 'true'); + hostElement.setAttribute('title', message); // Tooltip for error details +} + +/** Clears any visual error indication and enables the switch. */ +export function clearBooleanSwitchErrorState(hostElement: HTMLElement, elements: BooleanSwitchUIElements): void { + if (elements.checkboxElement) { + elements.checkboxElement.disabled = false; + } + hostElement.classList.remove('error'); + hostElement.removeAttribute('aria-disabled'); + hostElement.removeAttribute('title'); +} \ No newline at end of file diff --git a/src/webcomponents/BooleanSwitch/dom/buildBooleanSwitchDOM.ts b/src/webcomponents/BooleanSwitch/dom/buildBooleanSwitchDOM.ts new file mode 100644 index 0000000..1904bf6 --- /dev/null +++ b/src/webcomponents/BooleanSwitch/dom/buildBooleanSwitchDOM.ts @@ -0,0 +1,46 @@ +import type { ChangeEventHandler } from "../eventHandlers/changeHandler"; // Import type + +//? Type defining the UI Elements specific to the BooleanSwitch +export interface BooleanSwitchUIElements { + wrapper: HTMLElement; // The main host element might act as wrapper + labelElement: HTMLLabelElement; // Use label for accessibility + checkboxElement: HTMLInputElement; +} + +/** + * Builds the Shadow DOM structure for the boolean switch. + * @param shadowRoot - The ShadowRoot to append elements to. + * @param uniqueId - A unique ID for linking label and input. + * @param changeHandler - The function to call when the checkbox state changes. + * @returns An object containing references to the created UI elements. + */ +export function buildBooleanSwitchDOM( + shadowRoot: ShadowRoot, + uniqueId: string, // Needed for label 'for' attribute + changeHandler: ChangeEventHandler +): BooleanSwitchUIElements { + if (!shadowRoot) throw new Error("Cannot build DOM: ShadowRoot is null."); + + const wrapper = document.createElement("div"); // Use an inner wrapper if needed, or style host directly + wrapper.className = "switch-wrapper"; + + // --- Checkbox Input (The actual switch mechanism) --- + const checkboxElement = document.createElement("input"); + checkboxElement.type = "checkbox"; + checkboxElement.id = `bool-switch-${uniqueId}`; // Unique ID + checkboxElement.className = "bool-switch-input"; + checkboxElement.setAttribute("role", "switch"); // ARIA role + checkboxElement.addEventListener("change", changeHandler); + + // --- Label (Emoji + Text, associated with checkbox) --- + const labelElement = document.createElement("label"); + labelElement.htmlFor = checkboxElement.id; // Link label to input + labelElement.className = "switch-label"; + //? Text content will be set by the component later + + //? Append in desired visual order (Label first, then Switch) + wrapper.append(labelElement, checkboxElement); + shadowRoot.append(wrapper); + + return { wrapper, labelElement, checkboxElement }; +} \ No newline at end of file diff --git a/src/webcomponents/BooleanSwitch/eventHandlers/changeHandler.ts b/src/webcomponents/BooleanSwitch/eventHandlers/changeHandler.ts new file mode 100644 index 0000000..657e4b1 --- /dev/null +++ b/src/webcomponents/BooleanSwitch/eventHandlers/changeHandler.ts @@ -0,0 +1,25 @@ +//? Type for the component instance +interface BooleanSwitchInstance { + _updateData: () => Promise; + habitKey: string; +} + +//? Type for the event handler function itself +export type ChangeEventHandler = (event: Event) => void; + +/** Creates the handler for the checkbox's 'change' event. */ +export function createChangeHandler(instance: BooleanSwitchInstance): ChangeEventHandler { + return (event: Event) => { + //? We don't actually need the event target's value, + //? the component's _updateData method will flip the *current* state. + instance._updateData().catch(err => { + //? Error logging, specific handling is inside _updateData + console.error(`[BooleanSwitchChangeHandler ${instance.habitKey}] Error during update:`, err); + //? Consider reverting the checkbox state visually if update fails? Requires more complex state management. + if (event.target instanceof HTMLInputElement) { + // Example: Revert visual state - CAUTION: might fight with error state UI + event.target.checked = !event.target.checked; + } + }); + }; +} \ No newline at end of file diff --git a/src/webcomponents/BooleanSwitch/registerBooleanSwitch.ts b/src/webcomponents/BooleanSwitch/registerBooleanSwitch.ts new file mode 100644 index 0000000..834cce7 --- /dev/null +++ b/src/webcomponents/BooleanSwitch/registerBooleanSwitch.ts @@ -0,0 +1,58 @@ +import { DBService } from "../../DBService"; +import { BooleanSwitch } from "./BooleanSwitch"; + +//? Define element if needed +if (!customElements.get("boolean-switch")) { + customElements.define("boolean-switch", BooleanSwitch); +} + +/** + * Finds placeholder elements and replaces them with initialized BooleanSwitch components. + * @param el The container element to search within. + * @param dbService The DBService instance for dependency injection. + */ +export const registerBooleanSwitch = (el: HTMLElement, dbService: DBService) => { + const placeholders = el.querySelectorAll("span.boolean-switch-placeholder"); + + placeholders.forEach((placeholderEl) => { + if (!(placeholderEl instanceof HTMLElement)) return; + + //~ Read all expected attributes from the placeholder + const habitKey = placeholderEl.dataset.habit; + const date = placeholderEl.dataset.date; + const emoji = placeholderEl.dataset.emoji; + const table = placeholderEl.dataset.table; + const habitIdCol = placeholderEl.dataset.habitIdCol; + const valueCol = placeholderEl.dataset.valueCol; + const dateCol = placeholderEl.dataset.dateCol; + + // --- Basic Validation --- + if (!habitKey || !table || !habitIdCol || !valueCol || !dateCol) { + console.warn("[BooleanSwitch Reg] Placeholder missing one or more required data attributes (habitKey, table, habitIdCol, valueCol, dateCol):", placeholderEl); + placeholderEl.textContent = "[Config Error]"; + return; + } + + try { + const component = document.createElement("boolean-switch") as BooleanSwitch; + + //~ Set all attributes on the component element + component.setAttribute("data-key", habitKey); + component.setAttribute("data-table", table); + if (date) component.setAttribute("data-date", date); + else component.setAttribute("data-date", "@date"); // Default to dynamic date + if (emoji) component.setAttribute("data-emoji", emoji); + component.setAttribute("data-key-id-col", habitIdCol); + component.setAttribute("data-value-col", valueCol); + component.setAttribute("data-date-col", dateCol); + + component.setDbService(dbService); + + placeholderEl.replaceWith(component); + + } catch (error) { + console.error(`[BooleanSwitch Reg] Error processing placeholder for key "${habitKey}":`, error, placeholderEl); + placeholderEl.textContent = "[Error Loading]"; + } + }); +} \ No newline at end of file diff --git a/src/webcomponents/BooleanSwitch/styles/applyBooleanSwitchStyles.ts b/src/webcomponents/BooleanSwitch/styles/applyBooleanSwitchStyles.ts new file mode 100644 index 0000000..7948fbe --- /dev/null +++ b/src/webcomponents/BooleanSwitch/styles/applyBooleanSwitchStyles.ts @@ -0,0 +1,12 @@ +import { getBooleanSwitchStyles } from "./getBooleanSwitchStyles"; + +/** Creates a style element and appends it to the shadow root. */ +export function applyBooleanSwitchStyles(shadowRoot: ShadowRoot): void { + if (!shadowRoot) { + console.error("[BooleanSwitchStyles] Cannot apply styles: ShadowRoot is null."); + return; + } + const style = document.createElement("style"); + style.textContent = getBooleanSwitchStyles(); + shadowRoot.appendChild(style); +} \ No newline at end of file diff --git a/src/webcomponents/BooleanSwitch/styles/getBooleanSwitchStyles.ts b/src/webcomponents/BooleanSwitch/styles/getBooleanSwitchStyles.ts new file mode 100644 index 0000000..30aecd4 --- /dev/null +++ b/src/webcomponents/BooleanSwitch/styles/getBooleanSwitchStyles.ts @@ -0,0 +1,91 @@ +/** Returns the CSS string for the BooleanSwitch component's Shadow DOM. */ +export function getBooleanSwitchStyles(): string { + return ` + :host { + display: inline-flex; /* Align items horizontally */ + align-items: center; + gap: 0.6em; /* Space between label and switch */ + font-family: var(--font-text); + cursor: pointer; /* Make the whole thing clickable */ + user-select: none; /* Prevent text selection */ + } + + .switch-wrapper { + display: contents; /* Don't add extra layout */ + } + + .switch-label { + /* Styles for the emoji + text label */ + white-space: nowrap; + color: var(--text-normal); + line-height: 1.2; /* Align better with switch */ + } + + /* Basic Checkbox Styling (Foundation) */ + input[type="checkbox"] { + appearance: none; /* Remove default OS styling */ + -webkit-appearance: none; + -moz-appearance: none; + position: relative; /* For positioning pseudo-elements */ + width: 36px; /* Width of the switch */ + height: 20px; /* Height of the switch */ + background-color: var(--background-modifier-border); /* Off state background */ + border-radius: 10px; /* Rounded corners */ + cursor: pointer; + outline: none; + transition: background-color 0.2s ease-in-out; + vertical-align: middle; /* Align with text */ + margin: 0; /* Remove default margin */ + flex-shrink: 0; /* Prevent shrinking */ + } + + /* Styling the 'thumb' or 'knob' of the switch */ + input[type="checkbox"]::before { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 16px; /* Size of the knob */ + height: 16px; + background-color: var(--background-primary); /* Knob color */ + border-radius: 50%; /* Make it round */ + box-shadow: 0 1px 3px rgba(0,0,0,0.2); + transition: transform 0.2s ease-in-out; + } + + /* Styling when the switch is checked (On state) */ + input[type="checkbox"]:checked { + background-color: var(--interactive-accent); /* Use accent color for on state */ + } + + /* Moving the knob when checked */ + input[type="checkbox"]:checked::before { + transform: translateX(16px); /* Move knob to the right */ + } + + /* Focus state for accessibility */ + input[type="checkbox"]:focus-visible { + box-shadow: 0 0 0 2px var(--background-primary), 0 0 0 4px var(--interactive-accent-hover); + } + + /* Disabled state */ + input[type="checkbox"]:disabled { + background-color: var(--background-modifier-border-hover); + cursor: not-allowed; + opacity: 0.6; + } + input[type="checkbox"]:disabled::before { + background-color: var(--background-modifier-border); + } + :host([aria-disabled="true"]) { /* Style host when disabled */ + cursor: not-allowed; + opacity: 0.6; + } + + /* Error state indication (optional, applied to host or wrapper) */ + :host(.error) { + /* e.g., outline: 1px solid var(--text-error); */ + /* e.g., border-radius: var(--radius-s); */ + } + `; +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/HabitCounter.ts b/src/webcomponents/HabitCounter/HabitCounter.ts index 290e5fa..b18935c 100644 --- a/src/webcomponents/HabitCounter/HabitCounter.ts +++ b/src/webcomponents/HabitCounter/HabitCounter.ts @@ -1,5 +1,5 @@ import { DBService } from "../../DBService"; -import { HabitDataService } from "./services/HabitDataService"; +import { HabitDataService } from "../services/HabitDataService"; import { applyHabitCounterStyles } from "./styles/applyHabitCounterStyles"; import { buildHabitCounterDOM } from "./dom/buildHabitCounterDOM"; import { createDecrementHandler, createIncrementHandler } from "./eventHandlers/clickHandlers"; @@ -12,7 +12,7 @@ import { clearComponentErrorState, HabitCounterUIElements } from "./dom/uiUpdaters"; -import { DATE_CHANGED_EVENT_NAME, pluginState } from "../../pluginState"; +import { DATE_CHANGED_EVENT_NAME, } from "../../pluginState"; //? Main Web Component class, acts as an orchestrator. export class HabitCounter extends HTMLElement { @@ -33,9 +33,7 @@ export class HabitCounter extends HTMLElement { // --- Shadow DOM Element References --- //? Store the object returned by the builder - private uiElements: HabitCounterUIElements = { - wrapper: null, valueDisplay: null, labelElement: null, minusButton: null, plusButton: null - }; + private uiElements: HabitCounterUIElements private _isListeningForDateChanges: boolean = false; // Prevent multiple listeners diff --git a/src/webcomponents/HabitCounter/data/loadHabitData.ts b/src/webcomponents/HabitCounter/data/loadHabitData.ts index 1c63d66..0b158cd 100644 --- a/src/webcomponents/HabitCounter/data/loadHabitData.ts +++ b/src/webcomponents/HabitCounter/data/loadHabitData.ts @@ -1,5 +1,5 @@ -import { HabitDataService } from "../services/HabitDataService"; -import { calculateEffectiveDate } from "./calculateEffectiveDate"; +import { HabitDataService } from "../../services/HabitDataService"; +import { calculateEffectiveDate } from "../../services/utils/calculateEffectiveDate"; //? Interface describing the necessary parts of the component instance for loading. interface LoadComponentInstance { diff --git a/src/webcomponents/HabitCounter/data/updateHabitData.ts b/src/webcomponents/HabitCounter/data/updateHabitData.ts index 5c7a8f2..e928a9d 100644 --- a/src/webcomponents/HabitCounter/data/updateHabitData.ts +++ b/src/webcomponents/HabitCounter/data/updateHabitData.ts @@ -1,5 +1,5 @@ -import { HabitDataService } from "../services/HabitDataService"; -import { calculateEffectiveDate } from "./calculateEffectiveDate"; +import { HabitDataService } from "../../services/HabitDataService"; +import { calculateEffectiveDate } from "../../services/utils/calculateEffectiveDate"; import { Notice } from "obsidian"; //? Interface describing the necessary parts of the component instance for updating. diff --git a/src/webcomponents/services/BooleanDataService.ts b/src/webcomponents/services/BooleanDataService.ts new file mode 100644 index 0000000..737f96c --- /dev/null +++ b/src/webcomponents/services/BooleanDataService.ts @@ -0,0 +1,94 @@ +import { DBService } from "../../DBService"; +import { BooleanRecord, BooleanSwitchDataArgs, UpsertBooleanSwitchArgs } from "../BooleanSwitch/BooleanSwitch.types"; +import { quoteSqlIdentifier } from "./utils/quoteSqlIdentifier"; +import { validateBaseBooleanArgs, validateUpsertBooleanArgs } from "./utils/validateBooleanArgs"; + +export class BooleanSwitchDataService { + private dbService: DBService; + constructor(dbService: DBService) { + if (!dbService) throw new Error("BooleanSwitchDataService requires a valid DBService instance."); + this.dbService = dbService; + } + + /** Fetches the current 0/1 value for a specific key/date. Returns 0 if not found. */ + async fetchBooleanValue(args: BooleanSwitchDataArgs): Promise<0 | 1> { + try { + validateBaseBooleanArgs(args); + const safeTable = quoteSqlIdentifier(args.table); + const safeKeyIdCol = quoteSqlIdentifier(args.habitIdCol); + const safeValueCol = quoteSqlIdentifier(args.valueCol); + const safeDateCol = quoteSqlIdentifier(args.dateCol); + + const sql = `SELECT ${safeValueCol} AS value FROM ${safeTable} WHERE ${safeKeyIdCol} = ? AND ${safeDateCol} = ?`; + const params = [args.habitKey, args.date]; + + const result = await this.dbService.getQuery(sql, params); + + const value = result?.[0]?.value; + //^ Return 1 if value is 1, otherwise default to 0 + return value === 1 ? 1 : 0; + + } catch (error) { + console.error(`[BoolDataService] Error in fetchBooleanValue for ${args.habitKey}:`, error); + throw error; + } + } + + /** Upserts the 0/1 value. Handles optional UUID column logic. */ + async upsertBooleanValue(args: UpsertBooleanSwitchArgs): Promise { + try { + validateUpsertBooleanArgs(args); + + const safeTable = quoteSqlIdentifier(args.table); + const safeHabitIdCol = quoteSqlIdentifier(args.habitIdCol); + const safeValueCol = quoteSqlIdentifier(args.valueCol); + const safeDateCol = quoteSqlIdentifier(args.dateCol); + + let sql = ""; + let params: (string | number)[] = []; + + let existingUuid: string | undefined; + + try { + //^ Attempt to fetch UUID. Assumes the column is named 'uuid' if used. + const fetchUuidSql = `SELECT uuid FROM ${safeTable} WHERE ${safeHabitIdCol} = ? AND ${safeDateCol} = ?`; + const uuidResult = await this.dbService.getQuery<{ uuid: string }>(fetchUuidSql, [args.habitKey, args.date]); + existingUuid = uuidResult?.[0]?.uuid; + } catch (error) { + if (error instanceof Error && /no such column|does not exist/i.test(error.message) && error.message.includes('uuid')) { + console.log(`[BoolDataService] Info: Optional 'uuid' column not found in table '${args.table}'. Proceeding with standard upsert.`); + existingUuid = undefined; // Ensure it's undefined so the INSERT path is taken + } else { + console.error(`[BoolDataService] Error fetching potential UUID for ${args.habitKey} on ${args.date}:`, error); + throw error; // Re-throw unexpected errors + } + } + + if (existingUuid) { + sql = `UPDATE ${safeTable} SET ${safeValueCol} = ? WHERE uuid = ?`; + params = [args.newValue, existingUuid]; + } else { + // Standard UPSERT path (no UUID column configured) + sql = ` + INSERT INTO ${safeTable} (${safeHabitIdCol}, ${safeDateCol}, ${safeValueCol}) + VALUES (?, ?, ?) + ON CONFLICT(${safeHabitIdCol}, ${safeDateCol}) DO UPDATE + SET ${safeValueCol} = excluded.${safeValueCol}; + `; + //! Requires UNIQUE constraint on (keyIdCol, dateCol) + params = [args.habitKey, args.date, args.newValue]; + } + + await this.dbService.runQuery(sql, params); + + } catch (error) { + console.error(`[BoolDataService] Error in upsertBooleanValue for ${args.habitKey}:`, error); + if (error instanceof Error && error.message.includes("ON CONFLICT clause does not match")) { + //^ Provide more specific guidance mentioning the user-provided columns + console.error(`//! DB Constraint Error: Table '${args.table}' needs a UNIQUE index/key on (${args.habitIdCol}, ${args.dateCol}). Run: CREATE UNIQUE INDEX IF NOT EXISTS idx_habit_date ON ${quoteSqlIdentifier(args.table)} (${quoteSqlIdentifier(args.habitIdCol)}, ${quoteSqlIdentifier(args.dateCol)});`); + throw new Error(`Missing UNIQUE constraint on (${args.habitIdCol}, ${args.dateCol}) in table ${args.table}. ${error.message}`); + } + throw error; + } + } +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/services/HabitDataService.ts b/src/webcomponents/services/HabitDataService.ts similarity index 74% rename from src/webcomponents/HabitCounter/services/HabitDataService.ts rename to src/webcomponents/services/HabitDataService.ts index dcc3ffe..13b822e 100644 --- a/src/webcomponents/HabitCounter/services/HabitDataService.ts +++ b/src/webcomponents/services/HabitDataService.ts @@ -1,5 +1,5 @@ -import { DBService } from "../../../DBService"; -import { HabitRecord, HabitDataArgs, UpdateHabitDataArgs } from "../HabitCounter.types"; +import { DBService } from "../../DBService"; +import { HabitRecord, HabitDataArgs, UpdateHabitDataArgs } from "../HabitCounter/HabitCounter.types"; import { quoteSqlIdentifier } from "./utils/quoteSqlIdentifier"; import { validateFetchArgs, validateUpdateArgs } from "./utils/validateHabitArgs"; @@ -50,12 +50,25 @@ export class HabitDataService { const safeValueCol = quoteSqlIdentifier(args.valueCol); const safeDateCol = quoteSqlIdentifier(args.dateCol); - //? Attempt to fetch existing UUID - const fetchUuidSql = `SELECT uuid FROM ${safeTable} WHERE ${safeHabitIdCol} = ? AND ${safeDateCol} = ?`; - const fetchUuidParams = [args.habitKey, args.date]; - const uuidResult = await this.dbService.getQuery(fetchUuidSql, fetchUuidParams); - const existingUuid = uuidResult?.[0]?.uuid; + let existingUuid: string | undefined; + //? Attempt to fetch existing UUID + try { + //^ Attempt to fetch UUID. Assumes the column is named 'uuid' if used. + const fetchUuidSql = `SELECT uuid FROM ${safeTable} WHERE ${safeHabitIdCol} = ? AND ${safeDateCol} = ?`; + const uuidResult = await this.dbService.getQuery<{ uuid: string }>(fetchUuidSql, [args.habitKey, args.date]); + existingUuid = uuidResult?.[0]?.uuid; + } catch (error) { + if (error instanceof Error && /no such column|does not exist/i.test(error.message) && error.message.includes('uuid')) { + //^ It's expected that 'uuid' might not exist. Log for info and proceed without UUID. + console.log(`[HabitDataService] Info: Optional 'uuid' column not found in table '${args.table}'. Proceeding with standard upsert.`); + existingUuid = undefined; // Ensure it's undefined so the INSERT path is taken + } else { + //^ A different, unexpected error occurred during the UUID fetch, re-throw it. + console.error(`[HabitDataService] Error fetching potential UUID for ${args.habitKey} on ${args.date}:`, error); + throw error; // Re-throw unexpected errors + } + } if (existingUuid && typeof existingUuid === 'string') { const updateSql = `UPDATE ${safeTable} SET ${safeValueCol} = ? WHERE uuid = ?`; diff --git a/src/webcomponents/HabitCounter/data/calculateEffectiveDate.ts b/src/webcomponents/services/utils/calculateEffectiveDate.ts similarity index 100% rename from src/webcomponents/HabitCounter/data/calculateEffectiveDate.ts rename to src/webcomponents/services/utils/calculateEffectiveDate.ts diff --git a/src/webcomponents/HabitCounter/services/utils/quoteSqlIdentifier.ts b/src/webcomponents/services/utils/quoteSqlIdentifier.ts similarity index 100% rename from src/webcomponents/HabitCounter/services/utils/quoteSqlIdentifier.ts rename to src/webcomponents/services/utils/quoteSqlIdentifier.ts diff --git a/src/webcomponents/services/utils/validateBooleanArgs.ts b/src/webcomponents/services/utils/validateBooleanArgs.ts new file mode 100644 index 0000000..7c4b02e --- /dev/null +++ b/src/webcomponents/services/utils/validateBooleanArgs.ts @@ -0,0 +1,27 @@ +import { BooleanSwitchDataArgs, UpsertBooleanSwitchArgs } from "../../BooleanSwitch/BooleanSwitch.types"; + +const IDENTIFIER_REGEX = /^[a-zA-Z0-9_]+$/; + +/** Validates base arguments required for identifying boolean switch data and columns. */ +export function validateBaseBooleanArgs(args: BooleanSwitchDataArgs): void { + if (!args.table || !args.habitKey || !args.date) { + throw new Error(`Missing required identifying arguments: table='${args.table}', key='${args.habitKey}', date='${args.date}'`); + } + if (!args.habitIdCol || !IDENTIFIER_REGEX.test(args.habitIdCol)) { + throw new Error(`Invalid or missing key ID column name: '${args.habitIdCol}'`); + } + if (!args.valueCol || !IDENTIFIER_REGEX.test(args.valueCol)) { + throw new Error(`Invalid or missing value column name: '${args.valueCol}'`); + } + if (!args.dateCol || !IDENTIFIER_REGEX.test(args.dateCol)) { + throw new Error(`Invalid or missing date column name: '${args.dateCol}'`); + } +} + +/** Validates arguments required for upserting boolean switch data. */ +export function validateUpsertBooleanArgs(args: UpsertBooleanSwitchArgs): void { + validateBaseBooleanArgs(args); // Includes conditional uuidCol validation + if (args.newValue !== 0 && args.newValue !== 1) { //^ Check for 0 or 1 + throw new Error(`Invalid newValue for boolean upsert: '${args.newValue}' (must be 0 or 1)`); + } +} \ No newline at end of file diff --git a/src/webcomponents/HabitCounter/services/utils/validateHabitArgs.ts b/src/webcomponents/services/utils/validateHabitArgs.ts similarity index 100% rename from src/webcomponents/HabitCounter/services/utils/validateHabitArgs.ts rename to src/webcomponents/services/utils/validateHabitArgs.ts