diff --git a/PR.md b/PR.md new file mode 100644 index 00000000..1eb424f9 --- /dev/null +++ b/PR.md @@ -0,0 +1,30 @@ +# feat/ui-tweaks + +## Task card affordances and visible-property inheritance + +This change set refines task card affordances (clear click cues) and ensures subtasks inherit the parent card’s visible properties so metadata stays consistent across levels. + +Examples (illustrative): + +- Inline task cards show a pointer on the title to clarify clickability. +- Project cards keep the chevron visible when it appears on the right. +- Subtasks mirror the parent card’s visible properties. + +## Changelog + +- Add pointer cursor cues to clickable task card controls and inline titles. +- Mark project task cards and keep right-side chevrons visible. +- Persist resolved visible properties on cards and apply them to subtask rendering. +- Respect an explicitly empty visible-properties list (do not fall back to defaults). + +## Tests + +- `npm run i18n:sync` +- `npm run lint` (warnings only; matches `main`) +- `npm run typecheck` +- `npm run test:ci -- --verbose` (fails: `due-date-timezone-inconsistency` test, same as `main`) +- `./node_modules/.bin/jest tests/unit/ui/TaskCard.test.ts --runInBand` +- `npm run test:integration` +- `npm run test:performance` (no tests found) +- `npm run build` (missing OAuth IDs warning, same as `main`) +- `npm run test:build` diff --git a/src/ui/TaskCard.ts b/src/ui/TaskCard.ts index 3aeb88d1..0e08b467 100644 --- a/src/ui/TaskCard.ts +++ b/src/ui/TaskCard.ts @@ -409,6 +409,37 @@ function getDefaultVisibleProperties(plugin: TaskNotesPlugin): string[] { return convertInternalToUserProperties(internalDefaults, plugin); } +function resolveVisibleProperties( + visibleProperties: string[] | undefined, + plugin: TaskNotesPlugin +): string[] { + if (visibleProperties !== undefined) { + return visibleProperties; + } + + if (plugin.settings.defaultVisibleProperties) { + return convertInternalToUserProperties(plugin.settings.defaultVisibleProperties, plugin); + } + + return getDefaultVisibleProperties(plugin); +} + +function getSubtaskVisibleProperties(card: HTMLElement, plugin: TaskNotesPlugin): string[] { + const raw = card?.dataset?.visibleProperties; + if (raw) { + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + return parsed; + } + } catch (error) { + console.warn("Failed to parse visibleProperties from card dataset", error); + } + } + + return resolveVisibleProperties(undefined, plugin); +} + /** * Property value extractors for better type safety and error handling */ @@ -1333,6 +1364,7 @@ export function createTaskCard( const todayLocal = new Date(); return new Date(Date.UTC(todayLocal.getFullYear(), todayLocal.getMonth(), todayLocal.getDate())); })(); + const resolvedVisibleProperties = resolveVisibleProperties(visibleProperties, plugin); // Determine effective status for recurring tasks const effectiveStatus = task.recurrence @@ -1345,6 +1377,7 @@ export function createTaskCard( // Main container with BEM class structure // Use span for inline layout to ensure proper inline flow in CodeMirror const card = document.createElement(layout === "inline" ? "span" : "div"); + card.dataset.visibleProperties = JSON.stringify(resolvedVisibleProperties); // Store task path for circular reference detection (card as any)._taskPath = task.path; @@ -1357,6 +1390,7 @@ export function createTaskCard( ? task.skipped_instances?.includes(formatDateForStorage(targetDate)) || false // Direct check of skipped_instances : false; // Only recurring tasks can have skipped instances const isRecurring = !!task.recurrence; + const isProject = plugin.projectSubtasksService?.isTaskUsedAsProjectSync(task.path) || false; // Build BEM class names const cardClasses = ["task-card"]; @@ -1372,6 +1406,7 @@ export function createTaskCard( if (task.archived) cardClasses.push("task-card--archived"); if (isActivelyTracked) cardClasses.push("task-card--actively-tracked"); if (isRecurring) cardClasses.push("task-card--recurring"); + if (isProject) cardClasses.push("task-card--project"); // Add priority modifier if (task.priority) { @@ -1425,8 +1460,7 @@ export function createTaskCard( let statusDot: HTMLElement | null = null; const shouldShowStatus = !opts.hideStatusIndicator && - (!visibleProperties || - visibleProperties.some((prop) => isPropertyForField(prop, "status", plugin))); + resolvedVisibleProperties.some((prop) => isPropertyForField(prop, "status", plugin)); if (shouldShowStatus) { statusDot = mainRow.createEl("span", { cls: "task-card__status-dot" }); if (statusConfig) { @@ -1451,8 +1485,7 @@ export function createTaskCard( // Priority indicator dot (conditional based on visible properties) const shouldShowPriority = - !visibleProperties || - visibleProperties.some((prop) => isPropertyForField(prop, "priority", plugin)); + resolvedVisibleProperties.some((prop) => isPropertyForField(prop, "priority", plugin)); if (task.priority && priorityConfig && shouldShowPriority) { const priorityDot = mainRow.createEl("span", { cls: "task-card__priority-dot", @@ -1584,11 +1617,7 @@ export function createTaskCard( const metadataElements: HTMLElement[] = []; // Get properties to display - const propertiesToShow = - visibleProperties || - (plugin.settings.defaultVisibleProperties - ? convertInternalToUserProperties(plugin.settings.defaultVisibleProperties, plugin) - : getDefaultVisibleProperties(plugin)); + const propertiesToShow = resolvedVisibleProperties; // Render each visible property for (const propertyId of propertiesToShow) { @@ -1785,6 +1814,7 @@ export function updateTaskCard( const todayLocal = new Date(); return new Date(Date.UTC(todayLocal.getFullYear(), todayLocal.getMonth(), todayLocal.getDate())); })(); + const resolvedVisibleProperties = resolveVisibleProperties(visibleProperties, plugin); // Update effective status const effectiveStatus = task.recurrence @@ -1827,6 +1857,7 @@ export function updateTaskCard( } element.className = cardClasses.join(" "); + element.dataset.visibleProperties = JSON.stringify(resolvedVisibleProperties); element.dataset.status = effectiveStatus; // Get the main row container @@ -1858,8 +1889,8 @@ export function updateTaskCard( // Update status dot (conditional based on visible properties) const shouldShowStatus = - !visibleProperties || - visibleProperties.some((prop) => isPropertyForField(prop, "status", plugin)); + !opts.hideStatusIndicator && + resolvedVisibleProperties.some((prop) => isPropertyForField(prop, "status", plugin)); const statusDot = element.querySelector(".task-card__status-dot") as HTMLElement; if (shouldShowStatus) { @@ -1867,12 +1898,24 @@ export function updateTaskCard( // Update existing dot if (statusConfig) { statusDot.style.borderColor = statusConfig.color; + if (statusConfig.icon) { + statusDot.addClass("task-card__status-dot--icon"); + statusDot.empty(); + setIcon(statusDot, statusConfig.icon); + } else { + statusDot.removeClass("task-card__status-dot--icon"); + statusDot.empty(); + } } } else if (mainRow) { // Add missing dot const newStatusDot = mainRow.createEl("span", { cls: "task-card__status-dot" }); if (statusConfig) { newStatusDot.style.borderColor = statusConfig.color; + if (statusConfig.icon) { + newStatusDot.addClass("task-card__status-dot--icon"); + setIcon(newStatusDot, statusConfig.icon); + } } // Add click handler to cycle through statuses @@ -1910,6 +1953,10 @@ export function updateTaskCard( if (task.priority) cardClasses.push(`task-card--priority-${task.priority}`); if (newEffectiveStatus) cardClasses.push(`task-card--status-${newEffectiveStatus}`); + if (plugin.settings?.subtaskChevronPosition === "left") + cardClasses.push("task-card--chevron-left"); + if (plugin.projectSubtasksService?.isTaskUsedAsProjectSync(task.path)) + cardClasses.push("task-card--project"); element.className = cardClasses.join(" "); element.dataset.status = newEffectiveStatus; @@ -1967,8 +2014,7 @@ export function updateTaskCard( // Update priority indicator (conditional based on visible properties) const shouldShowPriority = - !visibleProperties || - visibleProperties.some((prop) => isPropertyForField(prop, "priority", plugin)); + resolvedVisibleProperties.some((prop) => isPropertyForField(prop, "priority", plugin)); const existingPriorityDot = element.querySelector(".task-card__priority-dot") as HTMLElement; if (shouldShowPriority && task.priority && priorityConfig) { @@ -2071,6 +2117,7 @@ export function updateTaskCard( plugin.projectSubtasksService .isTaskUsedAsProject(task.path) .then((isProject: boolean) => { + element.classList.toggle("task-card--project", isProject); // Remove old placeholders if they exist element.querySelector(".task-card__project-indicator-placeholder")?.remove(); element.querySelector(".task-card__chevron-placeholder")?.remove(); @@ -2178,11 +2225,7 @@ export function updateTaskCard( const metadataElements: HTMLElement[] = []; // Get properties to display - const propertiesToShow = - visibleProperties || - (plugin.settings.defaultVisibleProperties - ? convertInternalToUserProperties(plugin.settings.defaultVisibleProperties, plugin) - : getDefaultVisibleProperties(plugin)); + const propertiesToShow = resolvedVisibleProperties; for (const propertyId of propertiesToShow) { // Skip status and priority as they're rendered separately @@ -2430,6 +2473,7 @@ export async function toggleSubtasks( // Sort subtasks const sortedSubtasks = plugin.projectSubtasksService.sortTasks(subtasks); + const parentVisibleProperties = getSubtaskVisibleProperties(card, plugin); // Build parent chain by traversing up the DOM hierarchy const buildParentChain = (element: HTMLElement): string[] => { @@ -2461,7 +2505,7 @@ export async function toggleSubtasks( continue; } - const subtaskCard = createTaskCard(subtask, plugin, undefined); + const subtaskCard = createTaskCard(subtask, plugin, parentVisibleProperties); // Add subtask modifier class subtaskCard.classList.add("task-card--subtask"); diff --git a/styles/task-card-bem.css b/styles/task-card-bem.css index 045fb7eb..a7314819 100644 --- a/styles/task-card-bem.css +++ b/styles/task-card-bem.css @@ -266,6 +266,7 @@ transition: all var(--tn-transition-fast); position: relative; box-shadow: none; + cursor: pointer; } .tasknotes-plugin .task-card__status-dot:hover { @@ -366,6 +367,7 @@ transition: all var(--tn-transition-fast); border-radius: var(--tn-radius-sm); flex-shrink: 0; + cursor: pointer; } .tasknotes-plugin .task-card__recurring-indicator:hover { @@ -401,6 +403,7 @@ justify-content: center; border-radius: var(--tn-radius-sm); flex-shrink: 0; + cursor: pointer; } .tasknotes-plugin .task-card__reminder-indicator:hover { @@ -426,6 +429,7 @@ border-radius: var(--tn-radius-sm); transition: all var(--tn-transition-fast); flex-shrink: 0; + cursor: pointer; } .tasknotes-plugin .task-card__project-indicator:hover { @@ -451,6 +455,13 @@ border-radius: var(--tn-radius-sm); transition: all var(--tn-transition-fast); flex-shrink: 0; + cursor: pointer; + order: 10; +} + +/* Always show chevron for project tasks when on the right */ +.tasknotes-plugin .task-card--project:not(.task-card--chevron-left) .task-card__chevron { + opacity: 1; } .tasknotes-plugin .task-card__chevron:hover { @@ -496,6 +507,7 @@ transition: color var(--tn-transition-fast), background var(--tn-transition-fast), opacity var(--tn-transition-fast); border-radius: var(--tn-radius-sm); flex-shrink: 0; + cursor: pointer; } .tasknotes-plugin .task-card__blocking-toggle:hover { @@ -644,6 +656,7 @@ border-radius: var(--tn-radius-sm); flex-shrink: 0; margin-left: auto; + cursor: pointer; } .tasknotes-plugin .task-card__context-menu:hover { @@ -675,6 +688,7 @@ .tasknotes-plugin .task-card__priority-dot { transition: all var(--tn-transition-fast); box-shadow: none; + cursor: pointer; } .tasknotes-plugin .task-card__priority-dot:hover { @@ -688,6 +702,7 @@ padding: 3px 6px; margin: -3px -6px; position: relative; + cursor: pointer; } .tasknotes-plugin .task-card__metadata-date:hover { @@ -1139,6 +1154,12 @@ vertical-align: baseline; } +/* Inline layout: indicate the title is clickable (opens task dialog) */ +.tasknotes-plugin .task-card--layout-inline .task-card__title, +.tasknotes-plugin .task-card--layout-inline .task-card__title-text { + cursor: pointer; +} + /* Metadata in inline mode - stays on same line, scrolls if needed */ .tasknotes-plugin .task-card--layout-inline .task-card__metadata { display: inline-block !important; diff --git a/tests/unit/ui/TaskCard.test.ts b/tests/unit/ui/TaskCard.test.ts index c9bc4199..65387a99 100644 --- a/tests/unit/ui/TaskCard.test.ts +++ b/tests/unit/ui/TaskCard.test.ts @@ -123,7 +123,9 @@ describe('TaskCard Component', () => { app: mockApp, selectedDate: new Date('2025-01-15'), fieldMapper: { - isPropertyForField: jest.fn(() => false), + isPropertyForField: jest.fn((propertyId: string, internalField: string) => { + return propertyId === internalField; + }), toUserField: jest.fn((field) => field), toInternalField: jest.fn((field) => field), getMapping: jest.fn(() => ({