feat(kanban): add cycle selector and Other column for unmatched tasks

- Add cycle selector button in kanban controls to filter by specific status cycle
- Display 'Other' column for tasks not in selected cycle
- Prevent status updates when dragging to Other column (returns null)
- Save cycle selection to localStorage per view
- Add getUnmatchedTasks() to collect tasks outside current cycle
- Update getTasksForStatus() to respect cycle selection
- Add getStatusMarkForColumn() to handle Other column drag prevention
- Add translations for cycle selector UI (en, zh-cn)
- Update kanban styles for cycle selector button
- Update Fluent UI components and styles
- Update status switcher and task grouping logic
- Update read mode text mark handling
This commit is contained in:
Quorafind 2025-11-18 22:11:13 +08:00
parent bf36bf2ff8
commit 36d834091a
12 changed files with 5680 additions and 4077 deletions

View file

@ -24,7 +24,7 @@ export interface CustomNavButton {
export function isCompletedMark(
plugin: TaskProgressBarPlugin,
mark: string
mark: string,
): boolean {
if (!mark) return false;
try {
@ -48,7 +48,13 @@ export class TopNavigation extends Component {
private availableModes: ViewMode[] = ["list", "kanban", "tree", "calendar"];
private viewTabsContainer: HTMLElement | null = null;
private customButtonsContainer: HTMLElement | null = null;
private customButtons: Map<string, { buttonEl: HTMLElement; config: CustomNavButton }> = new Map();
private customButtons: Map<
string,
{ buttonEl: HTMLElement; config: CustomNavButton }
> = new Map();
private cycleSelectorContainer: HTMLElement | null = null;
private selectedCycleId: string | null = null;
private onCycleChange: ((cycleId: string | null) => void) | null = null;
constructor(
containerEl: HTMLElement,
@ -59,7 +65,7 @@ export class TopNavigation extends Component {
private onSortClick: () => void,
private onSettingsClick: () => void,
availableModes?: ViewMode[],
private onToggleSidebar?: () => void
private onToggleSidebar?: () => void,
) {
super();
this.containerEl = containerEl;
@ -87,14 +93,14 @@ export class TopNavigation extends Component {
this.registerEvent(
on(this.plugin.app, Events.CACHE_READY, () => {
this.updateNotificationCount();
})
}),
);
// Listen for task cache updates
this.registerEvent(
on(this.plugin.app, Events.TASK_CACHE_UPDATED, () => {
this.updateNotificationCount();
})
}),
);
}
@ -151,14 +157,14 @@ export class TopNavigation extends Component {
today.setHours(0, 0, 0, 0);
this.notificationCount = tasks.filter((task: Task) =>
this.isOverdueTask(task, today)
this.isOverdueTask(task, today),
).length;
this.updateNotificationBadge();
} catch (error) {
console.warn(
"[FluentTopNavigation] Failed to update notification count:",
error
error,
);
}
}
@ -170,9 +176,12 @@ export class TopNavigation extends Component {
// Hide entire navigation if no view modes are available
if (this.availableModes.length === 0) {
this.containerEl.hide();
this.containerEl.toggleClass("other-view", true);
return;
}
this.containerEl.toggleClass("other-view", false);
// Show navigation when modes are available
this.containerEl.show();
@ -212,6 +221,12 @@ export class TopNavigation extends Component {
cls: "fluent-nav-custom-buttons",
});
// Cycle selector container (hidden by default)
this.cycleSelectorContainer = rightSection.createDiv({
cls: "fluent-nav-cycle-selector-wrapper",
});
this.cycleSelectorContainer.hide();
// Notification button
const notificationBtn = rightSection.createDiv({
cls: "fluent-nav-icon-button",
@ -227,7 +242,7 @@ export class TopNavigation extends Component {
badge.show();
}
this.registerDomEvent(notificationBtn, "click", (e) =>
this.showNotifications(e)
this.showNotifications(e),
);
// Settings button
@ -236,7 +251,7 @@ export class TopNavigation extends Component {
});
setIcon(settingsBtn, "settings");
this.registerDomEvent(settingsBtn, "click", () =>
this.onSettingsClick()
this.onSettingsClick(),
);
}
@ -244,7 +259,7 @@ export class TopNavigation extends Component {
container: HTMLElement,
mode: ViewMode,
icon: string,
label: string
label: string,
) {
const tab = container.createEl("button", {
cls: ["fluent-view-tab", "clickable-icon"],
@ -275,7 +290,7 @@ export class TopNavigation extends Component {
});
const activeTab = this.containerEl.querySelector(
`[data-mode="${mode}"]`
`[data-mode="${mode}"]`,
);
if (activeTab) {
activeTab.addClass("is-active");
@ -296,7 +311,7 @@ export class TopNavigation extends Component {
today.setHours(0, 0, 0, 0);
const overdueTasks = tasks.filter((task: Task) =>
this.isOverdueTask(task, today)
this.isOverdueTask(task, today),
);
if (overdueTasks.length === 0) {
@ -306,7 +321,7 @@ export class TopNavigation extends Component {
} else {
menu.addItem((item) => {
item.setTitle(
`${overdueTasks.length} overdue tasks`
`${overdueTasks.length} overdue tasks`,
).setDisabled(true);
});
@ -340,7 +355,7 @@ export class TopNavigation extends Component {
private updateNotificationBadge() {
const badge = this.containerEl.querySelector(
".fluent-notification-badge"
".fluent-notification-badge",
);
if (badge instanceof HTMLElement) {
badge.textContent = String(this.notificationCount);
@ -386,7 +401,7 @@ export class TopNavigation extends Component {
this.viewTabsContainer,
mode,
config.icon,
config.label
config.label,
);
}
}
@ -399,9 +414,12 @@ export class TopNavigation extends Component {
// Hide entire navigation if no modes available
if (modes.length === 0) {
this.containerEl.hide();
this.containerEl.toggleClass("other-view", true);
return;
}
this.containerEl.toggleClass("other-view", false);
// If transitioning from empty to non-empty, need to re-render entire UI
if (wasEmpty && modes.length > 0) {
this.render();
@ -420,7 +438,7 @@ export class TopNavigation extends Component {
// Update center section visibility (this should always be visible now since we handle empty modes above)
const centerSection = this.containerEl.querySelector(
".fluent-nav-center"
".fluent-nav-center",
) as HTMLElement;
if (centerSection) {
centerSection.show();
@ -443,13 +461,17 @@ export class TopNavigation extends Component {
*/
public registerCustomButton(config: CustomNavButton): void {
if (!this.customButtonsContainer) {
console.warn("[FluentTopNavigation] Custom buttons container not initialized");
console.warn(
"[FluentTopNavigation] Custom buttons container not initialized",
);
return;
}
// Check if button already exists
if (this.customButtons.has(config.id)) {
console.warn(`[FluentTopNavigation] Button with id "${config.id}" already registered`);
console.warn(
`[FluentTopNavigation] Button with id "${config.id}" already registered`,
);
return;
}
@ -470,7 +492,9 @@ export class TopNavigation extends Component {
config: config,
});
console.log(`[FluentTopNavigation] Registered custom button: ${config.id}`);
console.log(
`[FluentTopNavigation] Registered custom button: ${config.id}`,
);
}
/**
@ -509,4 +533,165 @@ export class TopNavigation extends Component {
console.log("[FluentTopNavigation] Cleared all custom buttons");
}
/**
* Set the callback to be called when cycle selection changes
*/
public setCycleChangeCallback(
callback: (cycleId: string | null) => void,
): void {
this.onCycleChange = callback;
}
/**
* Show the cycle selector in the navigation bar
* @param selectedCycleId - Currently selected cycle ID (null for "All Cycles")
*/
public showCycleSelector(selectedCycleId: string | null = null): void {
if (!this.cycleSelectorContainer) {
console.warn(
"[FluentTopNavigation] Cycle selector container not initialized",
);
return;
}
this.selectedCycleId = selectedCycleId;
this.renderCycleSelector();
this.cycleSelectorContainer.show();
}
/**
* Hide the cycle selector from the navigation bar
*/
public hideCycleSelector(): void {
if (this.cycleSelectorContainer) {
this.cycleSelectorContainer.hide();
this.cycleSelectorContainer.empty();
}
}
/**
* Update the selected cycle ID and refresh the UI
*/
public setSelectedCycleId(cycleId: string | null): void {
this.selectedCycleId = cycleId;
if (
this.cycleSelectorContainer &&
this.cycleSelectorContainer.isShown()
) {
this.renderCycleSelector();
}
}
/**
* Get the currently selected cycle ID
*/
public getSelectedCycleId(): string | null {
return this.selectedCycleId;
}
/**
* Render the cycle selector dropdown button
*/
private renderCycleSelector(): void {
if (!this.cycleSelectorContainer) {
return;
}
this.cycleSelectorContainer.empty();
// Create cycle selector button
const cycleButton = this.cycleSelectorContainer.createEl("button", {
cls: "fluent-nav-cycle-button clickable-icon",
attr: {
"aria-label": t("kanban.cycleSelector"),
},
});
// Add icon
const iconDiv = cycleButton.createDiv({ cls: "fluent-nav-cycle-icon" });
setIcon(iconDiv, "layers");
// Add label
const labelSpan = cycleButton.createSpan({
cls: "fluent-nav-cycle-label",
});
// Set label based on selected cycle
if (this.selectedCycleId) {
const selectedCycle = (
this.plugin.settings.statusCycles || []
).find((c) => c.id === this.selectedCycleId);
labelSpan.textContent =
selectedCycle?.name || t("kanban.allCycles");
} else {
labelSpan.textContent = t("kanban.allCycles");
}
// Register click event to show menu
this.registerDomEvent(cycleButton, "click", (event: MouseEvent) => {
this.showCycleMenu(event);
});
}
/**
* Show the cycle selection menu
*/
private showCycleMenu(event: MouseEvent): void {
const menu = new Menu();
// "All Cycles" option
menu.addItem((item) => {
item.setTitle(t("kanban.allCycles"))
.setChecked(this.selectedCycleId === null)
.onClick(() => {
this.selectedCycleId = null;
this.renderCycleSelector();
if (this.onCycleChange) {
this.onCycleChange(null);
}
});
});
menu.addSeparator();
// Get enabled cycles, sorted by priority
const enabledCycles = (this.plugin.settings.statusCycles || [])
.filter((c) => c.enabled)
.sort((a, b) => a.priority - b.priority);
if (enabledCycles.length === 0) {
menu.addItem((item) => {
item.setTitle(t("kanban.noCyclesAvailable")).setDisabled(true);
});
} else {
for (const cycle of enabledCycles) {
menu.addItem((item) => {
item.setTitle(cycle.name)
.setChecked(this.selectedCycleId === cycle.id)
.onClick(() => {
this.selectedCycleId = cycle.id;
this.renderCycleSelector();
if (this.onCycleChange) {
this.onCycleChange(cycle.id);
}
});
});
}
}
menu.addSeparator();
menu.addItem((i) => {
i.setTitle(t("Open status cycle settings"));
i.onClick(() => {
this.plugin.app.setting.open();
this.plugin.app.setting.openTabById(this.plugin.manifest.id);
this.plugin.settingTab.openTab("task-status");
});
});
menu.showAtMouseEvent(event);
}
}

View file

@ -438,6 +438,11 @@ export class FluentComponentManager extends Component {
this.listContainer?.hide();
this.treeContainer?.hide();
}
// Hide cycle selector when hiding all components
if (this.topNavigation) {
this.topNavigation.hideCycleSelector();
}
}
/**
@ -619,8 +624,13 @@ export class FluentComponentManager extends Component {
this.currentVisibleComponent = targetComponent;
// Set TopNavigation reference for ContentComponent to enable custom buttons
if (targetComponent === this.contentComponent && this.topNavigation) {
console.log("[FluentComponent] Setting TopNavigation reference for ContentComponent");
if (
targetComponent === this.contentComponent &&
this.topNavigation
) {
console.log(
"[FluentComponent] Setting TopNavigation reference for ContentComponent",
);
this.contentComponent.setTopNavigation(this.topNavigation);
}
@ -724,6 +734,8 @@ export class FluentComponentManager extends Component {
// Clear custom buttons before switching view modes
if (this.topNavigation) {
this.topNavigation.clearCustomButtons();
// Hide cycle selector when switching away from kanban
this.topNavigation.hideCycleSelector();
}
// Based on the current view mode, show the appropriate component
@ -757,6 +769,31 @@ export class FluentComponentManager extends Component {
this.kanbanComponent.containerEl.show();
// Show cycle selector in TopNavigation for kanban view
if (this.topNavigation) {
// Load saved cycle selection from kanban component
const savedCycleId =
this.kanbanComponent["selectedCycleId"] || null;
// Set cycle change callback
this.topNavigation.setCycleChangeCallback(
(cycleId: string | null) => {
console.log(
"[FluentComponent] Cycle changed to:",
cycleId,
);
// Update kanban component's selected cycle
this.kanbanComponent["selectedCycleId"] = cycleId;
this.kanbanComponent["saveCycleSelection"]();
// Re-render columns with new cycle
this.kanbanComponent["renderColumns"]();
},
);
// Show cycle selector with current selection
this.topNavigation.showCycleSelector(savedCycleId);
}
console.log(
"[FluentComponent] Setting",
filteredTasks.length,
@ -888,7 +925,10 @@ export class FluentComponentManager extends Component {
/**
* Render error state with detailed context
*/
renderErrorState(context: ErrorContext | string, onRetry: () => void): void {
renderErrorState(
context: ErrorContext | string,
onRetry: () => void,
): void {
if (!this.contentArea) return;
// Parse context: support both new (ErrorContext) and old (string) formats
@ -952,7 +992,7 @@ export class FluentComponentManager extends Component {
*/
private createErrorContext(
errorEl: HTMLElement,
errorContext: ErrorContext
errorContext: ErrorContext,
): void {
if (
!errorContext.viewId &&
@ -994,7 +1034,7 @@ export class FluentComponentManager extends Component {
*/
private createErrorMessage(
errorEl: HTMLElement,
errorContext: ErrorContext
errorContext: ErrorContext,
): void {
const userMessage =
errorContext.userMessage ||
@ -1012,7 +1052,7 @@ export class FluentComponentManager extends Component {
*/
private createTechnicalDetails(
errorEl: HTMLElement,
errorContext: ErrorContext
errorContext: ErrorContext,
): void {
if (!errorContext.originalError) return;
@ -1045,10 +1085,7 @@ export class FluentComponentManager extends Component {
/**
* Create retry button
*/
private createRetryButton(
errorEl: HTMLElement,
onRetry: () => void
): void {
private createRetryButton(errorEl: HTMLElement, onRetry: () => void): void {
const retryBtn = errorEl.createEl("button", {
cls: "tg-fluent-button tg-fluent-button-primary",
text: t("Retry"),

View file

@ -71,6 +71,8 @@ export class KanbanComponent extends Component {
};
private hideEmptyColumns = false;
private configOverride: Partial<KanbanSpecificConfig> | null = null; // Configuration override from Bases view
private selectedCycleId: string | null = null; // Currently selected cycle ID for filtering
private readonly CYCLE_STORAGE_KEY_PREFIX = "kanban-cycle-"; // localStorage key prefix for cycle selection
constructor(
app: App,
@ -136,6 +138,7 @@ export class KanbanComponent extends Component {
// Load configuration settings
this.loadKanbanConfig();
this.loadCycleSelection();
this.filterContainerEl = this.containerEl.createDiv({
cls: "tg-kanban-filters",
@ -276,6 +279,78 @@ export class KanbanComponent extends Component {
menu.showAtMouseEvent(event);
});
// Cycle selector (only for status grouping)
this.renderCycleSelector(controlsContainer);
}
private renderCycleSelector(container: HTMLElement): void {
// Only show cycle selector in status grouping mode
const kanbanConfig = this.getEffectiveKanbanConfig();
if (kanbanConfig?.groupBy !== "status") {
return;
}
const cycleContainer = container.createDiv({
cls: "tg-kanban-cycle-container",
});
const cycleButton = cycleContainer.createEl(
"button",
{
cls: "tg-kanban-cycle-button clickable-icon",
attr: {
"aria-label": t("kanban.cycleSelector"),
},
},
(el) => {
setIcon(el, "layers");
},
);
this.registerDomEvent(cycleButton, "click", (event: MouseEvent) => {
const menu = new Menu();
// "All Cycles" option
menu.addItem((item) => {
item.setTitle(t("kanban.allCycles"))
.setChecked(this.selectedCycleId === null)
.onClick(() => {
this.selectedCycleId = null;
this.saveCycleSelection();
this.renderColumns();
});
});
menu.addSeparator();
// Get enabled cycles, sorted by priority
const enabledCycles = (this.plugin.settings.statusCycles || [])
.filter((c) => c.enabled)
.sort((a, b) => a.priority - b.priority);
if (enabledCycles.length === 0) {
menu.addItem((item) => {
item.setTitle(t("kanban.noCyclesAvailable")).setDisabled(
true,
);
});
} else {
for (const cycle of enabledCycles) {
menu.addItem((item) => {
item.setTitle(cycle.name)
.setChecked(this.selectedCycleId === cycle.id)
.onClick(() => {
this.selectedCycleId = cycle.id;
this.saveCycleSelection();
this.renderColumns();
});
});
}
}
menu.showAtMouseEvent(event);
});
}
private renderFilterControls(containerEl: HTMLElement) {
@ -555,12 +630,42 @@ export class KanbanComponent extends Component {
}
private renderStatusColumns() {
const { cycle: statusCycle, excludeMarksFromCycle } =
getTaskStatusConfig(this.plugin.settings);
let statusNames =
statusCycle.length > 0
? statusCycle
: ["Todo", "In Progress", "Done"];
// Determine which statuses to render based on cycle selection
let statusNames: string[];
let excludeMarksFromCycle: string[] = [];
if (this.selectedCycleId) {
// Find the selected cycle
const selectedCycle = (
this.plugin.settings.statusCycles || []
).find((c) => c.id === this.selectedCycleId && c.enabled);
if (selectedCycle) {
// Use only the statuses from the selected cycle
statusNames = [...selectedCycle.cycle];
} else {
// Cycle no longer exists or is disabled, reset to all
console.warn(
`[Kanban] Selected cycle ${this.selectedCycleId} not found, resetting`,
);
this.selectedCycleId = null;
this.saveCycleSelection();
const config = getTaskStatusConfig(this.plugin.settings);
statusNames =
config.cycle.length > 0
? config.cycle
: ["Todo", "In Progress", "Done"];
excludeMarksFromCycle = config.excludeMarksFromCycle || [];
}
} else {
// Show all cycles (existing behavior)
const config = getTaskStatusConfig(this.plugin.settings);
statusNames =
config.cycle.length > 0
? config.cycle
: ["Todo", "In Progress", "Done"];
excludeMarksFromCycle = config.excludeMarksFromCycle || [];
}
const spaceStatus: string[] = [];
const xStatus: string[] = [];
@ -614,6 +719,50 @@ export class KanbanComponent extends Component {
this.addChild(column);
this.columns.push(column);
});
// Render "Other" column for unmatched tasks (only when a cycle is selected)
if (this.selectedCycleId) {
const unmatchedTasks = this.getUnmatchedTasks();
if (unmatchedTasks.length > 0) {
const otherColumn = new KanbanColumnComponent(
this.app,
this.plugin,
this.columnContainerEl,
t("kanban.otherColumn"),
unmatchedTasks,
{
...this.params,
onTaskStatusUpdate: (
taskId: string,
newStatusMark: string,
) => this.handleStatusUpdate(taskId, newStatusMark),
onFilterApply: this.handleFilterApply,
},
);
this.addChild(otherColumn);
this.columns.push(otherColumn);
}
}
}
private getUnmatchedTasks(): Task[] {
if (!this.selectedCycleId) {
return [];
}
const selectedCycle = (this.plugin.settings.statusCycles || []).find(
(c) => c.id === this.selectedCycleId,
);
if (!selectedCycle) {
return [];
}
// Collect all marks from the selected cycle
const cycleMarks = new Set(Object.values(selectedCycle.marks));
// Return tasks whose status is not in the cycle
return this.tasks.filter((task) => !cycleMarks.has(task.status || " "));
}
private renderCustomColumns(
@ -809,14 +958,31 @@ export class KanbanComponent extends Component {
}
private getTasksForStatus(statusName: string): Task[] {
// Prefer multi-mark mapping from settings.taskStatuses when available
const allowed = this.getAllowedMarksForStatusName(statusName);
const statusMark = this.resolveStatusMark(statusName) ?? " ";
let allowedMarks: Set<string>;
if (this.selectedCycleId) {
// Use the selected cycle's marks mapping
const selectedCycle = (
this.plugin.settings.statusCycles || []
).find((c) => c.id === this.selectedCycleId);
if (selectedCycle) {
const mark = selectedCycle.marks[statusName];
allowedMarks = mark ? new Set([mark]) : new Set();
} else {
allowedMarks = new Set();
}
} else {
// Use multi-cycle logic (existing behavior)
const allowed = this.getAllowedMarksForStatusName(statusName);
const statusMark = this.resolveStatusMark(statusName) ?? " ";
allowedMarks = allowed || new Set([statusMark]);
}
// Filter from the already filtered list
const tasksForStatus = this.tasks.filter((task) => {
const mark = task.status || " ";
return allowed ? allowed.has(mark) : mark === statusMark;
return allowedMarks.has(mark);
});
// Sort tasks within the status column based on selected sort option
@ -943,18 +1109,21 @@ export class KanbanComponent extends Component {
this.getEffectiveKanbanConfig()?.groupBy || groupByPlugin;
if (groupBy === "status") {
// Handle status-based grouping (original logic)
const targetStatusMark = this.resolveStatusMark(
// Handle status-based grouping
const targetStatusMark = this.getStatusMarkForColumn(
(targetColumnTitle || "").trim(),
);
if (targetStatusMark !== undefined) {
if (
targetStatusMark !== undefined &&
targetStatusMark !== null
) {
console.log(
`Kanban requesting status update for task ${taskId} to status ${targetColumnTitle} (mark: ${targetStatusMark})`,
);
await this.handleStatusUpdate(taskId, targetStatusMark);
} else {
console.warn(
`Could not find status mark for status name: ${targetColumnTitle}`,
`Could not find status mark for status name: ${targetColumnTitle} or drag to Other column is not allowed`,
);
}
} else {
@ -1017,6 +1186,36 @@ export class KanbanComponent extends Component {
this.loadColumnOrder();
}
private getCycleStorageKey(): string {
return `${this.CYCLE_STORAGE_KEY_PREFIX}${this.currentViewId}`;
}
private loadCycleSelection(): void {
const key = this.getCycleStorageKey();
const saved = localStorage.getItem(key);
if (saved) {
// Verify that the cycle still exists and is enabled
const cycles = this.plugin.settings.statusCycles || [];
const cycleExists = cycles.some((c) => c.id === saved && c.enabled);
this.selectedCycleId = cycleExists ? saved : null;
if (!cycleExists && saved) {
console.log(
`[Kanban] Saved cycle ${saved} no longer exists or is disabled, resetting to null`,
);
localStorage.removeItem(key);
}
}
}
private saveCycleSelection(): void {
const key = this.getCycleStorageKey();
if (this.selectedCycleId) {
localStorage.setItem(key, this.selectedCycleId);
} else {
localStorage.removeItem(key);
}
}
private getSortOptionLabel(field: string, order: string): string {
const fieldLabels: Record<string, string> = {
priority: t("Priority"),
@ -1034,6 +1233,36 @@ export class KanbanComponent extends Component {
* Accepts either configured status names (e.g., "Abandoned")
* or raw marks (e.g., "-", "x", "/").
*/
/**
* Get status mark for a column title, respecting cycle selection.
* Returns null if dragging to "Other" column (not allowed).
*/
private getStatusMarkForColumn(
columnTitle: string,
): string | null | undefined {
if (!columnTitle) return undefined;
// Check if this is the "Other" column
if (columnTitle === t("kanban.otherColumn")) {
// Dragging to "Other" column is not allowed
return null;
}
if (this.selectedCycleId) {
// Use selected cycle's marks mapping
const selectedCycle = (
this.plugin.settings.statusCycles || []
).find((c) => c.id === this.selectedCycleId);
if (selectedCycle) {
return selectedCycle.marks[columnTitle] || undefined;
}
}
// Fallback to existing logic for multi-cycle or no cycle selected
return this.resolveStatusMark(columnTitle);
}
private resolveStatusMark(titleOrMark: string): string | undefined {
if (!titleOrMark) return undefined;
const trimmed = titleOrMark.trim();

View file

@ -199,6 +199,7 @@ class TaskTextMark extends Component {
// Get cycle configuration from plugin settings
const { cycle, marks, excludeMarksFromCycle } = getTaskStatusConfig(
this.plugin.settings,
this.currentMark,
);
// Filter out excluded marks

View file

@ -28,6 +28,8 @@ import {
getTaskStatusConfig,
} from "@/utils/status-cycle-resolver";
import { t } from "@/translations/helper";
export type TaskState = string;
export const taskStatusChangeAnnotation = Annotation.define();
@ -51,6 +53,7 @@ class TaskStatusWidget extends WidgetType {
readonly from: number,
readonly to: number,
readonly currentState: TaskState,
readonly currentMark: string,
readonly listPrefix: string,
) {
super();
@ -193,7 +196,7 @@ class TaskStatusWidget extends WidgetType {
if (applicableCycles.length > 0) {
// Show each applicable cycle with its next status
menu.addItem((item) => {
item.setTitle("Switch using cycle:");
item.setTitle(t("Cycle to next:"));
item.setDisabled(true);
});
@ -205,16 +208,16 @@ class TaskStatusWidget extends WidgetType {
if (nextStatusResult) {
menu.addItem((item) => {
const priorityIndicator =
cycle.priority === 0 ? "★ " : "";
const priorityIndicator = cycle.priority === 0;
item.setTitle(
`${priorityIndicator}${cycle.name}: ${nextStatusResult.statusName}`,
`${cycle.name}: ${nextStatusResult.statusName}`,
);
item.onClick(() => {
this.setTaskState(
nextStatusResult.statusName,
);
});
item.setChecked(priorityIndicator);
});
}
}
@ -223,7 +226,7 @@ class TaskStatusWidget extends WidgetType {
// Add "Choose any status" section
menu.addItem((item) => {
item.setTitle("Choose any status:");
item.setTitle(t("Or choose any:"));
item.setDisabled(true);
});
@ -234,11 +237,9 @@ class TaskStatusWidget extends WidgetType {
for (const statusName of Array.from(allStatusNames)) {
menu.addItem((item) => {
const isCurrent = statusName === this.currentState;
item.setTitle(
isCurrent
? `${statusName} (current)`
: statusName,
);
item.setTitle(statusName);
item.setDisabled(isCurrent);
item.setChecked(isCurrent);
item.onClick(() => {
this.setTaskState(statusName);
});
@ -346,7 +347,7 @@ class TaskStatusWidget extends WidgetType {
};
}
return getTaskStatusConfig(this.plugin.settings);
return getTaskStatusConfig(this.plugin.settings, this.currentMark);
}
// Cycle through task states
@ -440,7 +441,7 @@ export function taskStatusSwitcherExtension(
const checkbox = checkboxWithSpace.trim();
const isLivePreview = this.isLivePreview(view.state);
const { cycle, marks, excludeMarksFromCycle } =
getTaskStatusConfig(plugin.settings);
getTaskStatusConfig(plugin.settings, mark);
const remainingCycle = cycle.filter(
(state) => !excludeMarksFromCycle.includes(state),
);
@ -476,6 +477,7 @@ export function taskStatusSwitcherExtension(
checkboxStart,
checkboxEnd,
currentState,
mark,
bulletText,
),
}),
@ -499,6 +501,7 @@ export function taskStatusSwitcherExtension(
bulletWithSpace.length +
checkbox.length,
currentState,
mark,
bulletText,
),
}),

View file

@ -533,6 +533,54 @@ button.fluent-new-task-btn:hover {
align-items: center;
}
/* Cycle selector styles */
.fluent-nav-cycle-selector-wrapper {
display: flex;
align-items: center;
margin-right: 8px;
}
.fluent-nav-cycle-button {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background-color: var(--background-primary);
color: var(--text-normal);
cursor: pointer;
font-size: var(--font-ui-small);
transition:
background-color 0.2s ease,
border-color 0.2s ease;
white-space: nowrap;
}
.fluent-nav-cycle-button:hover {
background-color: var(--background-modifier-hover);
border-color: var(--background-modifier-border-hover);
}
.fluent-nav-cycle-button:active {
background-color: var(--background-modifier-active-hover);
}
.fluent-nav-cycle-icon {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
.fluent-nav-cycle-label {
font-weight: 500;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.fluent-nav-icon-button {
position: relative;
width: 36px;
@ -573,7 +621,9 @@ button.fluent-new-task-btn:hover {
.bases-view .tg-kanban-filters,
.tg-fluent-content .content-header,
.tg-fluent-content .tg-kanban-filters {
.tg-fluent-main-container:has(.fluent-top-navigation:not(.other-view))
.tg-fluent-content
.tg-kanban-filters {
display: none;
}
@ -637,7 +687,10 @@ button.fluent-new-task-btn:hover {
/* ===== Collapsed Sidebar (Rail) ===== */
.tg-fluent-sidebar-container {
transition: width 0.2s ease, min-width 0.2s ease, max-width 0.2s ease;
transition:
width 0.2s ease,
min-width 0.2s ease,
max-width 0.2s ease;
}
/* When the sidebar element itself is collapsed */
@ -668,7 +721,9 @@ button.fluent-new-task-btn:hover {
border-radius: var(--radius-s);
color: var(--text-muted);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.fluent-rail-btn:hover {
@ -832,7 +887,9 @@ button.fluent-new-task-btn:hover {
border-radius: 4px;
color: var(--text-muted);
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
transition:
background-color 0.15s ease,
color 0.15s ease;
margin-right: 8px;
flex-shrink: 0;
}

View file

@ -104,6 +104,7 @@
display: flex;
flex-direction: column;
background-color: var(--background-primary);
width: calc(100% - 360px);
}
.forecast-task-list {

View file

@ -48,6 +48,38 @@
border-color: var(--background-modifier-border-hover);
}
/* Cycle selector styles */
.tg-kanban-cycle-container {
display: flex;
align-items: center;
gap: 4px;
}
.tg-kanban-cycle-button {
padding: 4px 8px;
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-s);
background-color: var(--background-primary);
color: var(--text-normal);
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: var(--font-ui-small);
transition:
background-color 0.2s ease,
border-color 0.2s ease;
}
.tg-kanban-cycle-button:hover {
background-color: var(--background-modifier-hover);
border-color: var(--background-modifier-border-hover);
}
.tg-kanban-cycle-button:active {
background-color: var(--background-modifier-active-hover);
}
.tg-kanban-toggle-container {
display: flex;
align-items: center;
@ -115,7 +147,7 @@
background-color: var(--background-secondary);
border-radius: var(--radius-m);
height: 100%; /* Fill container height */
max-height: 100%; /* Prevent exceeding container */
max-height: calc(100vh - 170px); /* Prevent exceeding container */
overflow: hidden; /* Hide overflow within the column */
border: 1px solid var(--background-modifier-border);
@ -175,7 +207,9 @@
border: 1px solid var(--background-modifier-border);
font-size: var(--font-ui-small);
cursor: grab;
transition: box-shadow 0.2s ease-in-out, background-color 0.2s ease-in-out;
transition:
box-shadow 0.2s ease-in-out,
background-color 0.2s ease-in-out;
/* Ensure card fits within column and handles content */
max-width: 100%; /* Prevent card from exceeding parent width */
@ -313,14 +347,18 @@
margin-top: 10px; /* Increased margin */
border-top: 2px dashed var(--interactive-accent); /* Indicator */
/* padding-top: 20px; */ /* Removed padding */
transition: margin-top 0.1s ease-out, border-top 0.1s ease-out; /* Updated transition */
transition:
margin-top 0.1s ease-out,
border-top 0.1s ease-out; /* Updated transition */
}
.tg-kanban-card--drop-indicator-after {
margin-bottom: 10px; /* Increased margin */
border-bottom: 2px dashed var(--interactive-accent); /* Indicator */
/* padding-bottom: 20px; */ /* Removed padding */
transition: margin-bottom 0.1s ease-out, border-bottom 0.1s ease-out; /* Updated transition */
transition:
margin-bottom 0.1s ease-out,
border-bottom 0.1s ease-out; /* Updated transition */
}
/* Optional: Style for dropping into an empty column */
@ -335,9 +373,13 @@
/* Ensure transitions are smooth when classes are removed */
.tg-kanban-card {
/* Ensure existing transitions don't conflict, or add base transition */
transition: margin 0.1s ease-out, padding 0.1s ease-out,
border 0.1s ease-out, transform 0.2s ease-out,
box-shadow 0.2s ease-in-out, background-color 0.2s ease-in-out; /* Merged transitions */
transition:
margin 0.1s ease-out,
padding 0.1s ease-out,
border 0.1s ease-out,
transform 0.2s ease-out,
box-shadow 0.2s ease-in-out,
background-color 0.2s ease-in-out; /* Merged transitions */
}
.drop-target-active {
@ -373,7 +415,9 @@
cursor: pointer;
font-size: var(--font-ui-small);
text-align: left;
transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out;
transition:
background-color 0.2s ease-in-out,
color 0.2s ease-in-out;
}
.tg-kanban-add-card-button:hover {

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -680,7 +680,8 @@ export function groupTasksByStatus(
const groupMap = new Map<string, Task[]>();
tasks.forEach((task) => {
const status = task.status || "TODO";
// task.status is the mark character (e.g., "x", " ", "r", "-")
const status = task.status || " ";
if (!groupMap.has(status)) {
groupMap.set(status, []);
@ -688,14 +689,47 @@ export function groupTasksByStatus(
groupMap.get(status)!.push(task);
});
// Define status order
const statusOrder = ["TODO", "IN_PROGRESS", "WAITING", "DONE", "CANCELLED"];
// Build mark order from user's cycle configuration
// This ensures marks are sorted according to user's defined cycles
const markOrder: string[] = [];
if (settings?.statusCycles && settings.statusCycles.length > 0) {
// Get marks from all enabled cycles, ordered by priority
const enabledCycles = settings.statusCycles
.filter((c) => c.enabled)
.sort((a, b) => a.priority - b.priority);
for (const cycle of enabledCycles) {
for (const statusName of cycle.cycle) {
const mark = cycle.marks[statusName];
if (mark && !markOrder.includes(mark)) {
markOrder.push(mark);
}
}
}
} else if (settings?.taskStatusCycle && settings?.taskStatusMarks) {
// Legacy single-cycle mode
for (const statusName of settings.taskStatusCycle) {
const mark = settings.taskStatusMarks[statusName];
if (mark && !markOrder.includes(mark)) {
markOrder.push(mark);
}
}
}
// Fallback order for common marks not in user config
const fallbackOrder = [" ", "/", ">", "x", "X", "-", "?", "!"];
for (const mark of fallbackOrder) {
if (!markOrder.includes(mark)) {
markOrder.push(mark);
}
}
// Convert to TaskGroup array and sort by defined order
const groups: TaskGroup[] = [];
const sortedKeys = Array.from(groupMap.keys()).sort((a, b) => {
const indexA = statusOrder.indexOf(a);
const indexB = statusOrder.indexOf(b);
const indexA = markOrder.indexOf(a);
const indexB = markOrder.indexOf(b);
// If both are in the order array, sort by index
if (indexA !== -1 && indexB !== -1) {

File diff suppressed because one or more lines are too long