dralkh_spaceforge/ui/sidebar/list-view-renderer.ts
dralkh e80dfda20d release: v1.0.5
JS/TS:
- Replace builtin-modules with node:module (esbuild.config.mjs)
- Replace fs-extra with native fs (install.js), remove from deps
- Fix createEl deprecation: use createDiv/createSpan
- Remove unnecessary type assertions, fix unsafe casts
- Bind event handlers with arrow functions

CSS:
- Remove all !important declarations (~30 instances) — replace with
  chained selectors (.class.class) for equal specificity
- Merge all duplicate selectors across 7 CSS files (~50+ instances)
- Convert 3-digit hex to 6-digit format (_variables.css)
- Fix duplicate CSS properties (overflow-y in mcq.css)
- Delete leftover display:none suppression rule in calendar.css

Meta:
- Bump version to 1.0.5
- Update minAppVersion to 1.8.7
- Add versions.json entry for 1.0.5
2026-05-20 11:02:54 +03:00

1070 lines
55 KiB
TypeScript

import { Notice, Setting } from "obsidian";
import SpaceforgePlugin from "../../main";
import { ReviewSchedule } from "../../models/review-schedule";
import { DateUtils } from "../../utils/dates";
import { EstimationUtils } from "../../utils/estimation";
import { PomodoroUIManager } from "./pomodoro-ui-manager";
import { NoteItemRenderer } from "./note-item-renderer";
import { ConfirmationModal } from "../confirmation-modal";
/**
* Manages the rendering of the list view within the sidebar.
*/
export class ListViewRenderer {
private plugin: SpaceforgePlugin;
private pomodoroUIManager: PomodoroUIManager;
private noteItemRenderer: NoteItemRenderer;
// State properties needed from ReviewSidebarView (passed during render or managed via callbacks)
private getActiveListBaseDate: () => Date | null;
private getSelectedNotes: () => string[];
private setSelectedNotes: (notes: string[]) => void;
private getExpandedUpcomingDayKey: () => string | null;
private setExpandedUpcomingDayKey: (key: string | null) => void;
private getLastSelectedNotePath: () => string | null;
private setLastSelectedNotePath: (path: string | null) => void;
private refreshSidebarView: () => Promise<void>; // Callback to trigger full refresh if needed
constructor(
plugin: SpaceforgePlugin,
pomodoroUIManager: PomodoroUIManager,
noteItemRenderer: NoteItemRenderer,
stateAccessors: {
getActiveListBaseDate: () => Date | null;
getSelectedNotes: () => string[];
setSelectedNotes: (notes: string[]) => void;
getExpandedUpcomingDayKey: () => string | null;
setExpandedUpcomingDayKey: (key: string | null) => void;
getLastSelectedNotePath: () => string | null;
setLastSelectedNotePath: (path: string | null) => void;
refreshSidebarView: () => Promise<void>;
}
) {
this.plugin = plugin;
this.pomodoroUIManager = pomodoroUIManager;
this.noteItemRenderer = noteItemRenderer;
this.getActiveListBaseDate = stateAccessors.getActiveListBaseDate;
this.getSelectedNotes = stateAccessors.getSelectedNotes;
this.setSelectedNotes = stateAccessors.setSelectedNotes;
this.getExpandedUpcomingDayKey = stateAccessors.getExpandedUpcomingDayKey;
this.setExpandedUpcomingDayKey = stateAccessors.setExpandedUpcomingDayKey;
this.getLastSelectedNotePath = stateAccessors.getLastSelectedNotePath;
this.setLastSelectedNotePath = stateAccessors.setLastSelectedNotePath;
this.refreshSidebarView = stateAccessors.refreshSidebarView;
}
/**
* Render the list view content into the provided container.
* @param container Container element for list view content
*/
async render(container: HTMLElement): Promise<void> {
// container.empty(); // Clear only the list view content area -- REMOVED
const activeListBaseDate = this.getActiveListBaseDate();
const selectedNotes = this.getSelectedNotes();
const dueNotesForStats = this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true);
// Setup drag-and-drop event handlers for the container
this.setupDragAndDrop(container);
// --- Stats Section (REMOVED as per user request) ---
// await this._ensureAndUpdateStatsSection(container, dueNotesForStats);
// --- Review Buttons & Pomodoro ---
// Pass this.plugin.reviewController.getTodayNotes() to ensure Pomodoro uses notes for the selected date context
await this._ensureAndUpdateReviewButtonsSection(container, this.plugin.reviewController.getTodayNotes(), selectedNotes);
// --- "All Caught Up" Message ---
this._ensureAndUpdateAllCaughtUpMessage(container, dueNotesForStats, activeListBaseDate);
// --- Main List Grouping and Rendering ---
let notesToGroup: ReviewSchedule[];
let shouldIncludeFutureInGrouping = false;
if (activeListBaseDate) {
notesToGroup = Object.values(this.plugin.reviewScheduleService.schedules);
shouldIncludeFutureInGrouping = true;
} else {
notesToGroup = dueNotesForStats; // Use already fetched due notes
}
const groupedNotes = this.groupNotesByDate(notesToGroup, shouldIncludeFutureInGrouping);
const sortedDateKeys = this.getSortedDateKeys(groupedNotes);
// --- Render Sections for Main List ---
await this._ensureAndUpdateDateSections(container, sortedDateKeys, groupedNotes);
// --- Active Session Section ---
this._ensureAndUpdateActiveSessionSection(container);
// --- Upcoming Reviews Section (Conditional) ---
if (!activeListBaseDate) {
await this._ensureAndUpdateUpcomingReviewsSection(container);
} else {
const existingUpcomingSection = container.querySelector(".review-upcoming-section");
if (existingUpcomingSection) existingUpcomingSection.remove();
}
this.updateBulkActionButtonsVisibility(container); // Ensure bulk actions visibility is correct at the end
}
// private async _ensureAndUpdateStatsSection(container: HTMLElement, dueNotesForStats: ReviewSchedule[]): Promise<void> {
// let statsEl = container.querySelector(".review-stats-list-view") as HTMLElement;
// if (!statsEl) {
// statsEl = container.createDiv("review-stats-list-view");
// }
// let statsCountEl = statsEl.querySelector(".review-stats-count") as HTMLElement;
// if (!statsCountEl) {
// statsCountEl = statsEl.createEl("div", { cls: "review-stats-count" });
// }
// const overdueNotes = dueNotesForStats.filter(note => note.nextReviewDate < DateUtils.startOfUTCDay());
// let totalTime = 0;
// for (const note of dueNotesForStats) {
// totalTime += await this.plugin.reviewScheduleService.estimateReviewTime(note.path);
// }
// statsCountEl.setText(`${dueNotesForStats.length} notes - ${EstimationUtils.formatTime(totalTime)}${overdueNotes.length > 0 ? ` (${overdueNotes.length} overdue)` : ''}`);
// }
private _ensureAndUpdateReviewButtonsSection(container: HTMLElement, notesForDisplay: ReviewSchedule[], _selectedNotes: string[]): void {
// --- Pomodoro Section (Always visible when enabled, independent of notes) ---
let pomodoroContainer = container.querySelector<HTMLElement>(".sidebar-pomodoro-section");
if (!pomodoroContainer) {
pomodoroContainer = container.createDiv("sidebar-pomodoro-section");
}
let pomodoroSectionContainerEl = pomodoroContainer.querySelector<HTMLElement>(".sidebar-pomodoro-section-container");
if (!pomodoroSectionContainerEl) {
// Check for legacy class names and migrate if found
const legacyEl = pomodoroContainer.querySelector<HTMLElement>(".pomodoro-section-container, .sidebar-pomodoro-button-container");
if (legacyEl) {
legacyEl.className = "sidebar-pomodoro-section-container";
pomodoroSectionContainerEl = legacyEl;
} else {
pomodoroSectionContainerEl = pomodoroContainer.createDiv("sidebar-pomodoro-section-container");
}
}
// Update Pomodoro section visibility and content
if (this.pomodoroUIManager && pomodoroContainer && pomodoroSectionContainerEl) {
this.pomodoroUIManager.attachAndRender(pomodoroSectionContainerEl);
if (this.plugin.settings.pomodoroEnabled) {
pomodoroContainer.classList.remove('sf-hidden');
this.pomodoroUIManager.showPomodoroSection(true);
this.pomodoroUIManager.updatePomodoroUI();
// Automatically calculate estimation for current notes
void this.pomodoroUIManager.calculateAndDisplayEstimation();
} else {
pomodoroContainer.classList.add('sf-hidden');
this.pomodoroUIManager.showPomodoroSection(false);
}
}
// --- Review Buttons Section (Only visible when there are notes) ---
let reviewButtonsContainer = container.querySelector<HTMLElement>(".review-buttons-container");
// Visibility of review buttons should depend on whether there are notes in the current context (notesForDisplay)
if (notesForDisplay.length > 0) {
if (!reviewButtonsContainer) {
reviewButtonsContainer = container.createDiv("review-buttons-container");
// Create all buttons once
const navButtonsContainer = reviewButtonsContainer.createDiv("review-nav-buttons");
const prevNoteBtn = navButtonsContainer.createEl("button", { text: "Previous", title: "Navigate to previous note", cls: "review-all-button" });
prevNoteBtn.addEventListener("click", () => { void this.plugin.reviewController.navigateToPreviousNote(); });
const nextNoteBtn = navButtonsContainer.createEl("button", { text: "Next", title: "Navigate to next note", cls: "review-all-button" });
nextNoteBtn.addEventListener("click", () => { void this.plugin.reviewController.navigateToNextNote(); });
const reviewCurrentBtn = reviewButtonsContainer.createEl("button", { text: "Review current note", title: "Review the currently open note if it's due", cls: "review-all-button" });
reviewCurrentBtn.addEventListener("click", () => { void this.plugin.reviewController.reviewCurrentNote(); });
const reviewAllBtn = reviewButtonsContainer.createEl("button", { text: "Review all", title: "Start reviewing all due notes", cls: "review-all-button" });
reviewAllBtn.addEventListener("click", () => { void this.plugin.reviewController.reviewAllTodaysNotes(); });
if (this.plugin.settings.enableMCQ) {
const reviewAllMCQBtn = reviewButtonsContainer.createEl("button", { text: "Review all with MCQs", cls: "review-all-mcq-button" });
reviewAllMCQBtn.addEventListener("click", () => { this.plugin.reviewController.reviewAllNotesWithMCQ(true); });
}
}
reviewButtonsContainer.classList.remove('sf-hidden');
// Bulk action buttons
let bulkActionButtons = container.querySelector<HTMLElement>(".review-bulk-actions");
if (!bulkActionButtons) {
bulkActionButtons = container.createDiv("review-bulk-actions");
// Add cumulative time display
const selectionInfo = bulkActionButtons.createDiv("review-selection-info");
selectionInfo.createSpan("review-selection-count");
selectionInfo.createSpan("review-selection-time");
const reviewSelectedBtn = bulkActionButtons.createEl("button", { text: "Review selected", cls: "review-bulk-button" });
reviewSelectedBtn.addEventListener("click", () => {
void this.plugin.reviewController.reviewNotes(this.getSelectedNotes(), false); // Use getter
this.setSelectedNotes([]);
void this.refreshSidebarView();
});
const advanceSelectedBtn = bulkActionButtons.createEl("button", { text: "Advance selected", cls: "review-bulk-button review-bulk-advance" });
advanceSelectedBtn.addEventListener("click", () => {
const pathsToAdvance = [...this.getSelectedNotes()];
if (pathsToAdvance.length === 0) {
new Notice("No notes selected to advance.");
return;
}
// The controller's advanceNotes will handle eligibility and notices
void (async () => {
await this.plugin.reviewController.advanceNotes(pathsToAdvance);
this.setSelectedNotes([]); // Clear selection after action
// advanceNotes in controller already calls savePluginData and refreshSidebarView (via updateTodayNotes)
// A direct refresh here might be redundant but ensures UI update if controller logic changes.
await this.refreshSidebarView();
})();
});
const postponeSelectedBtn = bulkActionButtons.createEl("button", { text: "Postpone selected", cls: "review-bulk-button review-bulk-postpone" });
postponeSelectedBtn.addEventListener("click", () => {
const pathsToPostpone = [...this.getSelectedNotes()];
if (pathsToPostpone.length === 0) {
new Notice("No notes selected to postpone.");
return;
}
this.setSelectedNotes([]);
void (async () => {
await this.plugin.reviewController.postponeNotes(pathsToPostpone);
// postponeNotes in controller handles save and refresh.
await this.refreshSidebarView();
// Notice is handled by controller/service for postpone.
})();
});
const removeSelectedBtn = bulkActionButtons.createEl("button", { text: "Remove selected", cls: "review-bulk-button review-bulk-remove" });
removeSelectedBtn.addEventListener("click", () => {
const pathsToRemove = [...this.getSelectedNotes()]; // Use getter
new ConfirmationModal(
this.plugin.app,
'Remove Selected Notes',
`Remove ${pathsToRemove.length} selected notes from review schedule?`,
() => {
this.setSelectedNotes([]);
void (async () => {
await this.plugin.reviewController.removeNotes(pathsToRemove);
await this.plugin.savePluginData();
await this.refreshSidebarView();
new Notice(`Removed ${pathsToRemove.length} selected notes.`);
})();
}
).open();
});
}
this.updateBulkActionButtonsVisibility(container); // Update visibility based on current selection
} else if (reviewButtonsContainer) {
reviewButtonsContainer.classList.add('sf-hidden');
const bulkActionButtons = container.querySelector<HTMLElement>(".review-bulk-actions");
if (bulkActionButtons) bulkActionButtons.toggleClass('sf-hidden', true);
}
}
private _ensureAndUpdateAllCaughtUpMessage(container: HTMLElement, dueNotesForStats: ReviewSchedule[], activeListBaseDate: Date | null): void {
let caughtUpEl = container.querySelector<HTMLElement>(".review-all-caught-up");
if (dueNotesForStats.length === 0 && !activeListBaseDate) {
if (!caughtUpEl) {
caughtUpEl = container.createDiv("review-all-caught-up");
// Insert after stats or buttons if they exist
const statsEl = container.querySelector(".review-stats-list-view");
const buttonsContainer = container.querySelector(".review-buttons-container");
const anchor = buttonsContainer || statsEl;
if (anchor && anchor.nextSibling) {
container.insertBefore(caughtUpEl, anchor.nextSibling);
} else if (anchor) {
container.appendChild(caughtUpEl);
} else {
container.prepend(caughtUpEl); // Fallback
}
}
caughtUpEl.setText("All caught up! No notes due for review.");
caughtUpEl.toggleClass('sf-hidden', false);
} else if (caughtUpEl) {
caughtUpEl.toggleClass('sf-hidden', true);
}
}
private async _ensureAndUpdateDateSections(container: HTMLElement, sortedDateKeys: string[], groupedNotes: Record<string, ReviewSchedule[]>): Promise<void> {
const existingSectionElements = Array.from(container.querySelectorAll<HTMLElement>(".review-date-section"));
const dataKeysFromData = new Set(sortedDateKeys);
let notesDisplayed = false;
// Remove stale sections
for (const sectionEl of existingSectionElements) {
const key = sectionEl.dataset.dateKey;
if (key && !dataKeysFromData.has(key)) {
sectionEl.remove();
}
}
// Update existing or create new sections
for (const dateStr of sortedDateKeys) {
const notesForSection = groupedNotes[dateStr];
if (!notesForSection || notesForSection.length === 0) continue;
notesDisplayed = true;
let dateSectionEl = container.querySelector<HTMLElement>(`.review-date-section[data-date-key="${dateStr}"]`);
let notesContainerEl: HTMLElement | null;
if (!dateSectionEl) {
dateSectionEl = container.createDiv("review-date-section");
dateSectionEl.dataset.dateKey = dateStr;
const headerRow = dateSectionEl.createDiv("review-date-header");
const headerContainer = headerRow.createDiv("review-date-header-container");
headerContainer.createEl("h3"); // Placeholder for heading
// "Advance All" button for future sections
const todayStart = DateUtils.startOfUTCDay(new Date()); // Returns timestamp
const sectionDateKeyIsFuture = !["Due notes", "Today"].includes(dateStr) &&
(notesForSection[0] && DateUtils.startOfUTCDay(new Date(notesForSection[0].nextReviewDate)) > todayStart);
if (sectionDateKeyIsFuture) {
const advanceAllBtn = headerContainer.createEl("button", { text: "Advance all", cls: "review-date-action-button review-date-advance-all" });
advanceAllBtn.title = `Advance all notes in this section by 1 day`;
advanceAllBtn.addEventListener("click", () => {
const currentNotesForSection = groupedNotes[dateStr] || [];
if (currentNotesForSection.length === 0) return;
new ConfirmationModal(
this.plugin.app,
'Advance All Notes',
`Advance all ${currentNotesForSection.length} notes from "${dateStr}" by 1 day? (Only future notes will be affected)`,
() => {
const paths = currentNotesForSection.map(note => note.path);
void this.plugin.reviewController.advanceNotes(paths);
}
).open();
});
}
const postponeAllBtn = headerContainer.createEl("button", { text: "Postpone all", cls: "review-date-action-button review-date-postpone-all" });
postponeAllBtn.title = `Postpone all notes in this section by 1 day`;
postponeAllBtn.addEventListener("click", () => {
const currentNotesForSection = groupedNotes[dateStr] || [];
if (currentNotesForSection.length === 0) return;
const daysToPostpone = 1;
new ConfirmationModal(
this.plugin.app,
'Postpone All Notes',
`Postpone all ${currentNotesForSection.length} notes from "${dateStr}" by ${daysToPostpone} day(s)?`,
() => {
const paths = currentNotesForSection.map(note => note.path);
void this.plugin.reviewController.postponeNotes(paths, daysToPostpone);
}
).open();
});
headerRow.createSpan("review-date-time"); // Placeholder for time
notesContainerEl = dateSectionEl.createDiv("review-notes-container");
} else {
notesContainerEl = dateSectionEl.querySelector<HTMLElement>(".review-notes-container");
if (!notesContainerEl) { // Should not happen if structure is consistent
notesContainerEl = dateSectionEl.createDiv("review-notes-container");
}
}
// Update header content
dateSectionEl.removeClass("review-date-section-overdue");
// const isDefaultTodayView = !this.getActiveListBaseDate(); // Unused
// A section is considered "overdue" if its category key is "Due notes".
// This applies whether in default view (actual overdue notes)
// or when viewing a past date from calendar (where "Due notes" might be the category for that day's notes).
if (dateStr === "Due notes") {
dateSectionEl.addClass("review-date-section-overdue");
}
const headerContainer = dateSectionEl.querySelector<HTMLElement>(".review-date-header-container");
const dateHeading = headerContainer?.querySelector<HTMLElement>("h3");
const reviewTimeEl = dateSectionEl.querySelector<HTMLElement>(".review-date-time");
if (!headerContainer || !dateHeading) continue;
let displayHeader = dateStr;
const noteCountText = `${notesForSection.length} ${notesForSection.length === 1 ? 'note' : 'notes'}`;
if (dateStr !== "Due notes" && notesForSection.length > 0) {
const actualGroupSampleDate = new Date(notesForSection[0].nextReviewDate);
const formattedActualDate = actualGroupSampleDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
if (["Today", "Tomorrow"].includes(dateStr) || dateStr.startsWith("In ")) {
displayHeader = `${dateStr} (${formattedActualDate})`;
}
displayHeader += ` - ${noteCountText}`;
} else {
displayHeader = `${dateStr} - ${noteCountText}`;
}
dateHeading.setText(displayHeader);
let overdueBadge = dateHeading.querySelector<HTMLElement>(".review-overdue-badge");
const todayActualStart = DateUtils.startOfUTCDay(); // Timestamp for actual today's midnight
// Condition for showing overdue badge:
// The overdue badge should only show for the "Due notes" section.
const shouldShowOverdueBadge = (dateStr === "Due notes");
if (shouldShowOverdueBadge) {
// If the section is "Due notes", all notes within it are candidates for the overdue calculation.
const overdueNotesInThisSection = notesForSection;
if (overdueNotesInThisSection.length > 0) {
const daysDiff = overdueNotesInThisSection.map(note => {
// When dateStr is "Due notes":
// - If in default view (isDefaultTodayView = true), overdue is relative to actual today.
// - If viewing a specific past date from calendar (isDefaultTodayView = false),
// "Due notes" might be the category for notes *on* that day.
// In this case, overdue days relative to that selected past date will be 0.
const baseDate = this.getActiveListBaseDate();
const referenceDateForDiff = baseDate ? DateUtils.startOfUTCDay(baseDate) : todayActualStart;
return Math.floor((referenceDateForDiff - new Date(note.nextReviewDate).getTime()) / (24 * 60 * 60 * 1000));
});
const maxDays = Math.max(0, ...daysDiff.filter(d => d >= 0 && !isNaN(d))); // Ensure positive days and filter NaN
if (maxDays > 0) {
if (!overdueBadge) {
overdueBadge = dateHeading.createSpan("review-overdue-badge sf-overdue-badge");
}
overdueBadge.setText(` (${maxDays} ${maxDays === 1 ? 'day' : 'days'} overdue)`);
overdueBadge.toggleClass('sf-hidden', false);
} else if (overdueBadge) {
overdueBadge.toggleClass('sf-hidden', true); // No positive overdue days
}
} else if (overdueBadge) {
overdueBadge.toggleClass('sf-hidden', true); // No overdue notes in section
}
} else if (overdueBadge) {
overdueBadge.toggleClass('sf-hidden', true); // Conditions for badge not met
}
let sectionTime = 0;
for (const note of notesForSection) { sectionTime += await this.plugin.reviewScheduleService.estimateReviewTime(note.path); }
if (reviewTimeEl) reviewTimeEl.setText(`(${EstimationUtils.formatTime(sectionTime)})`);
await this._updateOrRenderNoteList(notesContainerEl, notesForSection, dateStr, container);
}
// Message for activeListBaseDate with no notes
let noNotesForDateMsg = container.querySelector<HTMLElement>(".review-no-notes-for-date");
const activeListBaseDate = this.getActiveListBaseDate();
if (activeListBaseDate && !notesDisplayed) {
if (!noNotesForDateMsg) {
noNotesForDateMsg = container.createDiv("review-no-notes-for-date"); // Similar to all-caught-up
}
noNotesForDateMsg.setText(`No notes scheduled on or after ${activeListBaseDate.toLocaleDateString()}.`);
noNotesForDateMsg.toggleClass('sf-hidden', false);
} else if (noNotesForDateMsg) {
noNotesForDateMsg.toggleClass('sf-hidden', true);
}
}
private _ensureAndUpdateActiveSessionSection(container: HTMLElement): void {
const activeSession = this.plugin.reviewSessionService.getActiveSession();
let sessionSection = container.querySelector<HTMLElement>(".review-session-section");
if (activeSession) {
if (!sessionSection) {
sessionSection = container.createDiv("review-session-section");
new Setting(sessionSection).setHeading().setName("Active review session");
const sessionInfo = sessionSection.createDiv("review-session-info");
sessionInfo.createDiv({ cls: "review-session-name" });
sessionInfo.createDiv({ cls: "review-session-progress" });
const progressBarContainer = sessionInfo.createDiv("review-session-progress-bar-container");
progressBarContainer.createDiv("review-session-progress-bar");
const continueBtn = sessionSection.createEl("button", { text: "Continue session", cls: "review-session-continue" });
continueBtn.addEventListener("click", () => { /* ... continue logic ... */ }); // TODO: Implement continue logic if not already present
const endBtn = sessionSection.createEl("button", { text: "End session", cls: "review-session-end" });
endBtn.addEventListener("click", () => {
this.plugin.reviewSessionService.setActiveSession(null);
void this.refreshSidebarView();
});
}
sessionSection.toggleClass('sf-hidden', false);
const nameEl = sessionSection.querySelector<HTMLElement>(".review-session-name");
if (nameEl) nameEl.setText(activeSession.name);
const progressEl = sessionSection.querySelector<HTMLElement>(".review-session-progress");
if (progressEl) progressEl.setText(`Progress: ${activeSession.currentIndex}/${activeSession.hierarchy.traversalOrder.length}`);
const progressBar = sessionSection.querySelector<HTMLElement>(".review-session-progress-bar");
if (progressBar) {
const progressPercent = Math.min(100, Math.round((activeSession.currentIndex / activeSession.hierarchy.traversalOrder.length) * 100));
progressBar.style.width = `${progressPercent}%`;
}
} else if (sessionSection) {
sessionSection.toggleClass('sf-hidden', true);
}
}
private async _ensureAndUpdateUpcomingReviewsSection(container: HTMLElement): Promise<void> {
const allSchedules = Object.values(this.plugin.reviewScheduleService.schedules);
const upcomingGroupedNotes = await this.groupNotesByDate(allSchedules, true);
const upcomingKeys = this.getSortedDateKeys(upcomingGroupedNotes)
.filter(key => {
if (key === 'Due notes') return false;
const actualTodayStart = DateUtils.startOfUTCDay(new Date());
if (key === DateUtils.formatDate(actualTodayStart, 'relative', null)) return false; // Exclude "Today" if it's empty or handled by main list
if (key === DateUtils.formatDate(DateUtils.addDays(actualTodayStart, 1), 'relative', null)) return false; // Exclude "Tomorrow"
return true;
});
let upcomingSection = container.querySelector<HTMLElement>(".review-upcoming-section");
if (upcomingKeys.length > 0) {
if (!upcomingSection) {
upcomingSection = container.createDiv("review-upcoming-section");
new Setting(upcomingSection).setHeading().setName("Upcoming reviews");
upcomingSection.createDiv("review-upcoming-list"); // List container
}
upcomingSection.toggleClass('sf-hidden', false);
const upcomingListEl = upcomingSection.querySelector<HTMLElement>(".review-upcoming-list");
if (!upcomingListEl) return; // Should not happen
const existingDayItemElements = Array.from(upcomingListEl.querySelectorAll<HTMLElement>(".review-upcoming-day"));
// Remove stale day items
for (const dayItemEl of existingDayItemElements) {
const key = dayItemEl.dataset.dayKey;
if (key && !upcomingKeys.includes(key)) {
dayItemEl.remove();
}
}
// Update or create day items
for (const dayKey of upcomingKeys) {
const notesForDay = upcomingGroupedNotes[dayKey];
if (!notesForDay || notesForDay.length === 0) {
// Ensure any existing DOM element for this empty dayKey is removed
const staleEmptyDayItem = upcomingListEl.querySelector(`.review-upcoming-day[data-day-key="${dayKey}"]`);
if (staleEmptyDayItem) staleEmptyDayItem.remove();
continue;
}
let dayItemEl = upcomingListEl.querySelector<HTMLElement>(`.review-upcoming-day[data-day-key="${dayKey}"]`);
if (!dayItemEl) {
dayItemEl = upcomingListEl.createDiv("review-upcoming-day");
dayItemEl.addClass("clickable");
dayItemEl.dataset.dayKey = dayKey;
const daySummary = dayItemEl.createDiv("review-upcoming-day-summary");
daySummary.createSpan({ cls: "review-upcoming-day-name" }); // Placeholder for name
dayItemEl.addEventListener("click", (e) => { // Attach listener once
const currentDayKey = (e.currentTarget as HTMLElement).dataset.dayKey;
if (!currentDayKey) return;
const expandedUpcomingDayKey = this.getExpandedUpcomingDayKey();
const isCurrentlyExpanded = expandedUpcomingDayKey === currentDayKey;
this.setExpandedUpcomingDayKey(isCurrentlyExpanded ? null : currentDayKey);
// Visual state and note list rendering will be handled by the main render pass
void this.refreshSidebarView(); // Trigger a re-render to reflect expansion
});
}
// Update summary
const daySummaryNameEl = dayItemEl.querySelector<HTMLElement>(".review-upcoming-day-summary .review-upcoming-day-name");
let upcomingDisplayHeader = dayKey;
if (notesForDay.length > 0) { // Should always be true here
const sampleUpcomingDate = new Date(notesForDay[0].nextReviewDate);
const formattedUpcomingDate = DateUtils.formatDate(sampleUpcomingDate.getTime(), 'medium');
if (["Today", "Tomorrow"].includes(dayKey) || dayKey.startsWith("In ")) {
upcomingDisplayHeader = `${dayKey} (${formattedUpcomingDate})`;
} else {
upcomingDisplayHeader = formattedUpcomingDate;
}
}
if (daySummaryNameEl) daySummaryNameEl.setText(`${upcomingDisplayHeader}: ${notesForDay.length} ${notesForDay.length === 1 ? 'note' : 'notes'}`);
// Handle expanded state
const isExpanded = this.getExpandedUpcomingDayKey() === dayKey;
dayItemEl.classList.toggle("is-expanded", isExpanded);
let notesContainerEl = dayItemEl.querySelector<HTMLElement>(".review-upcoming-notes-container");
if (isExpanded) {
if (!notesContainerEl) {
notesContainerEl = dayItemEl.createDiv("review-upcoming-notes-container");
}
notesContainerEl.toggleClass('sf-hidden', false);
await this._updateOrRenderNoteList(notesContainerEl, notesForDay, dayKey, container);
} else if (notesContainerEl) {
notesContainerEl.toggleClass('sf-hidden', true); // Hide instead of removing
}
}
} else if (upcomingSection) {
upcomingSection.toggleClass('sf-hidden', true);
}
}
/**
* Renders or updates a list of notes within a given container.
*/
private async _updateOrRenderNoteList(
notesContainer: HTMLElement,
notes: ReviewSchedule[],
dateStr: string,
parentContainerForBulkActions: HTMLElement
): Promise<void> {
const existingNoteElements = Array.from(notesContainer.querySelectorAll<HTMLElement>('.review-note-item[data-note-path]'));
const existingNotesMap = new Map(existingNoteElements.map(el => [el.dataset.notePath, el]));
const notesInOrder: HTMLElement[] = [];
const lastSelectedNotePath = this.getLastSelectedNotePath();
const lastSelectedNotePathRef = { current: lastSelectedNotePath };
for (const note of notes) {
let noteEl = existingNotesMap.get(note.path);
if (noteEl) {
await this.noteItemRenderer.updateNoteItem(
noteEl,
note,
dateStr,
this.getSelectedNotes()
);
existingNotesMap.delete(note.path); // Mark as processed
} else {
noteEl = await this.noteItemRenderer.renderNoteItem(
notesContainer, // Temporarily append here, will be reordered
note,
dateStr,
parentContainerForBulkActions,
this.getSelectedNotes(),
lastSelectedNotePathRef,
() => this.handleSelectionChange(parentContainerForBulkActions),
async () => { await this.handleNoteAction(); }
);
}
if (noteEl) notesInOrder.push(noteEl);
}
// Remove stale notes
for (const staleNoteEl of existingNotesMap.values()) {
staleNoteEl.remove();
}
// Ensure correct order
notesContainer.empty(); // Clear container before re-appending in correct order
for (const noteEl of notesInOrder) {
notesContainer.appendChild(noteEl);
}
this.setLastSelectedNotePath(lastSelectedNotePathRef.current);
}
/**
* Callback function passed to NoteItemRenderer to handle UI updates after selection changes.
*
* @param container The container element
*/
private handleSelectionChange(container: HTMLElement): void {
this.updateSelectionClasses(container);
this.updateBulkActionButtonsVisibility(container);
void this.updateSelectionInfo(container);
}
/**
* Callback function passed to NoteItemRenderer for actions (like postpone, remove)
* that require a broader UI update (potentially a full refresh or targeted updates).
*/
private async handleNoteAction(): Promise<void> {
await this.refreshSidebarView();
}
/**
* Updates the 'selected' class on note items based on the selectedNotes array.
* (Called by handleSelectionChange)
*/
private updateSelectionClasses(container: HTMLElement): void {
const selectedNotes = this.getSelectedNotes();
const allNoteElements = container.querySelectorAll<HTMLElement>('.review-note-item[data-note-path]');
allNoteElements.forEach(el => {
const path = el.dataset.notePath;
if (path && selectedNotes.includes(path)) {
el.classList.add('selected');
} else {
el.classList.remove('selected');
}
});
}
/**
* Updates the selection info display with count and cumulative time.
* (Called by handleSelectionChange)
*/
private async updateSelectionInfo(container: HTMLElement): Promise<void> {
const selectedNotesPaths = this.getSelectedNotes();
const bulkActionsContainer = container.querySelector<HTMLElement>('.review-bulk-actions');
if (bulkActionsContainer && selectedNotesPaths.length > 0) {
let selectionInfo = bulkActionsContainer.querySelector<HTMLElement>('.review-selection-info');
if (!selectionInfo) {
selectionInfo = bulkActionsContainer.createDiv('review-selection-info');
selectionInfo.createSpan('review-selection-count');
selectionInfo.createSpan('review-selection-time');
}
const countSpan = selectionInfo.querySelector('.review-selection-count');
const timeSpan = selectionInfo.querySelector('.review-selection-time');
if (countSpan && timeSpan) {
// Update count
countSpan.setText(`${selectedNotesPaths.length} ${selectedNotesPaths.length === 1 ? 'note' : 'notes'} selected`);
// Calculate cumulative time
let totalTime = 0;
for (const path of selectedNotesPaths) {
totalTime += await this.plugin.reviewScheduleService.estimateReviewTime(path);
}
// Update time display
timeSpan.setText(`(${EstimationUtils.formatTime(totalTime)})`);
}
}
}
/**
* Updates the visibility of the bulk action buttons based on selection count.
* (Called by handleSelectionChange and render)
*/
private updateBulkActionButtonsVisibility(container: HTMLElement): void {
const selectedNotesPaths = this.getSelectedNotes();
const bulkActionsContainer = container.querySelector<HTMLElement>('.review-bulk-actions');
if (bulkActionsContainer) {
bulkActionsContainer.toggleClass('sf-hidden', selectedNotesPaths.length <= 1);
// Handle visibility/disabled state of "Advance Selected" button
const advanceSelectedBtn = bulkActionsContainer.querySelector<HTMLButtonElement>('.review-bulk-advance');
if (advanceSelectedBtn) {
if (selectedNotesPaths.length > 1) {
const todayStart = DateUtils.startOfUTCDay(new Date()); // Returns timestamp
const hasEligibleFutureNote = selectedNotesPaths.some(path => {
const schedule = this.plugin.reviewScheduleService.schedules[path];
return schedule && DateUtils.startOfUTCDay(new Date(schedule.nextReviewDate)) > todayStart;
});
advanceSelectedBtn.disabled = !hasEligibleFutureNote;
advanceSelectedBtn.toggleClass('sf-hidden', false); // Always show if bulk actions are visible, rely on disabled state
} else {
advanceSelectedBtn.disabled = true;
// advanceSelectedBtn.style.display = 'none'; // Or hide if no selection
}
}
}
}
/**
* Setup drag-and-drop event handlers for the container
*/
private setupDragAndDrop(container: HTMLElement): void {
// Track the current drag target for visual feedback
let currentDragTarget: HTMLElement | null = null;
// Add drag-over event handler - only prevent default to allow drop
container.addEventListener('dragover', (e) => {
// Just allow the drop, don't do expensive calculations here
e.preventDefault();
});
// Add drop event handler
container.addEventListener('drop', (e) => {
e.preventDefault();
void (async () => {
const draggedPath = e.dataTransfer?.getData('text/plain');
if (!draggedPath) return;
// Find the drop target note item
const target = e.target as HTMLElement;
const noteItem = target.closest<HTMLElement>('.review-note-item');
if (!noteItem) return;
const targetPath = noteItem.dataset.notePath;
if (!targetPath || draggedPath === targetPath) return;
// Get due notes to initialize custom order if needed
const dueNotes = this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true);
// Initialize custom order if empty - use current display order
let currentOrder = [...this.plugin.reviewScheduleService.customNoteOrder];
if (currentOrder.length === 0) {
// Use the order of currently displayed due notes
currentOrder = dueNotes.map(n => n.path);
}
// Ensure both paths are in the order
if (!currentOrder.includes(draggedPath)) {
currentOrder.push(draggedPath);
}
if (!currentOrder.includes(targetPath)) {
currentOrder.push(targetPath);
}
// Find positions
const draggedIndex = currentOrder.indexOf(draggedPath);
const targetIndex = currentOrder.indexOf(targetPath);
if (draggedIndex === -1 || targetIndex === -1) return;
// Remove dragged from current position
currentOrder.splice(draggedIndex, 1);
// Calculate insert position based on mouse Y relative to target
const rect = noteItem.getBoundingClientRect();
const insertIndex = e.clientY < rect.top + rect.height / 2
? targetIndex
: targetIndex + 1;
// Insert at new position
currentOrder.splice(insertIndex, 0, draggedPath);
// Update and save
await this.plugin.reviewScheduleService.updateCustomNoteOrder(currentOrder);
await this.plugin.savePluginData();
// Refresh view
await this.refreshSidebarView();
})();
});
// Add drag-enter for visual feedback
container.addEventListener('dragenter', (e) => {
const target = e.target as HTMLElement;
const noteItem = target.closest('.review-note-item') as HTMLElement | null;
if (noteItem && noteItem !== currentDragTarget) {
// Clear previous target
if (currentDragTarget) {
currentDragTarget.classList.remove('drag-over');
}
// Set new target
currentDragTarget = noteItem;
noteItem.classList.add('drag-over');
}
});
// Add drag-leave event handler
container.addEventListener('dragleave', (e) => {
const target = e.target as HTMLElement;
const noteItem = target.closest('.review-note-item') as HTMLElement | null;
if (noteItem && noteItem === currentDragTarget) {
// Check if we're actually leaving the element, not just entering a child
const relatedTarget = e.relatedTarget as HTMLElement;
if (!noteItem.contains(relatedTarget)) {
noteItem.classList.remove('drag-over');
currentDragTarget = null;
}
}
});
// Clean up on drag end
container.addEventListener('dragend', () => {
if (currentDragTarget) {
currentDragTarget.classList.remove('drag-over');
currentDragTarget = null;
}
// Clear all drag-over classes as safety
container.querySelectorAll('.review-note-item.drag-over').forEach(el => {
el.classList.remove('drag-over');
});
});
}
/**
* Updates the main header statistics display.
*/
private async updateHeaderStats(container: HTMLElement): Promise<void> {
const headerStatsEl = container.querySelector('.review-stats-list-view .review-stats-count');
if (!headerStatsEl) return;
const dueNotesForStats = this.plugin.reviewScheduleService.getDueNotesWithCustomOrder(Date.now(), true);
const overdueNotes = dueNotesForStats.filter(note => note.nextReviewDate < DateUtils.startOfUTCDay());
let totalTime = 0;
for (const note of dueNotesForStats) {
totalTime += await this.plugin.reviewScheduleService.estimateReviewTime(note.path);
}
const statsText = `${dueNotesForStats.length} notes - ${EstimationUtils.formatTime(totalTime)}${overdueNotes.length > 0 ? ` (${overdueNotes.length} overdue)` : ''}`;
headerStatsEl.textContent = statsText;
const reviewButtonsContainer = container.querySelector('.review-buttons-container');
if (reviewButtonsContainer) {
(reviewButtonsContainer as HTMLElement).toggleClass('sf-hidden', dueNotesForStats.length === 0);
}
this.updateBulkActionButtonsVisibility(container);
const allCaughtUpEl = container.querySelector('.review-all-caught-up');
// Use notesForDisplay (which reflects the current date context) for "All caught up" message
const notesInCurrentContext = this.plugin.reviewController.getTodayNotes();
if (allCaughtUpEl) {
(allCaughtUpEl as HTMLElement).toggleClass('sf-hidden', notesInCurrentContext.length !== 0);
if (notesInCurrentContext.length === 0) {
allCaughtUpEl.setText(this.getActiveListBaseDate() ? "No notes for selected date." : "All caught up! No notes due for review.");
}
} else if (notesInCurrentContext.length === 0) {
const buttonsContainer = container.querySelector('.review-buttons-container');
const newCaughtUpEl = container.createDiv("review-all-caught-up");
newCaughtUpEl.setText(this.getActiveListBaseDate() ? "No notes for selected date." : "All caught up! No notes due for review.");
// Insert after stats or buttons if they exist
// const statsEl = container.querySelector(".review-stats-list-view"); // Stats section is removed
const anchor = buttonsContainer; // || statsEl; // Stats section removed
if (anchor && anchor.nextSibling) {
container.insertBefore(newCaughtUpEl, anchor.nextSibling);
} else if (anchor) {
container.appendChild(newCaughtUpEl);
} else {
container.prepend(newCaughtUpEl); // Fallback
}
}
}
/**
* Updates the count and estimated time for a specific date section header, or removes the section if empty.
*/
private async updateSectionCounts(sectionEl: HTMLElement, container: HTMLElement): Promise<void> {
if (!sectionEl || !container || !sectionEl.parentElement) return;
const notesInSection = Array.from(sectionEl.querySelectorAll('.review-note-item[data-note-path]'));
const count = notesInSection.length;
if (count === 0) {
sectionEl.remove();
} else {
const headerTextEl = sectionEl.querySelector('.review-date-header-container h3');
const timeEl = sectionEl.querySelector('.review-date-time');
const dateStr = sectionEl.dataset.dateKey;
if (headerTextEl && timeEl && dateStr) {
let sectionTime = 0;
for (const noteEl of notesInSection) {
const path = (noteEl as HTMLElement).dataset.notePath;
if (path) {
sectionTime += await this.plugin.reviewScheduleService.estimateReviewTime(path);
}
}
timeEl.setText(`(${EstimationUtils.formatTime(sectionTime)})`);
let displayHeader = dateStr;
const noteCountText = `${count} ${count === 1 ? 'note' : 'notes'}`;
const firstNotePath = (notesInSection[0] as HTMLElement).dataset.notePath;
const schedule = firstNotePath ? this.plugin.reviewScheduleService.schedules[firstNotePath] : null;
if (dateStr !== "Due notes" && schedule) {
const actualGroupSampleDate = new Date(schedule.nextReviewDate);
const formattedActualDate = actualGroupSampleDate.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
if (["Today", "Tomorrow"].includes(dateStr) || dateStr.startsWith("In ")) {
displayHeader = `${dateStr} (${formattedActualDate})`;
}
displayHeader += ` - ${noteCountText}`;
} else {
displayHeader = `${dateStr} - ${noteCountText}`;
}
headerTextEl.textContent = displayHeader;
let overdueBadge = headerTextEl.querySelector(".review-overdue-badge");
if (dateStr === "Due notes") {
const daysDiff = notesInSection.map(noteEl => {
const path = (noteEl as HTMLElement).dataset.notePath;
const noteSchedule = path ? this.plugin.reviewScheduleService.schedules[path] : null;
return noteSchedule ? Math.abs(Math.floor((noteSchedule.nextReviewDate - DateUtils.startOfUTCDay()) / (24 * 60 * 60 * 1000))) : 0;
});
const maxDays = Math.max(0, ...daysDiff.filter(d => !isNaN(d)));
if (maxDays > 0) {
const badgeText = ` (${maxDays} ${maxDays === 1 ? 'day' : 'days'} overdue)`;
if (!overdueBadge) {
overdueBadge = headerTextEl.createSpan("review-overdue-badge sf-overdue-badge");
}
overdueBadge.setText(badgeText);
} else if (overdueBadge) {
overdueBadge.remove();
}
} else if (overdueBadge) {
overdueBadge.remove();
}
}
}
await this.updateHeaderStats(container);
}
/**
* Updates counts for all date sections currently in the DOM.
*/
private async updateAllSectionCounts(container: HTMLElement): Promise<void> {
const sections = container.querySelectorAll('.review-date-section');
for (const section of Array.from(sections)) {
await this.updateSectionCounts(section as HTMLElement, container);
}
await this.updateHeaderStats(container);
}
/**
* Group notes by their review date, considering activeListBaseDate.
*/
groupNotesByDate(notes: ReviewSchedule[], _includeFuture = false): Record<string, ReviewSchedule[]> {
const grouped: Record<string, ReviewSchedule[]> = {};
const actualTodayStart = DateUtils.startOfUTCDay(new Date());
const activeListBaseDate = this.getActiveListBaseDate();
const refDateForFilteringStart = activeListBaseDate ? DateUtils.startOfUTCDay(new Date(activeListBaseDate)) : actualTodayStart;
for (const note of notes) {
const noteDate = new Date(note.nextReviewDate);
const noteDateStart = DateUtils.startOfUTCDay(noteDate); // Timestamp for note's due day midnight (UTC)
if (activeListBaseDate) {
// When a specific date is selected from the calendar (activeListBaseDate is set),
// only include notes whose due day (UTC midnight) matches the selected day (UTC midnight).
if (noteDateStart !== refDateForFilteringStart) {
continue;
}
}
// For the default view (activeListBaseDate is null), notesToGroup (derived from controller's todayNotes)
// already contains all notes due up to and including today.
// No further date-based filtering is needed here for the default view.
// Determine the group string.
// If activeListBaseDate is set, format relative to that date.
// If activeListBaseDate is null (default view), format relative to actual current time.
const dateStr = DateUtils.formatDate(note.nextReviewDate, 'relative', activeListBaseDate);
// Overdue notes will now naturally be grouped under "Due notes" (or similar key from DateUtils)
// by the dateStr determined above, rather than being merged into "Today".
if (!grouped[dateStr]) {
grouped[dateStr] = [];
}
grouped[dateStr].push(note);
}
return grouped;
}
/**
* Get sorted date keys in the preferred display order: Due notes, Today, Tomorrow, future dates.
*/
getSortedDateKeys(groupedNotes: Record<string, ReviewSchedule[]>): string[] {
const keys = Object.keys(groupedNotes);
const dateOrder: Record<string, number> = { 'Due notes': 0, 'Today': 1, 'Tomorrow': 2 };
return keys.sort((a, b) => {
const aIsSpecial = a in dateOrder;
const bIsSpecial = b in dateOrder;
if (aIsSpecial && bIsSpecial) return dateOrder[a] - dateOrder[b];
if (aIsSpecial) return -1;
if (bIsSpecial) return 1;
const numAMatch = a.match(/^In (\d+) days$/);
const numBMatch = b.match(/^In (\d+) days$/);
if (numAMatch && numBMatch) return parseInt(numAMatch[1]) - parseInt(numBMatch[1]);
if (numAMatch) return -1;
if (numBMatch) return 1;
return a.localeCompare(b);
});
}
}