mirror of
https://github.com/taskgenius/taskgenius-plugin.git
synced 2026-07-22 06:40:25 +00:00
feat: update habit view
This commit is contained in:
parent
edce8bf1b6
commit
a6e2ab4007
20 changed files with 2589 additions and 6 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
38
src/components/habit/habitcard.ts
Normal file
38
src/components/habit/habitcard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
85
src/components/habit/habitcard/counthabitcard.ts
Normal file
85
src/components/habit/habitcard/counthabitcard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/components/habit/habitcard/dailyhabitcard.ts
Normal file
88
src/components/habit/habitcard/dailyhabitcard.ts
Normal 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;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
240
src/components/habit/habitcard/habitcard.ts
Normal file
240
src/components/habit/habitcard/habitcard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
5
src/components/habit/habitcard/index.ts
Normal file
5
src/components/habit/habitcard/index.ts
Normal 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";
|
||||
159
src/components/habit/habitcard/mappinghabitcard.ts
Normal file
159
src/components/habit/habitcard/mappinghabitcard.ts
Normal 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],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
157
src/components/habit/habitcard/scheduledhabitcard.ts
Normal file
157
src/components/habit/habitcard/scheduledhabitcard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
450
src/styles/habit.css
Normal 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
79
src/types/habit-card.d.ts
vendored
Normal 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;
|
||||
}
|
||||
14
src/types/obsidian-ex.d.ts
vendored
14
src/types/obsidian-ex.d.ts
vendored
|
|
@ -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
624
src/utils/HabitManager.ts
Normal 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
381
styles.css
381
styles.css
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue