feat: update habit view

This commit is contained in:
Quorafind 2025-04-28 17:51:19 +08:00
parent edce8bf1b6
commit a6e2ab4007
20 changed files with 2589 additions and 6 deletions

View file

@ -53,6 +53,7 @@
},
"dependencies": {
"date-fns": "^4.1.0",
"localforage": "^1.10.0"
"localforage": "^1.10.0",
"obsidian-daily-notes-interface": "^0.9.4"
}
}

View file

@ -14,6 +14,9 @@ importers:
localforage:
specifier: ^1.10.0
version: 1.10.0
obsidian-daily-notes-interface:
specifier: ^0.9.4
version: 0.9.4(@codemirror/state@6.5.2)(@codemirror/view@6.36.5)
devDependencies:
'@codemirror/language':
specifier: https://github.com/lishid/cm-language
@ -1608,12 +1611,23 @@ packages:
nwsapi@2.2.20:
resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==}
obsidian-daily-notes-interface@0.9.4:
resolution: {integrity: sha512-PILoRtZUB5wEeGnDQAPMlkVlXwDYoxkLR8Wl4STU2zLNwhcq9kKvQexiXi7sfjGlpTnL+LeAOfEVWyeVndneKg==}
hasBin: true
obsidian@1.8.7:
resolution: {integrity: sha512-h4bWwNFAGRXlMlMAzdEiIM2ppTGlrh7uGOJS6w4gClrsjc+ei/3YAtU2VdFUlCiPuTHpY4aBpFJJW75S1Tl/JA==}
peerDependencies:
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
obsidian@https://codeload.github.com/obsidianmd/obsidian-api/tar.gz/9ab497e64afeac291a2546d2faeded6a7bed626a:
resolution: {tarball: https://codeload.github.com/obsidianmd/obsidian-api/tar.gz/9ab497e64afeac291a2546d2faeded6a7bed626a}
version: 1.8.7
peerDependencies:
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@ -1910,6 +1924,9 @@ packages:
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.1.0:
resolution: {integrity: sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==}
tslib@2.4.0:
resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
@ -3976,6 +3993,14 @@ snapshots:
nwsapi@2.2.20: {}
obsidian-daily-notes-interface@0.9.4(@codemirror/state@6.5.2)(@codemirror/view@6.36.5):
dependencies:
obsidian: https://codeload.github.com/obsidianmd/obsidian-api/tar.gz/9ab497e64afeac291a2546d2faeded6a7bed626a(@codemirror/state@6.5.2)(@codemirror/view@6.36.5)
tslib: 2.1.0
transitivePeerDependencies:
- '@codemirror/state'
- '@codemirror/view'
obsidian@1.8.7(@codemirror/state@6.5.2)(@codemirror/view@6.36.5):
dependencies:
'@codemirror/state': 6.5.2
@ -3983,6 +4008,13 @@ snapshots:
'@types/codemirror': 5.60.8
moment: 2.29.4
obsidian@https://codeload.github.com/obsidianmd/obsidian-api/tar.gz/9ab497e64afeac291a2546d2faeded6a7bed626a(@codemirror/state@6.5.2)(@codemirror/view@6.36.5):
dependencies:
'@codemirror/state': 6.5.2
'@codemirror/view': 6.36.5
'@types/codemirror': 5.60.8
moment: 2.29.4
once@1.4.0:
dependencies:
wrappy: 1.0.2
@ -4238,6 +4270,8 @@ snapshots:
tslib@1.14.1: {}
tslib@2.1.0: {}
tslib@2.4.0: {}
tsutils@3.21.0(typescript@4.7.3):

View file

@ -211,6 +211,12 @@ export interface RewardSettings {
occurrenceLevels: OccurrenceLevel[];
}
export interface HabitSettings {
enableHabits: boolean;
habitKeyMap: Record<string, string[]>;
scheduledEventMap: Record<string, Record<string, string>>;
}
/** Define the main settings structure */
export interface TaskProgressBarSettings {
// General Settings (Example)
@ -281,6 +287,9 @@ export interface TaskProgressBarSettings {
// Reward Settings (NEW)
rewards: RewardSettings;
// Habit Settings
habit: HabitSettings;
}
/** Define the default settings */
@ -360,8 +369,8 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
enableCompletedTaskMover: true,
taskMarkerType: "date",
versionMarker: "version 1.0",
dateMarker: "archived on {{DATE:YYYY-MM-DD}}",
customMarker: "moved {{DATE:YYYY-MM-DD HH:mm}}",
dateMarker: t("archived on") + " {{date}}",
customMarker: t("moved") + " {{DATE:YYYY-MM-DD HH:mm}}",
treatAbandonedAsCompleted: false,
completeAllMovedTasks: true,
withCurrentFileLink: true,
@ -555,6 +564,16 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
useMarkdownRenderer: true,
} as GanttSpecificConfig,
},
{
id: "habit",
name: t("Habit"),
icon: "calendar-clock",
type: "default",
visible: true,
hideCompletedAndAbandonedTasks: false,
filterRules: {},
filterBlanks: false,
},
],
// Review Settings
@ -598,6 +617,13 @@ export const DEFAULT_SETTINGS: TaskProgressBarSettings = {
{ name: t("legendary"), chance: 5 },
],
},
// Habit Settings
habit: {
enableHabits: false,
habitKeyMap: {},
scheduledEventMap: {},
},
};
// Helper function to get view settings safely

View file

@ -0,0 +1,186 @@
import { Component, App, Modal, Setting } from "obsidian";
import {
HabitProps,
DailyHabitProps,
CountHabitProps,
ScheduledHabitProps,
MappingHabitProps,
} from "../../types/habit-card"; // Assuming types are in src/types
import TaskProgressBarPlugin from "src";
import {
DailyHabitCard,
CountHabitCard,
ScheduledHabitCard,
MappingHabitCard,
} from "./habitcard"; // Import the habit card classes
import { t } from "src/translations/helper";
import "../../styles/habit.css";
export class Habit extends Component {
plugin: TaskProgressBarPlugin;
containerEl: HTMLElement; // The element where the view will be rendered
constructor(plugin: TaskProgressBarPlugin, parentEl: HTMLElement) {
super();
this.plugin = plugin;
this.containerEl = parentEl.createDiv("tg-habit-component-container");
}
async onload() {
console.log("HabitView loaded.");
if (this.plugin) {
// Cast to any to avoid TypeScript error about event name
this.registerEvent(
(this.plugin.app.workspace as any).on(
"task-genius:habit-index-updated",
this.redraw
)
);
}
this.redraw(); // Initial draw
}
onunload() {
console.log("HabitView unloaded.");
this.containerEl.empty(); // Clear the container on unload
}
// Redraw the entire habit view
redraw = () => {
const scrollState = this.containerEl.scrollTop;
this.containerEl.empty(); // Clear previous content
const habits = this.getHabitData(); // Method to fetch habit data
if (!habits || habits.length === 0) {
this.renderEmptyState();
} else {
this.renderHabitList(habits);
}
this.containerEl.scrollTop = scrollState; // Restore scroll position
};
getHabitData(): HabitProps[] {
const habits = this.plugin.habitManager?.habits || [];
return habits;
}
renderEmptyState() {
const emptyDiv = this.containerEl.createDiv({
cls: "habit-empty-state",
});
emptyDiv.createEl("h2", { text: t("No Habits Yet") });
emptyDiv.createEl("p", {
text: t("Click the open habit button to create a new habit."),
}); // Adjust text based on UI
}
renderHabitList(habits: HabitProps[]) {
const listContainer = this.containerEl.createDiv({
cls: "habit-list-container",
});
habits.forEach((habit) => {
const habitCardContainer = listContainer.createDiv({
cls: "habit-card-wrapper",
}); // Wrapper for context menu, etc.
// TODO: Add context menu logic here
this.renderHabitCard(habitCardContainer, habit);
});
// TODO: Add Confetti logic if needed (could be managed globally or per card)
}
renderHabitCard(container: HTMLElement, habit: HabitProps) {
// Ensure completions is an object
habit.completions = habit.completions || {};
switch (habit.type) {
case "daily":
const dailyCard = new DailyHabitCard(
habit as DailyHabitProps,
container
);
this.addChild(dailyCard);
break;
case "count":
const countCard = new CountHabitCard(
habit as CountHabitProps,
container
);
this.addChild(countCard);
break;
case "scheduled":
const scheduledCard = new ScheduledHabitCard(
habit as ScheduledHabitProps,
container
);
this.addChild(scheduledCard);
break;
case "mapping":
const mappingCard = new MappingHabitCard(
habit as MappingHabitProps,
container
);
this.addChild(mappingCard);
break;
default:
// Use a type assertion to handle potential future types or errors
const unknownHabit = habit as any;
console.warn(`Unsupported habit type: ${unknownHabit?.type}`);
container.createDiv({
text: `Unsupported habit: ${
unknownHabit?.name || "Unknown"
}`,
});
}
}
}
// --- Modal for Scheduled Event Details ---
export class EventDetailModal extends Modal {
eventName: string;
onSubmit: (details: string) => void;
details: string = "";
constructor(
app: App,
eventName: string,
onSubmit: (details: string) => void
) {
super(app);
this.eventName = eventName;
this.onSubmit = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.addClass("habit-event-modal");
contentEl.createEl("h2", {
text: `Record Details for ${this.eventName}`,
});
new Setting(contentEl).setName("Details (optional)").addText((text) =>
text
.setPlaceholder(`Enter details for ${this.eventName}...`)
.onChange((value) => {
this.details = value;
})
);
new Setting(contentEl).addButton((btn) =>
btn
.setButtonText("Submit")
.setCta()
.onClick(() => {
this.close();
this.onSubmit(this.details);
})
);
}
onClose() {
let { contentEl } = this;
contentEl.empty();
}
}

View file

@ -0,0 +1,38 @@
import { Component } from "obsidian";
import {
CountHabitProps,
DailyHabitProps,
HabitProps,
MappingHabitProps,
ScheduledHabitProps,
} from "src/types/habit-card";
export class HabitCard extends Component {
constructor(public habit: HabitProps, public container: HTMLElement) {
super();
}
}
export class DailyHabitCard extends HabitCard {
constructor(habit: DailyHabitProps, container: HTMLElement) {
super(habit, container);
}
}
export class CountHabitCard extends HabitCard {
constructor(habit: CountHabitProps, container: HTMLElement) {
super(habit, container);
}
}
export class ScheduledHabitCard extends HabitCard {
constructor(habit: ScheduledHabitProps, container: HTMLElement) {
super(habit, container);
}
}
export class MappingHabitCard extends HabitCard {
constructor(habit: MappingHabitProps, container: HTMLElement) {
super(habit, container);
}
}

View file

@ -0,0 +1,85 @@
import { Component, Notice, setIcon } from "obsidian";
import { CountHabitProps } from "src/types/habit-card";
import { HabitCard } from "./habitcard";
import { t } from "src/translations/helper";
import TaskProgressBarPlugin from "src/index";
export class CountHabitCard extends HabitCard {
constructor(
public habit: CountHabitProps,
public container: HTMLElement,
public plugin: TaskProgressBarPlugin
) {
super(habit, container, plugin);
}
render(): void {
super.render();
const card = this.container.createDiv({
cls: "habit-card count-habit-card",
});
const header = card.createDiv({ cls: "card-header" });
const titleDiv = header.createDiv({ cls: "card-title" });
const iconEl = titleDiv.createSpan({ cls: "habit-icon" });
setIcon(iconEl, (this.habit.icon as string) || "dice");
// Add count unit to title if defined
const titleText = this.habit.countUnit
? `${this.habit.name} (${this.habit.countUnit})`
: this.habit.name;
titleDiv
.createSpan({ text: titleText, cls: "habit-name" })
.onClickEvent(() => {
new Notice(`Chart for ${this.habit.name} (Not Implemented)`);
// TODO: Implement Chart Dialog
});
const contentWrapper = card.createDiv({ cls: "card-content-wrapper" });
const button = contentWrapper.createEl("button", {
cls: "habit-icon-button",
});
const buttonIcon = button.createSpan({ cls: "habit-icon" });
setIcon(buttonIcon, (this.habit.icon as string) || "plus-circle"); // Use habit icon or a plus icon
const today = new Date().toISOString().split("T")[0];
let countToday = this.habit.completions[today] ?? 0;
button.addEventListener("click", (e) => {
this.toggleHabitCompletion(this.habit.id);
// TODO: Implement confetti/toast logic from React code
if (this.habit.max && countToday + 1 === this.habit.max) {
new Notice(`${t("Goal reached")} ${this.habit.name}! ✅`);
} else if (this.habit.max && countToday + 1 > this.habit.max) {
new Notice(`${t("Exceeded goal")} ${this.habit.name}! 💪`);
}
});
const infoDiv = contentWrapper.createDiv({ cls: "habit-info" });
// Add count unit to display if defined
const countText = this.habit.countUnit
? `${countToday} / ${this.habit.max} ${this.habit.countUnit}`
: `${countToday} / ${this.habit.max}`;
infoDiv.createEl("h3", { text: countText });
// TODO: Add last completion date span if needed
const progressArea = contentWrapper.createDiv({
cls: "habit-progress-area",
});
const heatmapContainer = progressArea.createDiv({
cls: "habit-heatmap-small",
});
if (this.habit.max && this.habit.max > 0) {
this.renderHeatmap(
heatmapContainer,
this.habit.completions,
"sm",
(value: any) => value >= (this.habit.max ?? 0)
);
this.renderProgressBar(progressArea, countToday, this.habit.max);
}
}
}

View file

@ -0,0 +1,88 @@
import { Component, Notice, setIcon } from "obsidian";
import { DailyHabitProps } from "src/types/habit-card";
import { HabitCard } from "./habitcard";
import { t } from "src/translations/helper";
import TaskProgressBarPlugin from "src/index";
export class DailyHabitCard extends HabitCard {
constructor(
public habit: DailyHabitProps,
public container: HTMLElement,
public plugin: TaskProgressBarPlugin
) {
super(habit, container, plugin);
}
render(): void {
super.render();
const card = this.container.createDiv({
cls: "habit-card daily-habit-card",
});
const header = card.createDiv({ cls: "card-header" });
const titleDiv = header.createDiv({ cls: "card-title" });
const iconEl = titleDiv.createSpan({ cls: "habit-icon" });
setIcon(iconEl, (this.habit.icon as string) || "dice"); // Use default icon 'dice' if none provided
// Add completion text indicator if defined
const titleText = this.habit.completionText
? `${this.habit.name} (${this.habit.completionText})`
: this.habit.name;
titleDiv
.createSpan({ text: titleText, cls: "habit-name" })
.onClickEvent(() => {
new Notice(`Chart for ${this.habit.name} (Not Implemented)`);
// TODO: Implement Chart Dialog
});
const checkboxContainer = header.createDiv({
cls: "habit-checkbox-container",
});
const checkbox = checkboxContainer.createEl("input", {
type: "checkbox",
cls: "habit-checkbox",
});
const today = new Date().toISOString().split("T")[0];
// Check if completed based on completion text or any value
let isCompletedToday = false;
const todayValue = this.habit.completions[today];
if (this.habit.completionText) {
// If completionText is defined, check if value is 1 (meaning it matched completionText)
isCompletedToday = todayValue === 1;
} else {
// Default behavior: any truthy value means completed
isCompletedToday = !!todayValue;
}
checkbox.checked = isCompletedToday;
checkbox.addEventListener("click", (e) => {
e.preventDefault(); // Prevent default toggle, handle manually
this.toggleHabitCompletion(this.habit.id);
// TODO: Trigger confetti if needed
if (!isCompletedToday) {
// Optional: trigger confetti only on completion
new Notice(`${t("Completed")} ${this.habit.name}! 🎉`);
}
});
const contentWrapper = card.createDiv({ cls: "card-content-wrapper" });
this.renderHeatmap(
contentWrapper,
this.habit.completions,
"lg",
(value: any) => {
// If completionText is defined, check if value is 1 (meaning it matched completionText)
if (this.habit.completionText) {
return value === 1;
}
// Default behavior: any truthy value means completed
return value > 0;
}
);
}
}

View file

@ -0,0 +1,240 @@
import { Component } from "obsidian";
import {
DailyHabitProps,
HabitProps,
MappingHabitProps,
} from "src/types/habit-card";
import TaskProgressBarPlugin from "src/index";
function getDatesInRange(startDate: string, endDate: string): string[] {
const dates = [];
let currentDate = new Date(startDate);
const endDateObj = new Date(endDate);
while (currentDate <= endDateObj) {
dates.push(
`${currentDate.getFullYear()}-${String(
currentDate.getMonth() + 1
).padStart(2, "0")}-${String(currentDate.getDate()).padStart(
2,
"0"
)}`
);
currentDate.setDate(currentDate.getDate() + 1);
}
return dates;
}
export class HabitCard extends Component {
heatmapDateRange: number = 30; // Default number of days for heatmaps
constructor(
public habit: HabitProps,
public container: HTMLElement,
public plugin: TaskProgressBarPlugin
) {
super();
}
render(): void {
// Base rendering logic
this.container.empty();
}
getHabitData(): HabitProps[] {
const habits = this.plugin.habitManager?.habits || [];
return habits;
}
renderProgressBar(container: HTMLElement, value: number, max: number) {
const progressContainer = container.createDiv({
cls: "habit-progress-container",
});
const progressBar = progressContainer.createDiv({
cls: "habit-progress-bar",
});
const progressText = progressContainer.createDiv({
cls: "habit-progress-text",
});
value = Math.max(0, value); // Ensure value is not negative
max = Math.max(1, max); // Ensure max is at least 1 to avoid division by zero
const percentage =
max > 0 ? Math.min(100, Math.max(0, (value / max) * 100)) : 0;
progressBar.style.width = `${percentage}%`;
progressText.setText(`${value}/${max}`);
progressContainer.setAttribute(
"aria-label",
`Progress: ${value} out of ${max}`
);
}
// Basic heatmap renderer (shows last N days)
renderHeatmap(
container: HTMLElement,
completions: Record<string, any>,
size: "sm" | "md" | "lg",
getVariantCondition: (value: any) => boolean, // Function to determine if cell is "filled"
getCellValue?: (value: any) => string | HTMLElement | null // Optional function to get custom cell content
) {
const heatmapRoot = container.createDiv({
cls: `heatmap-root heatmap-${size}`,
});
const heatmapContainer = heatmapRoot.createDiv({
cls: `heatmap-container-simple`,
});
const endDate = new Date();
const startDate = new Date(
endDate.getTime() -
(this.heatmapDateRange - 1) * 24 * 60 * 60 * 1000
);
const dates = getDatesInRange(
startDate.toISOString().split("T")[0],
endDate.toISOString().split("T")[0]
);
// Render dates in reverse chronological order (most recent first)
dates.reverse().forEach((date) => {
const cellValue = completions[date];
const isFilled = getVariantCondition(cellValue);
const customContent = getCellValue ? getCellValue(cellValue) : null;
const cell = heatmapContainer.createDiv({
cls: `heatmap-cell heatmap-cell-square`, // Base class
});
cell.dataset.date = date;
// Determine tooltip content
let tooltipText = `${date}: `;
if (cellValue === undefined || cellValue === null) {
tooltipText += "Missed";
} else if (typeof cellValue === "object") {
// For scheduled: handled by custom renderer's aria-label
if (!cell.hasAttribute("aria-label")) {
// Set default if not set by custom renderer
tooltipText += "Recorded";
}
} else if (typeof cellValue === "number" && size === "sm") {
// Count habit
tooltipText += `${cellValue} times`;
} else if (typeof cellValue === "number" && customContent) {
// Mapping habit (emoji shown)
tooltipText += `${
customContent instanceof HTMLElement
? customContent.textContent
: customContent
}`; // Show emoji
} else if (isFilled) {
tooltipText += "Completed";
} else {
tooltipText += "Missed";
}
if (!cell.hasAttribute("aria-label")) {
cell.setAttribute("aria-label", tooltipText);
}
if (customContent) {
cell.addClass("has-custom-content");
if (typeof customContent === "string") {
cell.addClass("has-text-content");
cell.setText(customContent);
} else if (customContent instanceof HTMLElement) {
cell.appendChild(customContent);
}
} else if (isFilled) {
cell.addClass("filled");
} else {
cell.addClass("default");
}
});
}
toggleHabitCompletion(habitId: string, data?: any) {
console.log(`Toggling completion for ${habitId}`, data);
// 1. Get current habit state (use a deep copy to avoid mutation issues)
const currentHabits = this.getHabitData(); // In real scenario, fetch from indexer
const habitIndex = currentHabits.findIndex((h) => h.id === habitId);
if (habitIndex === -1) {
console.error("Habit not found:", habitId);
return;
}
// Create a deep copy to modify - simple version for this example
const habitToUpdate = JSON.parse(
JSON.stringify(currentHabits[habitIndex])
);
const today = new Date().toISOString().split("T")[0];
// 2. Calculate new completion state based on habit type
let newCompletionValue: any;
habitToUpdate.completions = habitToUpdate.completions || {}; // Ensure completions exists
const currentCompletionToday = habitToUpdate.completions[today];
switch (habitToUpdate.type) {
case "daily":
const dailyHabit = habitToUpdate as DailyHabitProps;
if (dailyHabit.completionText) {
// If completionText is defined, toggle between 1 (completed) and 0 (not completed)
// 1 means the value matched completionText
newCompletionValue = currentCompletionToday === 1 ? 0 : 1;
} else {
// Default behavior: toggle between 0 and 1
newCompletionValue = currentCompletionToday ? 0 : 1;
}
break;
case "count":
newCompletionValue =
(typeof currentCompletionToday === "number"
? currentCompletionToday
: 0) + 1;
break;
case "scheduled":
if (!data || !data.id) {
console.error(
"Missing event data for scheduled habit toggle"
);
return;
}
// Ensure current completion is an object
const currentEvents =
typeof currentCompletionToday === "object" &&
currentCompletionToday !== null
? currentCompletionToday
: {};
newCompletionValue = {
...currentEvents,
[data.id]: data.details ?? "", // Store details, default to empty string
};
break;
case "mapping":
if (
data === undefined ||
data === null ||
typeof data !== "number"
) {
console.error("Invalid value for mapping habit toggle");
return;
}
const mappingHabit = habitToUpdate as MappingHabitProps;
// Ensure the value is valid for this mapping
if (!mappingHabit.mapping[data]) {
console.error(`Invalid mapping value: ${data}`);
return;
}
newCompletionValue = data; // Value comes from slider/button
break;
default:
console.error("Unhandled habit type in toggleCompletion");
return;
}
// Update the completion for today
habitToUpdate.completions[today] = newCompletionValue;
this.plugin.habitManager?.updateHabitInObsidian(habitToUpdate, today);
}
}

View file

@ -0,0 +1,5 @@
export { HabitCard } from "./habitcard";
export { DailyHabitCard } from "./dailyhabitcard";
export { CountHabitCard } from "./counthabitcard";
export { ScheduledHabitCard } from "./scheduledhabitcard";
export { MappingHabitCard } from "./mappinghabitcard";

View file

@ -0,0 +1,159 @@
import { Component, Notice, setIcon, Setting } from "obsidian";
import { MappingHabitProps } from "src/types/habit-card";
import { HabitCard } from "./habitcard";
import TaskProgressBarPlugin from "src";
export class MappingHabitCard extends HabitCard {
constructor(
public habit: MappingHabitProps,
public container: HTMLElement,
public plugin: TaskProgressBarPlugin
) {
super(habit, container, plugin);
}
render(): void {
super.render();
const card = this.container.createDiv({
cls: "habit-card mapping-habit-card",
});
const header = card.createDiv({ cls: "card-header" });
const titleDiv = header.createDiv({ cls: "card-title" });
const iconEl = titleDiv.createSpan({ cls: "habit-icon" });
setIcon(iconEl, (this.habit.icon as string) || "smile-plus"); // Better default icon
titleDiv.createSpan({ text: this.habit.name, cls: "habit-name" });
const contentWrapper = card.createDiv({ cls: "card-content-wrapper" });
const heatmapContainer = contentWrapper.createDiv({
cls: "habit-heatmap-medium",
});
this.renderHeatmap(
heatmapContainer,
this.habit.completions,
"md",
(value: any) => typeof value === "number" && value > 0, // Check if it's a positive number
(value: number) => {
// Custom renderer for emoji
if (typeof value !== "number" || value <= 0) return null;
const emoji = this.habit.mapping?.[value] || "?";
const cellContent = createSpan({ text: emoji });
// Add tooltip showing the mapped value label if available
if (this.habit.mapping && this.habit.mapping[value]) {
cellContent.setAttribute(
"aria-label",
`${this.habit.mapping[value]}`
);
cellContent.addClass("has-tooltip");
} else {
cellContent.setAttribute("aria-label", `Value: ${value}`);
}
return cellContent;
}
);
const controlsDiv = contentWrapper.createDiv({ cls: "habit-controls" });
const today = new Date().toISOString().split("T")[0];
const defaultValue = Object.keys(this.habit.mapping || {})
.map(Number)
.includes(3)
? 3
: Object.keys(this.habit.mapping || {})
.map(Number)
.sort((a, b) => a - b)[0] || 1;
let currentSelection = this.habit.completions[today] ?? defaultValue;
// Display current selection with emoji and label
const moodDisplay = controlsDiv.createDiv({
cls: "habit-mood-display",
});
const moodEmoji = moodDisplay.createSpan({
cls: "habit-mood-emoji",
text: this.habit.mapping?.[currentSelection] || "?",
});
// Add label if available
if (this.habit.mapping && this.habit.mapping[currentSelection]) {
moodDisplay.createSpan({
cls: "habit-mood-label",
text: this.habit.mapping[currentSelection],
});
}
const moodButton = controlsDiv.createEl("button", {
cls: "habit-mood-button",
text: "Record",
});
moodButton.addEventListener("click", () => {
if (
currentSelection > 0 &&
this.habit.mapping?.[currentSelection]
) {
// Ensure a valid selection is made
this.toggleHabitCompletion(this.habit.id, currentSelection);
const noticeText =
this.habit.mapping && this.habit.mapping[currentSelection]
? `Recorded ${this.habit.name} as ${this.habit.mapping[currentSelection]}`
: `Recorded ${this.habit.name} as ${this.habit.mapping[currentSelection]}`;
new Notice(noticeText);
} else {
new Notice(
"Please select a valid value using the slider first."
);
}
});
// Slider using Obsidian Setting
new Setting(controlsDiv)
.setClass("habit-slider-setting")
.addSlider((slider) => {
const mappingKeys = Object.keys(this.habit.mapping || {})
.map(Number)
.sort((a, b) => a - b);
const min = mappingKeys[0] || 1;
const max = mappingKeys[mappingKeys.length - 1] || 5;
slider
.setLimits(min, max, 1)
.setValue(currentSelection)
.setDynamicTooltip()
.onChange((value) => {
currentSelection = value;
// Update emoji display
moodEmoji.setText(
this.habit.mapping?.[currentSelection] || "?"
);
// Update label if available
const labelEl =
moodDisplay.querySelector(".habit-mood-label");
if (labelEl) {
if (
this.habit.mapping &&
this.habit.mapping[currentSelection]
) {
labelEl.textContent =
this.habit.mapping[currentSelection];
} else {
labelEl.textContent = "";
}
} else if (
this.habit.mapping &&
this.habit.mapping[currentSelection]
) {
// Create label if it doesn't exist but should
moodDisplay.createSpan({
cls: "habit-mood-label",
text: this.habit.mapping[currentSelection],
});
}
});
});
}
}

View file

@ -0,0 +1,157 @@
import { Component, Notice, setIcon, Setting } from "obsidian";
import { ScheduledHabitProps } from "src/types/habit-card";
import { HabitCard } from "./habitcard";
import TaskProgressBarPlugin from "src";
import { t } from "src/translations/helper";
import { EventDetailModal } from "../habit";
function renderPieDotSVG(completed: number, total: number): string {
if (total <= 0) return "";
const percentage = (completed / total) * 100;
const radius = 8; // SVG viewbox units
const circumference = 2 * Math.PI * radius;
const offset = circumference - (percentage / 100) * circumference;
// Simple SVG circle progress
return `
<svg viewBox="0 0 20 20" width="100%" height="100%">
<circle cx="10" cy="10" r="${radius}" fill="transparent" stroke="var(--background-modifier-border)" stroke-width="3"></circle>
<circle cx="10" cy="10" r="${radius}" fill="transparent"
stroke="var(--interactive-accent)"
stroke-width="3"
stroke-dasharray="${circumference}"
stroke-dashoffset="${offset}"
transform="rotate(-90 10 10)">
</circle>
${
completed > 0
? `<text x="10" y="10" text-anchor="middle" dy=".3em" font-size="8px" fill="var(--text-muted)">${completed}</text>`
: ""
}
</svg>
`;
}
export class ScheduledHabitCard extends HabitCard {
constructor(
public habit: ScheduledHabitProps,
public container: HTMLElement,
public plugin: TaskProgressBarPlugin
) {
super(habit, container, plugin);
}
render(): void {
super.render();
const card = this.container.createDiv({
cls: "habit-card scheduled-habit-card",
});
const header = card.createDiv({ cls: "card-header" });
const titleDiv = header.createDiv({ cls: "card-title" });
const iconEl = titleDiv.createSpan({ cls: "habit-icon" });
setIcon(iconEl, (this.habit.icon as string) || "calendar-clock"); // Better default icon
titleDiv
.createSpan({ text: this.habit.name, cls: "habit-name" })
.onClickEvent(() => {
new Notice(`Chart for ${this.habit.name} (Not Implemented)`);
// TODO: Implement Chart Dialog
});
const contentWrapper = card.createDiv({ cls: "card-content-wrapper" });
const heatmapContainer = contentWrapper.createDiv({
cls: "habit-heatmap-medium",
});
this.renderHeatmap(
heatmapContainer,
this.habit.completions,
"md",
(value: any) =>
value &&
typeof value === "object" &&
Object.keys(value).length > 0, // Check if it's an object with keys
(value: Record<string, string>) => {
// Custom cell renderer
if (
!value ||
typeof value !== "object" ||
Object.keys(value).length === 0
)
return null;
const completedCount = Object.keys(value).length;
// Ensure events array exists and has length
const totalEvents = Array.isArray(this.habit.events)
? this.habit.events.length
: 0;
const pieDiv = createDiv({ cls: "pie-dot-container" });
pieDiv.innerHTML = renderPieDotSVG(completedCount, totalEvents);
// Add tooltip showing completed events for the day
const tooltipText = Object.entries(value)
.map(([name, detail]) =>
detail ? `${name}: ${detail}` : name
)
.join("\n");
pieDiv.setAttribute(
"aria-label",
tooltipText || "No events completed"
);
return pieDiv;
}
);
const controlsDiv = contentWrapper.createDiv({ cls: "habit-controls" });
const today = new Date().toISOString().split("T")[0];
// Ensure completions for today exists and is an object
const todaysCompletions: Record<string, string> =
typeof this.habit.completions[today] === "object" &&
this.habit.completions[today] !== null
? this.habit.completions[today]
: {};
const completedEventsToday = Object.keys(todaysCompletions).length;
const totalEvents = Array.isArray(this.habit.events)
? this.habit.events.length
: 0;
const allEventsDoneToday =
totalEvents > 0 && completedEventsToday >= totalEvents;
// Use Obsidian Setting for dropdown
new Setting(controlsDiv)
.setClass("habit-event-dropdown") // Add class for styling
.addDropdown((dropdown) => {
dropdown.addOption(
"",
allEventsDoneToday ? t("All Done!") : t("Select event...")
);
if (Array.isArray(this.habit.events)) {
this.habit.events.forEach((event) => {
// Ensure event name exists and is not already completed
if (event?.name && !todaysCompletions[event.name]) {
dropdown.addOption(event.name, event.name);
}
});
}
dropdown.setValue(""); // Reset selection
dropdown.onChange((eventName) => {
if (eventName) {
// Open modal to get details
new EventDetailModal(
this.plugin.app,
eventName,
(details: string) => {
this.toggleHabitCompletion(this.habit.id, {
id: eventName,
details: details,
});
}
).open();
}
// Reset dropdown after selection or modal close
dropdown.setValue("");
});
dropdown.setDisabled(allEventsDoneToday || totalEvents === 0);
});
this.renderProgressBar(controlsDiv, completedEventsToday, totalEvents);
}
}

View file

@ -53,7 +53,7 @@ export class SidebarComponent extends Component {
// 将视图分成顶部组和底部组
const topViews = [];
const bottomViews = ["calendar", "gantt", "kanban"]; // 这些视图将放在底部
const bottomViews = ["habit", "calendar", "gantt", "kanban"]; // 这些视图将放在底部
// 首先渲染顶部组视图
this.plugin.settings.viewConfiguration.forEach((viewConfig) => {

View file

@ -67,6 +67,7 @@ import { TaskSpecificView } from "./pages/TaskSpecificView";
import { TASK_SPECIFIC_VIEW_TYPE } from "./pages/TaskSpecificView";
import { getTaskGeniusIcon } from "./icon";
import { RewardManager } from "./utils/RewardManager";
import { HabitManager } from "./utils/HabitManager";
class TaskProgressBarPopover extends HoverPopover {
plugin: TaskProgressBarPlugin;
@ -151,6 +152,8 @@ export default class TaskProgressBarPlugin extends Plugin {
rewardManager: RewardManager;
habitManager: HabitManager;
// Preloaded tasks:
preloadedTasks: Task[] = [];
@ -185,6 +188,11 @@ export default class TaskProgressBarPlugin extends Plugin {
this.addChild(this.rewardManager);
}
if (this.settings.habit.enableHabits) {
this.habitManager = new HabitManager(this);
this.addChild(this.habitManager);
}
this.registerCommands();
this.registerEditorExt();

View file

@ -37,6 +37,7 @@ import { CalendarComponent, CalendarEvent } from "../components/calendar";
import { KanbanComponent } from "../components/kanban/kanban";
import { GanttComponent } from "../components/gantt/gantt";
import { TaskPropertyTwoColumnView } from "../components/task-view/TaskPropertyTwoColumnView";
import { Habit } from "src/components/habit/habit";
export const TASK_VIEW_TYPE = "task-genius-view";
@ -67,6 +68,7 @@ export class TaskView extends ItemView {
private currentSelectedTaskId: string | null = null;
private currentSelectedTaskDOM: HTMLElement | null = null;
private lastToggleTimestamp: number = 0;
private habitComponent: Habit;
private tabActionButton: HTMLElement;
@ -394,6 +396,10 @@ export class TaskView extends ItemView {
this.addChild(this.ganttComponent);
this.ganttComponent.containerEl.hide();
this.habitComponent = new Habit(this.plugin, this.rootContainerEl);
this.addChild(this.habitComponent);
this.habitComponent.containerEl.hide();
this.detailsComponent = new TaskDetailsComponent(
this.rootContainerEl,
this.app,
@ -558,7 +564,7 @@ export class TaskView extends ItemView {
this.twoColumnViewComponents.forEach((component) => {
component.containerEl.hide();
});
this.habitComponent.containerEl.hide();
this.calendarComponent.containerEl.hide();
this.kanbanComponent.containerEl.hide();
this.ganttComponent.containerEl.hide();
@ -605,6 +611,9 @@ export class TaskView extends ItemView {
} else {
// Standard view types
switch (viewId) {
case "habit":
targetComponent = this.habitComponent;
break;
case "forecast":
targetComponent = this.forecastComponent;
break;

View file

@ -2725,6 +2725,7 @@ export class TaskProgressBarSettingTab extends PluginSettingTab {
// Edit button - Now available for ALL views to edit rules/name/icon
viewSetting.addExtraButton((button) => {
button
.setDisabled(view.id === "habit")
.setIcon("pencil")
.setTooltip(t("Edit View"))
.onClick(() => {

450
src/styles/habit.css Normal file
View file

@ -0,0 +1,450 @@
.tg-habit-component-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 1rem; /* gap-4 */
padding: 1rem; /* p-4 */
height: 100%; /* Allow scrolling if content overflows */
overflow-y: auto;
}
/* === Habit List Container === */
.habit-list-container {
display: grid;
grid-template-columns: repeat(1, minmax(0, 1fr));
gap: 1rem; /* gap-4 */
}
/* Medium breakpoint (md:) equivalent ~768px */
@media (min-width: 768px) {
.habit-list-container {
/* Example: Switch to 2 columns on wider screens */
/* grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); */
/* Or fixed 2 columns */
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* Adjust max-width/columns based on desired layout */
/* === Base Card Styles === */
.habit-card {
border: 1px solid var(--background-modifier-border);
border-radius: var(--radius-m); /* Standard medium radius */
background-color: var(--background-secondary);
color: var(--text-normal);
overflow: hidden;
display: flex;
flex-direction: column; /* Stack header and content */
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem; /* Smaller padding than p-4 px-6 */
gap: 0.5rem; /* gap-2 */
border-bottom: 1px solid var(--background-modifier-border); /* Separator */
}
.card-title {
display: flex;
align-items: center;
gap: 0.5rem; /* gap-2 */
font-size: var(--font-ui-large); /* Larger UI font */
font-weight: 600; /* font-semibold */
flex-grow: 1; /* Allow title to take space */
overflow: hidden; /* Prevent long names breaking layout */
white-space: nowrap;
text-overflow: ellipsis;
}
.habit-name.habit-name:hover {
/* Specificity for hover */
text-decoration: underline;
cursor: pointer;
}
.card-content-wrapper {
padding: 0.75rem 1rem; /* Consistent padding */
flex-grow: 1; /* Allow content to fill space */
/* Layout specifics defined per card type */
}
/* === Daily Habit Card === */
.daily-habit-card .card-header {
/* No border needed if checkbox is main action */
/* border-bottom: none; */
}
.daily-habit-card .habit-checkbox-container {
/* Style container if needed */
}
.daily-habit-card .habit-checkbox {
width: 1.25rem; /* w-5 */
height: 1.25rem; /* h-5 */
cursor: pointer;
accent-color: var(--interactive-accent); /* Style checkbox color */
}
.daily-habit-card .card-content-wrapper {
/* Contains heatmap */
padding: 0.5rem 1rem 0.75rem; /* Adjust padding around heatmap */
}
/* === Count Habit Card === */
.count-habit-card .card-content-wrapper {
display: flex;
flex-direction: column; /* Default mobile layout */
gap: 0.75rem; /* Adjusted gap */
align-items: center;
}
.count-habit-card .habit-icon-button {
height: 3.5rem; /* Slightly smaller than h-16 */
width: 3.5rem;
padding: 0;
border: 1px solid var(--button-secondary-border-color);
background-color: var(--button-secondary-bg);
color: var(--button-secondary-color);
cursor: pointer;
border-radius: var(--radius-s); /* Smaller radius */
display: flex;
justify-content: center;
align-items: center;
font-size: 1.5rem; /* For icon */
}
.count-habit-card .habit-icon-button:hover {
background-color: var(--button-secondary-hover-bg);
}
.count-habit-card .habit-info {
display: flex;
flex-direction: column;
align-items: center; /* Center text on mobile */
text-align: center;
flex-grow: 1;
}
.count-habit-card .habit-info h3 {
font-size: var(--font-ui-large);
font-weight: 600;
}
.count-habit-card .habit-progress-area {
width: 100%; /* Full width on mobile */
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
/* Tablet/Desktop layout for Count Card */
@media (min-width: 640px) {
/* sm breakpoint */
.count-habit-card .card-content-wrapper {
flex-direction: row;
align-items: center; /* Align items vertically */
gap: 1rem;
}
.count-habit-card .habit-info {
align-items: flex-start; /* Left-align text */
text-align: left;
}
.count-habit-card .habit-progress-area {
width: auto; /* Allow it to shrink */
min-width: 150px; /* Ensure minimum width for heatmap/progress */
align-items: flex-end; /* Align progress to the right */
}
.count-habit-card .habit-heatmap-small {
/* Allow heatmap to define its own width */
}
}
/* === Scheduled Habit Card === */
.scheduled-habit-card .card-header {
padding-bottom: 0.5rem; /* pb-2 */
}
.scheduled-habit-card .card-content-wrapper {
display: flex;
flex-direction: column; /* Mobile default */
gap: 0.75rem;
align-items: center; /* Center on mobile */
}
.scheduled-habit-card .habit-heatmap-medium {
width: 100%; /* Full width heatmap on mobile */
}
.scheduled-habit-card .habit-controls {
width: 100%; /* Full width controls on mobile */
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center; /* Center controls on mobile */
}
.scheduled-habit-card .habit-event-dropdown .setting-item-control select {
width: 100%; /* Full width dropdown */
max-width: 200px; /* Limit max width */
/* Use Obsidian's dropdown styles */
}
/* Hide the setting name/description by default */
.scheduled-habit-card .habit-event-dropdown .setting-item-info {
display: none;
}
.scheduled-habit-card .habit-event-dropdown .setting-item {
width: 100%; /* Make setting take full width of controls */
padding: 0; /* Remove default setting padding */
border: none; /* Remove default setting border */
}
/* Tablet/Desktop layout for Scheduled Card */
@media (min-width: 640px) {
.scheduled-habit-card .card-content-wrapper {
flex-direction: row;
align-items: flex-start; /* Align top */
justify-content: space-between;
}
.scheduled-habit-card .habit-heatmap-medium {
width: auto; /* Allow heatmap to shrink */
flex-grow: 1; /* Allow heatmap to take available space */
margin-right: 1rem; /* Space between heatmap and controls */
}
.scheduled-habit-card .habit-controls {
width: auto; /* Shrink controls */
min-width: 150px; /* Minimum width */
align-items: flex-start; /* Align controls left */
}
.scheduled-habit-card .habit-event-dropdown .setting-item-control select {
width: auto; /* Auto width dropdown */
}
}
/* === Mapping Habit Card === */
.mapping-habit-card .card-header {
padding-bottom: 0.5rem; /* pb-2 */
}
.mapping-habit-card .card-content-wrapper {
display: flex;
flex-direction: column; /* Mobile default */
gap: 0.75rem;
align-items: center;
}
.mapping-habit-card .habit-heatmap-medium {
width: 100%; /* Full width heatmap on mobile */
}
.mapping-habit-card .habit-controls {
width: 100%; /* Full width controls on mobile */
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.mapping-habit-card .habit-mood-button {
display: flex;
justify-content: center;
align-items: center;
font-size: 1.75rem; /* Larger emoji/text */
padding: 0.5rem;
width: 100%;
max-width: 100px; /* Limit button width */
height: 3.5rem; /* h-16 equivalent */
border: 1px solid var(--button-secondary-border-color);
background-color: var(--button-secondary-bg);
color: var(--text-normal); /* Ensure emoji visibility */
cursor: pointer;
border-radius: var(--radius-s);
}
.mapping-habit-card .habit-mood-button:hover {
background-color: var(--button-secondary-hover-bg);
}
.mapping-habit-card .habit-slider-setting {
width: 100%; /* Slider takes full width */
max-width: 200px; /* Limit slider width */
}
.mapping-habit-card .habit-slider-setting .setting-item-info {
display: none; /* Hide slider label */
}
.mapping-habit-card .habit-slider-setting .setting-item {
width: 100%;
padding: 0;
border: none;
}
.mapping-habit-card .habit-slider-setting .setting-item-control {
width: 100%; /* Make slider control take full width */
}
/* Tablet/Desktop layout for Mapping Card */
@media (min-width: 640px) {
.mapping-habit-card .card-content-wrapper {
flex-direction: row;
align-items: center; /* Center items vertically */
justify-content: space-between;
}
.mapping-habit-card .habit-heatmap-medium {
width: auto;
flex-grow: 1;
margin-right: 1rem;
}
.mapping-habit-card .habit-controls {
width: auto;
min-width: 80px; /* Width for button + slider */
flex-direction: column; /* Keep button above slider */
align-items: center;
gap: 0.75rem;
}
.mapping-habit-card .habit-mood-button {
width: 4rem; /* Fixed width button */
height: 4rem;
}
.mapping-habit-card .habit-slider-setting {
width: 100%; /* Slider takes width of control area */
max-width: none;
}
}
/* === Progress Bar (Common for Count/Scheduled) === */
.habit-progress-container {
width: 100%;
height: 0.75rem; /* Slightly thicker */
background-color: var(--background-modifier-border);
border-radius: var(--radius-l); /* Pill shape */
overflow: hidden;
position: relative; /* For text overlay */
}
.habit-progress-bar {
height: 100%;
background-color: var(--interactive-accent);
border-radius: var(--radius-l);
transition: width 0.3s ease-in-out;
}
.habit-progress-text {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
font-size: 0.6rem; /* Tiny text */
line-height: 1;
color: var(--text-on-accent); /* Text color on filled part */
mix-blend-mode: difference; /* Try to make text visible */
font-weight: 500;
}
/* === Heatmap Styles === */
.heatmap-root {
width: 100%; /* Take available width */
}
.heatmap-container-simple {
display: flex;
gap: 3px; /* Small gap between cells */
justify-content: flex-end; /* Align cells to the right by default */
overflow-x: auto; /* Allow horizontal scroll if too many cells */
padding-bottom: 2px; /* Space for scrollbar */
}
/* Specific adjustments for heatmap size classes */
.heatmap-sm .heatmap-container-simple {
/* For Count Card */
justify-content: center; /* Center the dots */
}
.heatmap-cell {
border-radius: var(--radius-xs); /* Small radius */
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
flex-shrink: 0; /* Prevent cells from shrinking */
background-color: var(
--background-modifier-border
); /* Default background */
border: 1px solid transparent; /* Border for blank/custom */
}
/* Heatmap Shapes */
.heatmap-cell-square {
/* Defined by size */
}
.heatmap-cell-dot {
border-radius: 50%;
}
/* Heatmap Sizes */
.heatmap-sm .heatmap-cell {
width: 0.75rem;
height: 0.75rem;
}
.heatmap-md .heatmap-cell {
width: 1.1rem;
height: 1.1rem;
font-size: 0.7rem;
} /* Mapping/Scheduled */
.heatmap-lg .heatmap-cell {
width: 1.25rem;
height: 1.25rem;
font-size: 0.75rem;
} /* Daily */
/* Heatmap Variants */
.heatmap-cell.default {
/* Default styles already set */
}
.heatmap-cell.filled {
background-color: var(--interactive-accent);
color: var(--text-on-accent); /* Text on filled (e.g., emoji) */
}
/* Style for cells with custom content like PieDot or Emoji */
.heatmap-cell.has-custom-content,
.heatmap-cell.has-text-content {
background-color: var(--background-secondary); /* Use card background */
border-color: var(--background-modifier-border); /* Add border */
color: var(--text-normal); /* Ensure text/emoji is visible */
}
.heatmap-cell.has-text-content {
line-height: 1; /* Center emoji vertically */
}
/* PieDot Container */
.pie-dot-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
.pie-dot-container svg {
display: block; /* Remove extra space below SVG */
}
/* === Empty State === */
.habit-empty-state {
text-align: center;
padding: 2rem 1rem;
color: var(--text-muted);
}
.habit-empty-state h2 {
font-size: var(--font-ui-large);
font-weight: 600;
margin-bottom: 0.5rem;
}
.habit-empty-state p {
font-size: var(--font-ui-normal);
color: var(--text-faint);
}
/* === Icons Placeholder === */
.habit-icon {
/* Basic icon placeholder style */
display: inline-block;
width: 1em; /* Match font size */
height: 1em;
line-height: 1;
text-align: center;
/* Replace with actual Obsidian icon styles later */
color: var(--text-muted);
font-style: italic;
margin-right: 0.25em; /* Space after icon */
}

79
src/types/habit-card.d.ts vendored Normal file
View file

@ -0,0 +1,79 @@
// 基础习惯类型
interface BaseHabitProps {
id: string;
name: string;
description?: string;
icon: string; // Lucide icon id
properties?: string[];
}
// 日常习惯类型
export interface DailyHabitProps extends BaseHabitProps {
type: "daily";
completions: Record<string, string | number>; // String is date, string or number is completion value
completionText?: string; // Custom text that represents completion (default is any non-empty value)
}
// 计数习惯类型
export interface CountHabitProps extends BaseHabitProps {
type: "count";
min?: number; // Minimum completion value
max?: number; // Maximum completion value
notice?: string; // Trigger notice when completion value is reached
completions: Record<string, number>; // String is date, number is completion value
countUnit?: string; // Optional unit for the count (e.g., "cups", "times")
}
export interface ScheduledEvent {
name: string;
details: string;
}
export interface ScheduledHabitProps extends BaseHabitProps {
type: "scheduled";
events: ScheduledEvent[];
completions: Record<string, Record<string, string>>; // String is date, Record<string, string> is event name and completion value
}
export interface MappingHabitProps extends BaseHabitProps {
type: "mapping";
mapping: Record<number, string>;
completions: Record<string, number>; // String is date, number is completion value
}
// 所有习惯类型的联合
export type HabitProps =
| DailyHabitProps
| CountHabitProps
| ScheduledHabitProps
| MappingHabitProps;
// 习惯卡片属性
export interface HabitCardProps {
habit: HabitProps;
toggleCompletion: (habitId: string, ...args: any[]) => void;
triggerConfetti?: (pos: {
x: number;
y: number;
width?: number;
height?: number;
}) => void;
}
interface MappingHabitCardProps extends HabitCardProps {
toggleCompletion: (habitId: string, value: number) => void;
}
interface ScheduledHabitCardProps extends HabitCardProps {
toggleCompletion: (
habitId: string,
{
id,
details,
}: {
id: string;
details: string;
}
) => void;
}

View file

@ -11,6 +11,7 @@ import {
TFile,
} from "obsidian";
import { Component } from "obsidian";
import { HabitProps } from "./habit-card";
interface Token extends EditorRange {
/** @todo Documentation incomplete. */
@ -305,12 +306,17 @@ declare module "obsidian" {
cm: EditorView;
}
interface MetadataTypeManager {
properties: Record<string, any>;
}
interface App {
commands: Commands;
setting: Setting;
embedRegistry: EmbedRegistry;
appId: string;
metadataTypeManager: MetadataTypeManager;
}
interface EmbedRegistry {
@ -370,6 +376,10 @@ declare module "obsidian" {
event: "task-genius:task-completed",
callback: (task: Task) => void
): EventRef;
on(
event: "task-genius:habit-index-updated",
callback: (habits: HabitProps[]) => void
): EventRef;
trigger(event: "task-genius:task-completed", task: Task): void;
trigger(event: "task-genius:task-added", task: Task): void;
@ -379,6 +389,10 @@ declare module "obsidian" {
event: "task-genius:task-cache-updated",
cache: TaskCache
): void;
trigger(
event: "task-genius:habit-index-updated",
habits: HabitProps[]
): void;
}
interface WorkspaceLeaf {

624
src/utils/HabitManager.ts Normal file
View file

@ -0,0 +1,624 @@
import {
App,
CachedMetadata,
Component,
debounce,
FrontMatterCache,
moment,
TFile,
} from "obsidian";
import {
HabitProps,
ScheduledHabitProps,
DailyHabitProps,
CountHabitProps,
MappingHabitProps,
BaseHabitProps,
} from "../types/habit-card";
import TaskProgressBarPlugin from "../index"; // Assuming HabitTracker is the main plugin class
import {
createDailyNote,
getAllDailyNotes,
getDailyNote,
getDateFromFile,
} from "obsidian-daily-notes-interface";
export class HabitManager extends Component {
private plugin: TaskProgressBarPlugin;
habits: HabitProps[] = [];
constructor(plugin: TaskProgressBarPlugin) {
super();
this.plugin = plugin;
}
async initializeHabits(): Promise<void> {
const dailyNotes = await this.getDailyNotes();
const initialHabits = await this.processHabits(dailyNotes);
this.habits = initialHabits;
this.registerEvent(
this.plugin.app.metadataCache.on(
"changed",
(file: TFile, _data: string, cache: CachedMetadata) => {
if (this.isDailyNote(file)) {
this.updateHabitCompletions(file, cache);
}
}
)
);
}
private async getDailyNotes(): Promise<TFile[]> {
const files = getAllDailyNotes();
return Object.values(files);
}
private isDailyNote(file: TFile): boolean {
try {
// Use 'day' to specifically target daily notes if weekly/monthly are handled differently
return getDateFromFile(file, "day") !== null;
} catch (e) {
// Handle cases where getDateFromFile might throw error for non-note files
// console.warn(`Could not determine if file is a daily note: ${file.path}`, e);
return false;
}
}
private async processHabits(dailyNotes: TFile[]): Promise<HabitProps[]> {
// Use a deep copy of settings habits to avoid modifying the source directly
const initialHabits: HabitProps[] = [];
const { habitKeyMap = {}, scheduledEventMap = {} } =
this.plugin.settings.habit || {};
for (const note of dailyNotes) {
if (!this.isDailyNote(note)) continue; // Skip non-daily notes
const cache = this.plugin.app.metadataCache.getFileCache(note);
const frontmatter = cache?.frontmatter;
if (frontmatter) {
const dateMoment = getDateFromFile(note, "day");
if (!dateMoment) continue; // Should not happen due to isDailyNote check, but belts and suspenders
const date = dateMoment.format("YYYY-MM-DD");
for (const habit of initialHabits) {
if (!habit.completions) habit.completions = {}; // Ensure completions object exists
const properties = habitKeyMap[habit.id] || [];
switch (habit.type) {
case "scheduled":
// Handle scheduled habits (journey habits)
const scheduledHabit = habit as ScheduledHabitProps;
const eventMap = scheduledEventMap[habit.id] || {};
if (!scheduledHabit.completions[date])
scheduledHabit.completions[date] = {};
for (const [
eventName,
propertyKey,
] of Object.entries(eventMap)) {
if (
propertyKey &&
frontmatter[propertyKey as string] !==
undefined
) {
const value =
frontmatter[propertyKey as string];
// Store the raw value or format it as needed
scheduledHabit.completions[date][
eventName
] = value ?? "";
}
}
break;
case "daily":
// Handle daily habits with custom completion text
const dailyHabit = habit as DailyHabitProps;
for (const property of properties) {
if (
property &&
frontmatter[property] !== undefined
) {
const value = frontmatter[property];
// If completionText is defined, check if value matches it
if (dailyHabit.completionText) {
// If value matches completionText, mark as completed (1)
// Otherwise, store the actual text value
if (
value === dailyHabit.completionText
) {
dailyHabit.completions[date] = 1;
} else {
dailyHabit.completions[date] =
value as string;
}
} else {
// Default behavior: any non-empty value means completed
dailyHabit.completions[date] = value
? 1
: 0;
}
break; // Use the first found property
}
}
break;
case "count":
// Handle count habits
const countHabit = habit as CountHabitProps;
for (const property of properties) {
if (
property &&
frontmatter[property] !== undefined
) {
const value = frontmatter[property];
// For count habits, try to parse as number
const numValue = Number(value);
if (!isNaN(numValue)) {
countHabit.completions[date] = numValue;
}
break; // Use the first found property
}
}
break;
case "mapping":
// Handle mapping habits
const mappingHabit = habit as MappingHabitProps;
for (const property of properties) {
if (
property &&
frontmatter[property] !== undefined
) {
const value = frontmatter[property];
// For mapping habits, try to parse as number
const numValue = Number(value);
if (
!isNaN(numValue) &&
mappingHabit.mapping[numValue]
) {
mappingHabit.completions[date] =
numValue;
}
break; // Use the first found property
}
}
break;
default:
// Fallback for any other habit types
let completionValue: number | undefined = undefined;
for (const property of properties) {
if (
property &&
frontmatter[property] !== undefined
) {
const value = frontmatter[property];
// Simple habit: expect boolean or number-like
completionValue =
Number(value) || (!!value ? 1 : 0);
break; // Use the first found property
}
}
if (completionValue !== undefined) {
(habit as DailyHabitProps).completions[date] =
completionValue;
}
break;
}
}
}
}
return initialHabits;
}
private updateHabitCompletions(file: TFile, cache: CachedMetadata): void {
if (!cache?.frontmatter) return;
const dateMoment = getDateFromFile(file, "day");
if (!dateMoment) return; // Not a daily note
const dateStr = dateMoment.format("YYYY-MM-DD");
let habitsChanged = false;
const updatedHabits = this.habits.map((habit) => {
const habitClone = JSON.parse(JSON.stringify(habit)) as HabitProps; // Work on a clone
if (!habitClone.completions) habitClone.completions = {};
const { habitKeyMap = {}, scheduledEventMap = {} } =
this.plugin.settings.habit || {};
switch (habitClone.type) {
case "scheduled":
// Handle scheduled habits (journey habits)
const scheduledHabit = habitClone as ScheduledHabitProps;
const eventMap = scheduledEventMap[habit.id] || {};
if (!scheduledHabit.completions[dateStr])
scheduledHabit.completions[dateStr] = {};
let eventChanged = false;
for (const [eventName, propertyKey] of Object.entries(
eventMap
)) {
if (
propertyKey &&
cache.frontmatter?.[propertyKey as string] !==
undefined
) {
const newValue =
cache.frontmatter[propertyKey as string] ?? "";
if (
scheduledHabit.completions[dateStr][
eventName
] !== newValue
) {
scheduledHabit.completions[dateStr][eventName] =
newValue;
eventChanged = true;
}
} else if (
scheduledHabit.completions[dateStr]?.[eventName] !==
undefined
) {
// Handle case where property might have been removed from frontmatter
delete scheduledHabit.completions[dateStr][
eventName
];
eventChanged = true;
}
}
if (eventChanged) habitsChanged = true;
break;
case "daily":
// Handle daily habits with custom completion text
const dailyHabit = habitClone as DailyHabitProps;
const dailyProperties = habitKeyMap[habit.id] || [];
let foundDailyProperty = false;
for (const property of dailyProperties) {
if (
property &&
cache.frontmatter?.[property] !== undefined
) {
foundDailyProperty = true;
const value = cache.frontmatter[property];
// If completionText is defined, check if value matches it
if (dailyHabit.completionText) {
const newValue =
value === dailyHabit.completionText
? 1
: (value as string);
if (
dailyHabit.completions[dateStr] !== newValue
) {
dailyHabit.completions[dateStr] = newValue;
habitsChanged = true;
}
} else {
// Default behavior: any non-empty value means completed
const newValue = value ? 1 : 0;
if (
dailyHabit.completions[dateStr] !== newValue
) {
dailyHabit.completions[dateStr] = newValue;
habitsChanged = true;
}
}
break; // Use the first found property
}
}
if (
!foundDailyProperty &&
dailyHabit.completions[dateStr] !== undefined
) {
delete dailyHabit.completions[dateStr];
habitsChanged = true;
}
break;
case "count":
// Handle count habits
const countHabit = habitClone as CountHabitProps;
const countProperties = habitKeyMap[habit.id] || [];
let foundCountProperty = false;
for (const property of countProperties) {
if (
property &&
cache.frontmatter?.[property] !== undefined
) {
foundCountProperty = true;
const value = cache.frontmatter[property];
const numValue = Number(value);
if (
!isNaN(numValue) &&
countHabit.completions[dateStr] !== numValue
) {
countHabit.completions[dateStr] = numValue;
habitsChanged = true;
}
break; // Use the first found property
}
}
if (
!foundCountProperty &&
countHabit.completions[dateStr] !== undefined
) {
delete countHabit.completions[dateStr];
habitsChanged = true;
}
break;
case "mapping":
// Handle mapping habits
const mappingHabit = habitClone as MappingHabitProps;
const mappingProperties = habitKeyMap[habit.id] || [];
let foundMappingProperty = false;
for (const property of mappingProperties) {
if (
property &&
cache.frontmatter?.[property] !== undefined
) {
foundMappingProperty = true;
const value = cache.frontmatter[property];
const numValue = Number(value);
if (
!isNaN(numValue) &&
mappingHabit.mapping[numValue] &&
mappingHabit.completions[dateStr] !== numValue
) {
mappingHabit.completions[dateStr] = numValue;
habitsChanged = true;
}
break; // Use the first found property
}
}
if (
!foundMappingProperty &&
mappingHabit.completions[dateStr] !== undefined
) {
delete mappingHabit.completions[dateStr];
habitsChanged = true;
}
break;
default:
// Fallback for any other habit types
const properties = habitKeyMap[habit.id] || [];
let completionValue: number | undefined = undefined;
let foundProperty = false;
for (const property of properties) {
if (
property &&
cache.frontmatter?.[property] !== undefined
) {
foundProperty = true;
const value = cache.frontmatter[property];
completionValue =
Number(value) || (!!value ? 1 : 0);
break;
}
}
const currentValue = (habitClone as DailyHabitProps)
.completions[dateStr];
if (foundProperty && currentValue !== completionValue) {
((habitClone as DailyHabitProps).completions as any)[
dateStr
] = completionValue as number;
habitsChanged = true;
} else if (!foundProperty && currentValue !== undefined) {
delete (habitClone as DailyHabitProps).completions[
dateStr
];
habitsChanged = true;
}
break;
}
return habitClone; // Return the updated clone
});
if (habitsChanged) {
// Update state without tracking in history for background updates
this.habits = updatedHabits;
this.plugin.app.workspace.trigger(
"task-genius:habit-index-updated",
this.habits
);
}
}
async updateHabitInObsidian(
updatedHabit: HabitProps,
date: string
): Promise<void> {
const app: App = this.plugin.app;
const momentDate = moment(date, "YYYY-MM-DD");
if (!momentDate.isValid()) {
console.error(
`Invalid date format provided: ${date}. Expected YYYY-MM-DD.`
);
return;
}
let dailyNote: TFile | null = null;
try {
dailyNote = getDailyNote(momentDate, getAllDailyNotes());
if (!dailyNote) {
dailyNote = await createDailyNote(momentDate);
}
} catch (error) {
console.error("Error getting or creating daily note:", error);
return;
}
if (dailyNote) {
try {
await app.fileManager.processFrontMatter(
dailyNote,
(frontmatter) => {
const { habitKeyMap = {}, scheduledEventMap = {} } =
this.plugin.settings.habit || {};
const completion = updatedHabit.completions[date];
switch (updatedHabit.type) {
case "scheduled":
// Handle scheduled habits (journey habits)
const eventMap =
scheduledEventMap[updatedHabit.id] || {};
for (const [
eventName,
propertyKey,
] of Object.entries(eventMap)) {
if (propertyKey) {
// Only update if a property key is defined
if (
typeof completion === "object" &&
completion?.[eventName] !==
undefined
) {
frontmatter[propertyKey as string] =
completion[eventName];
} else {
// If completion doesn't exist or eventName is missing, maybe remove property or set to default?
// For now, we only update if value exists. Decide if removal is needed.
// delete frontmatter[propertyKey]; // Example: remove if not in completion
}
}
}
break;
case "daily":
// Handle daily habits with custom completion text
const dailyHabit =
updatedHabit as DailyHabitProps;
const dailyProperties =
habitKeyMap[updatedHabit.id] || [];
if (dailyProperties.length > 0) {
const keyToUpdate = dailyProperties[0]; // Update the primary property
if (completion !== undefined) {
// If completionText is defined and completion is 1, use the completionText
if (
dailyHabit.completionText &&
completion === 1
) {
frontmatter[keyToUpdate] =
dailyHabit.completionText;
} else {
// Otherwise use the raw value
frontmatter[keyToUpdate] =
completion;
}
} else {
// If completion is undefined, remove the property
delete frontmatter[keyToUpdate];
}
} else {
console.warn(
`Habit ${updatedHabit.id} has no properties defined in habitKeyMap.`
);
}
break;
case "count":
// Handle count habits
const countProperties =
habitKeyMap[updatedHabit.id] || [];
if (countProperties.length > 0) {
const keyToUpdate = countProperties[0]; // Update the primary property
if (completion !== undefined) {
frontmatter[keyToUpdate] = completion;
} else {
// If completion is undefined, remove the property
delete frontmatter[keyToUpdate];
}
} else {
console.warn(
`Habit ${updatedHabit.id} has no properties defined in habitKeyMap.`
);
}
break;
case "mapping":
// Handle mapping habits
const mappingHabit =
updatedHabit as MappingHabitProps;
const mappingProperties =
habitKeyMap[updatedHabit.id] || [];
if (mappingProperties.length > 0) {
const keyToUpdate = mappingProperties[0]; // Update the primary property
if (
completion !== undefined &&
typeof completion === "number" &&
mappingHabit.mapping[completion]
) {
frontmatter[keyToUpdate] = completion;
} else {
// If completion is undefined or invalid, remove the property
delete frontmatter[keyToUpdate];
}
} else {
console.warn(
`Habit ${updatedHabit.id} has no properties defined in habitKeyMap.`
);
}
break;
default:
// Simple habit (fallback)
const properties =
habitKeyMap[
(updatedHabit as BaseHabitProps).id
] || [];
if (properties.length > 0) {
const keyToUpdate = properties[0]; // Update the primary property
if (completion !== undefined) {
frontmatter[keyToUpdate] = completion;
} else {
// If completion is undefined, remove the property
delete frontmatter[keyToUpdate];
}
} else {
console.warn(
`Habit ${
(updatedHabit as BaseHabitProps).id
} has no properties defined in habitKeyMap.`
);
}
break;
}
}
);
} catch (error) {
console.error(
`Error processing frontmatter for ${dailyNote.path}:`,
error
);
}
} else {
console.warn(
`Daily note could not be found or created for date: ${date}`
);
}
}
}

File diff suppressed because one or more lines are too long