The Ultimate Habit Tracker & Goal Setting With Datacore
Datacore for powerful habit tracking system in Obsidian
This is a code that you can copy directly in combination with Datacore plugin to track habits and track your goals in Obsidian.
Watch this video to learn how to configure this.
Thank you Shane Timmons for the original work.
```datacorejsx
// Constants and color configuration
const HABITS = [
{ id: 'Reading', emoji: '📚', label: 'Reading', defaultDuration: 25, unit: 'minutes', monthlyGoal: 1000 },
{ id: 'Writing', emoji: '✍️', label: 'Writing', defaultDuration: 30, unit: 'minutes' },
{ id: 'Money', emoji: '💰', label: 'Money', defaultDuration: 10, unit: 'dollars', monthlyGoal: 2000 },
{ id: 'Workout', emoji: '🧘♂️', label: 'Workout', defaultDuration: 30, unit: 'minutes' }
];
const GOALS = {
perfectDays: {
monthly: 20,
yearly: 250
}
};
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const COLORS = {
primary: 'var(--interactive-accent)',
secondary: 'var(--background-secondary)',
hoverState: 'var(--interactive-accent-hover)',
textPrimary: 'var(--text-normal)',
textLight: 'var(--text-on-accent)',
progressBar: {
low: 'var(--interactive-accent)',
medium: 'var(--interactive-accent)',
high: 'var(--interactive-accent)',
gradient: {
start: 'var(--interactive-accent)',
end: 'var(--interactive-accent-hover)'
}
}
};
// Utility Functions
const formatMetricValue = (value, habit) => {
if (value === null || value === undefined) return '0';
switch(habit.unit) {
case 'minutes':
return value === 1 ? '1 Minute' : `${value} Minutes`;
case 'dollars':
return `$${Number(value).toLocaleString()}`;
case 'rupees':
return `Rs. ${Number(value).toLocaleString()}`;
default:
return value;
}
};
const calculateTrendPercentage = (current, previous) => {
if (previous === 0) return current > 0 ? 100 : 0;
return ((current - previous) / previous) * 100;
};
// Moved getCompletionColor to top level
const getCompletionColor = (percentage) => {
if (percentage >= 75) return COLORS.progressBar.high;
if (percentage >= 50) return COLORS.progressBar.medium;
return COLORS.progressBar.low;
};
// Base Components
const CircularProgress = ({ value, size, color = 'var(--interactive-accent)' }) => {
const strokeWidth = 4;
const radius = (size - strokeWidth * 2) / 2;
const circumference = 2 * Math.PI * radius;
const progress = ((100 - value) / 100) * circumference;
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
style={{
transform: 'rotate(-90deg)',
overflow: 'visible'
}}
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke="var(--background-modifier-border)"
strokeWidth={strokeWidth}
fill="none"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={progress}
fill="none"
style={{
transition: 'stroke-dashoffset 0.5s ease',
transformOrigin: 'center'
}}
/>
</svg>
);
};
// Additional Base Components
const TrendIndicator = ({ current, previous }) => {
const trend = calculateTrendPercentage(current, previous);
let color = 'var(--text-normal)';
let indicator = '→';
if (trend > 0) {
color = 'var(--color-green)';
indicator = '↑';
} else if (trend < 0) {
color = 'var(--color-red)';
indicator = '↓';
}
return (
<span style={{ color }}>
{indicator} {Math.abs(trend).toFixed(1)}%
</span>
);
};
const TimeInput = ({
entry,
habitId,
editingTime,
setEditingTime,
updateHabitDuration,
getHabitStatus,
getHabitDuration
}) => {
const duration = getHabitDuration(entry, habitId);
const isEditing = editingTime?.entryPath === entry.$path && editingTime?.habitId === habitId;
if (!getHabitStatus(entry, habitId)) return null;
if (isEditing) {
return (
<input
type="number"
defaultValue={duration}
min="0"
style={{
width: '60px',
padding: '2px',
fontSize: '0.9em',
textAlign: 'center'
}}
onBlur={(e) => updateHabitDuration(entry, habitId, e.target.value)}
autoFocus
/>
);
}
return (
<span
onClick={() => setEditingTime({ entryPath: entry.$path, habitId })}
style={{ cursor: 'pointer', fontSize: '0.8em' }}
>
{formatMetricValue(duration, HABITS.find(h => h.id === habitId))}
</span>
);
};
const ProgressBar = ({ value, max, color = 'var(--interactive-accent)' }) => {
const percentage = Math.min((value / max) * 100, 100);
return (
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--background-modifier-border)',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
width: `${percentage}%`,
height: '100%',
backgroundColor: color,
transition: 'width 0.3s ease'
}} />
</div>
);
};
const StyledCard = ({ children, extraStyles = {} }) => (
<div style={{
backgroundColor: 'var(--background-primary)',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
padding: '24px',
transition: 'all 0.2s ease',
':hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transform: 'translateY(-2px)'
},
...extraStyles
}}>
{children}
</div>
);
const ActionButton = ({ icon, label, onClick, isActive, extraStyles = {} }) => (
<button
onClick={onClick}
style={{
padding: '12px 24px',
borderRadius: '8px',
border: 'none',
backgroundColor: isActive ? COLORS.primary : COLORS.secondary,
color: isActive ? COLORS.textLight : COLORS.textPrimary,
display: 'flex',
alignItems: 'center',
gap: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
fontSize: '16px',
fontWeight: '500',
':hover': {
transform: 'translateY(-1px)',
backgroundColor: COLORS.primary,
color: COLORS.textLight,
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)'
},
...extraStyles
}}
>
<span style={{ fontSize: '20px' }}>{icon}</span>
{label && <span>{label}</span>}
</button>
);
const NavigationControls = ({
selectedDate,
navigateDate,
activeView,
setActiveView
}) => (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '24px'
}}>
<div style={{
display: 'flex',
gap: '16px',
alignItems: 'center',
justifyContent: 'center',
background: COLORS.secondary,
padding: '8px 16px',
borderRadius: '12px',
boxShadow: 'var(--shadow-s)'
}}>
<ActionButton
icon="←"
onClick={() => navigateDate(-1)}
extraStyles={{
backgroundColor: COLORS.primary,
color: COLORS.textLight
}}
/>
<div style={{
fontWeight: 'bold',
fontSize: '24px',
minWidth: '240px',
textAlign: 'center',
fontFamily: 'var(--font-interface)',
background: 'var(--background-primary)',
padding: '8px 16px',
borderRadius: '8px',
boxShadow: 'var(--shadow-s)'
}}>
{selectedDate.toFormat('MMMM dd, yyyy')}
</div>
<ActionButton
icon="→"
onClick={() => navigateDate(1)}
extraStyles={{
backgroundColor: COLORS.primary,
color: COLORS.textLight
}}
/>
</div>
</div>
);
const CalendarView = ({
selectedDate,
sortedNotes,
getHabitStatus,
calculateCompletedHabits,
updateHabit,
getHabitDuration,
editingTime,
setEditingTime,
updateHabitDuration
}) => {
const dates = [];
let currentDate = selectedDate;
// Show last 7 days (today and previous 6 days)
for (let i = 0; i < 7; i++) {
dates.push(currentDate.minus({ days: i }));
}
const notesMap = new Map(sortedNotes.map(note => [note.$name, note]));
const today = dc.luxon.DateTime.now().startOf('day');
return (
<div style={{
width: '100%',
overflow: 'hidden',
padding: '8px 4px'
}}>
<div style={{
display: 'flex',
gap: '12px',
overflowX: 'auto',
paddingBottom: '12px',
WebkitOverflowScrolling: 'touch',
scrollbarWidth: 'none',
msOverflowStyle: 'none',
'::-webkit-scrollbar': {
display: 'none'
}
}}>
{dates.map((date) => {
const dateStr = date.toFormat('yyyy-MM-dd');
const entry = notesMap.get(dateStr);
const isSelected = date.hasSame(selectedDate, 'day');
const isToday = date.hasSame(today, 'day');
return (
<div style={{
flex: '0 0 320px',
maxWidth: '320px'
}}>
<CalendarDayCard
key={dateStr}
date={date}
entry={entry}
getHabitStatus={getHabitStatus}
calculateCompletedHabits={calculateCompletedHabits}
isSelected={isSelected}
isToday={isToday}
updateHabit={updateHabit}
getHabitDuration={getHabitDuration}
editingTime={editingTime}
setEditingTime={setEditingTime}
updateHabitDuration={updateHabitDuration}
/>
</div>
);
})}
</div>
</div>
);
};
const CalendarDayCard = ({
date,
entry,
getHabitStatus,
calculateCompletedHabits,
isSelected,
isToday,
updateHabit,
getHabitDuration,
editingTime,
setEditingTime,
updateHabitDuration
}) => {
const completedCount = calculateCompletedHabits(entry);
const completionPercentage = entry ? Math.round((completedCount / HABITS.length) * 100) : 0;
return (
<div style={{
padding: '12px',
borderRadius: '16px',
backgroundColor: 'var(--background-primary)',
color: COLORS.textPrimary,
boxShadow: 'var(--shadow-s)',
border: isSelected ? `2px solid ${COLORS.primary}` : '1px solid var(--background-modifier-border)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
display: 'flex',
flexDirection: 'column',
gap: '8px',
minHeight: '168px'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: COLORS.secondary,
padding: '8px 12px',
borderRadius: '10px'
}}>
<span style={{
fontSize: '1em',
fontWeight: '600',
color: COLORS.textPrimary
}}>
{DAYS[date.weekday % 7]}
</span>
<span style={{
fontWeight: '500',
fontSize: '0.9em',
color: COLORS.textPrimary
}}>
{date.toFormat('MM-dd')}
</span>
</div>
{entry && (
<>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
gap: '8px',
flex: 1,
padding: '2px'
}}>
{HABITS.map(habit => {
const isCompleted = getHabitStatus(entry, habit.id);
const duration = getHabitDuration(entry, habit.id);
return (
<div
key={habit.id}
onClick={() => updateHabit(entry, habit.id)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
backgroundColor: isCompleted ? COLORS.primary : COLORS.secondary,
borderRadius: '10px',
cursor: 'pointer',
padding: '12px',
width: '100%',
minHeight: '80px',
transition: 'all 0.2s ease'
}}
>
<span style={{
fontSize: '24px',
marginBottom: '4px'
}}>
{habit.emoji}
</span>
<span style={{
fontSize: '0.95em',
fontWeight: '600',
color: isCompleted ? COLORS.textLight : COLORS.textPrimary,
letterSpacing: '0.2px',
textAlign: 'center',
lineHeight: '1.2'
}}>
{habit.label}
</span>
{isCompleted && duration && (
<span style={{
fontSize: '0.75em',
fontWeight: '600',
color: isCompleted ? COLORS.textLight : COLORS.textPrimary,
textAlign: 'center'
}}>
{formatMetricValue(duration, habit)}
</span>
)}
</div>
);
})}
</div>
<div style={{
marginTop: 'auto',
display: 'flex',
flexDirection: 'column',
gap: '4px'
}}>
<ProgressBar
value={completedCount}
max={HABITS.length}
color={getCompletionColor(completionPercentage)}
/>
<div style={{
textAlign: 'right',
fontSize: '0.8em',
fontWeight: '600',
color: getCompletionColor(completionPercentage)
}}>
{completionPercentage}%
</div>
</div>
</>
)}
</div>
);
};
const MetricCard = ({ habit, current, previous, ytdTotal }) => {
const trend = calculateTrendPercentage(current, previous);
const formattedTotal = formatMetricValue(current, habit);
const formattedYTD = formatMetricValue(ytdTotal, habit);
return (
<div style={{
backgroundColor: 'var(--background-primary)',
borderRadius: '16px',
padding: '24px',
boxShadow: 'var(--shadow-s)',
display: 'flex',
flexDirection: 'column',
gap: '16px'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px'
}}>
<div style={{
fontSize: '32px',
backgroundColor: COLORS.secondary,
borderRadius: '12px',
padding: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
{habit.emoji}
</div>
<div>
<h3 style={{ margin: 0 }}>{habit.label}</h3>
<div style={{
color: 'var(--text-muted)',
fontSize: '0.9em'
}}>
Last 30 Days
</div>
</div>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '16px'
}}>
<div style={{
backgroundColor: COLORS.secondary,
padding: '16px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '0.9em', color: 'var(--text-muted)' }}>Current</div>
<div style={{
fontSize: '1.4em',
fontWeight: 'bold',
marginTop: '4px'
}}>
{formattedTotal}
</div>
</div>
<div style={{
backgroundColor: COLORS.secondary,
padding: '16px',
borderRadius: '12px',
textAlign: 'center'
}}>
<div style={{ fontSize: '0.9em', color: 'var(--text-muted)' }}>YTD</div>
<div style={{
fontSize: '1.4em',
fontWeight: 'bold',
marginTop: '4px'
}}>
{formattedYTD}
</div>
</div>
</div>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: COLORS.secondary,
padding: '12px 16px',
borderRadius: '12px'
}}>
<span>Trend</span>
<TrendIndicator current={current} previous={previous} />
</div>
</div>
);
};
const TrendsView = ({ trends }) => {
const monthlyProgress = (trends.currentMonth.perfectDays / GOALS.perfectDays.monthly) * 100;
const yearlyProgress = (trends.yearToDate.perfectDays / GOALS.perfectDays.yearly) * 100;
return (
<div style={{
padding: '24px',
display: 'flex',
flexDirection: 'column',
gap: '32px'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
gap: '24px'
}}>
<div style={{
backgroundColor: 'var(--background-primary)',
borderRadius: '16px',
padding: '24px',
display: 'flex',
alignItems: 'center',
gap: '24px',
boxShadow: 'var(--shadow-s)'
}}>
<CircularProgress value={monthlyProgress} size={100} color={COLORS.primary} />
<div>
<h3 style={{ margin: '0 0 8px 0', color: 'var(--text-normal)' }}>Monthly Goal</h3>
<div style={{ fontSize: '1.2em', fontWeight: 'bold', color: 'var(--text-normal)' }}>
{trends.currentMonth.perfectDays}/{GOALS.perfectDays.monthly} Perfect Days
</div>
<div style={{ color: 'var(--text-muted)' }}>
{monthlyProgress.toFixed(1)}% Complete
</div>
</div>
</div>
<div style={{
backgroundColor: 'var(--background-primary)',
borderRadius: '16px',
padding: '24px',
display: 'flex',
alignItems: 'center',
gap: '24px',
boxShadow: 'var(--shadow-s)'
}}>
<CircularProgress value={yearlyProgress} size={100} color={COLORS.progressBar.high} />
<div>
<h3 style={{ margin: '0 0 8px 0', color: 'var(--text-normal)' }}>Yearly Goal</h3>
<div style={{ fontSize: '1.2em', fontWeight: 'bold', color: 'var(--text-normal)' }}>
{trends.yearToDate.perfectDays}/{GOALS.perfectDays.yearly} Perfect Days
</div>
<div style={{ color: 'var(--text-muted)' }}>
{yearlyProgress.toFixed(1)}% Complete
</div>
</div>
</div>
<div style={{
backgroundColor: 'var(--background-primary)',
borderRadius: '16px',
padding: '24px',
display: 'flex',
alignItems: 'center',
gap: '24px',
boxShadow: 'var(--shadow-s)'
}}>
<div style={{
width: '100px',
height: '100px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '48px',
backgroundColor: 'var(--background-secondary)',
borderRadius: '50%'
}}>
🔥
</div>
<div>
<h3 style={{ margin: '0 0 8px 0', color: 'var(--text-normal)' }}>Current Streak</h3>
<div style={{ fontSize: '1.2em', fontWeight: 'bold', color: 'var(--text-normal)' }}>
{trends.last30Days.perfectDays} Days
</div>
<div style={{ color: 'var(--text-muted)' }}>
Last 30 Days
</div>
</div>
</div>
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '24px'
}}>
{HABITS.map(habit => (
<MetricCard
key={habit.id}
habit={habit}
current={trends.last30Days.habitMetrics[habit.id].total}
previous={trends.last30Days.habitMetrics[habit.id].previousPeriodTotal}
ytdTotal={trends.yearToDate.habitMetrics[habit.id].total}
/>
))}
</div>
</div>
);
};
const HistoricalView = ({
sortedNotes,
currentPage,
setCurrentPage,
updateHabit,
getHabitStatus,
getHabitDuration,
editingTime,
setEditingTime,
updateHabitDuration,
calculateCompletedHabits
}) => {
const itemsPerPage = 20;
const totalPages = Math.ceil(sortedNotes.length / itemsPerPage);
const startIndex = currentPage * itemsPerPage;
const displayNotes = sortedNotes.slice(startIndex, startIndex + itemsPerPage);
return (
<div style={{
padding: '24px',
backgroundColor: COLORS.secondary,
borderRadius: '12px',
marginTop: '24px'
}}>
<h3 style={{ margin: '0 0 20px 0' }}>Historical Data</h3>
<div style={{
width: '100%',
overflow: 'auto',
borderRadius: '12px',
boxShadow: 'var(--shadow-s)',
backgroundColor: 'var(--background-primary)'
}}>
<table style={{
width: '100%',
borderCollapse: 'separate',
borderSpacing: 0
}}>
<thead>
<tr>
<th style={{
padding: '16px',
backgroundColor: COLORS.secondary,
color: COLORS.textPrimary,
fontWeight: 'bold',
textAlign: 'left',
position: 'sticky',
top: 0,
zIndex: 10,
}}>Date</th>
{HABITS.map(habit => (
<th key={habit.id} style={{
padding: '16px',
backgroundColor: COLORS.secondary,
color: COLORS.textPrimary,
fontWeight: 'bold',
textAlign: 'center',
position: 'sticky',
top: 0,
zIndex: 10,
}}>
<div style={{ fontSize: '1.4em' }}>{habit.emoji}</div>
<div>{habit.label}</div>
</th>
))}
<th style={{
padding: '16px',
backgroundColor: COLORS.secondary,
color: COLORS.textPrimary,
fontWeight: 'bold',
textAlign: 'center',
position: 'sticky',
top: 0,
zIndex: 10,
}}>Completion</th>
</tr>
</thead>
<tbody>
{displayNotes.map((entry, index) => (
<tr key={entry.$path} style={{
backgroundColor: index % 2 === 0 ? 'var(--background-primary)' : 'var(--background-secondary)'
}}>
<td style={{
padding: '12px 16px',
borderBottom: '1px solid rgba(0, 0, 0, 0.05)',
minWidth: '150px'
}}>{entry.$name}</td>
{HABITS.map(habit => {
const isCompleted = getHabitStatus(entry, habit.id);
return (
<td key={habit.id} style={{
padding: '12px 16px',
borderBottom: '1px solid rgba(0, 0, 0, 0.05)',
textAlign: 'center',
minWidth: '120px'
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px'
}}>
<div
onClick={() => updateHabit(entry, habit.id)}
style={{
padding: '4px 8px',
borderRadius: '4px',
backgroundColor: isCompleted ? COLORS.primary : COLORS.secondary,
color: isCompleted ? COLORS.textLight : 'var(--text-muted)',
cursor: 'pointer'
}}
>
{isCompleted ? '✓' : '×'}
</div>
{isCompleted && (
<TimeInput
entry={entry}
habitId={habit.id}
editingTime={editingTime}
setEditingTime={setEditingTime}
updateHabitDuration={updateHabitDuration}
getHabitStatus={getHabitStatus}
getHabitDuration={getHabitDuration}
/>
)}
</div>
</td>
);
})}
<td style={{
padding: '12px 16px',
borderBottom: '1px solid rgba(0, 0, 0, 0.05)',
textAlign: 'center',
color: getCompletionColor(Math.round((calculateCompletedHabits(entry) / HABITS.length) * 100)),
fontWeight: '600'
}}>
{Math.round((calculateCompletedHabits(entry) / HABITS.length) * 100)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{
display: 'flex',
justifyContent: 'center',
gap: '8px',
marginTop: '16px'
}}>
<ActionButton
icon="←"
onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
extraStyles={{
opacity: currentPage === 0 ? 0.5 : 1,
cursor: currentPage === 0 ? 'default' : 'pointer'
}}
/>
<span style={{
padding: '8px 16px',
backgroundColor: 'var(--background-primary)',
borderRadius: '8px'
}}>
Page {currentPage + 1} of {totalPages}
</span>
<ActionButton
icon="→"
onClick={() => setCurrentPage(prev => Math.min(totalPages - 1, prev + 1))}
extraStyles={{
opacity: currentPage === totalPages - 1 ? 0.5 : 1,
cursor: currentPage === totalPages - 1 ? 'default' : 'pointer'
}}
/>
</div>
</div>
);
};
const GoalsView = ({ entries, daysInMonth }) => {
const habitsWithGoals = HABITS.filter(h => h.monthlyGoal);
const calculateProgress = (habitId) => {
const total = entries.reduce((sum, entry) => sum + (entry?.value(habitId) ?? 0), 0);
const habit = HABITS.find(h => h.id === habitId);
// Count days that actually have data
const daysWithData = entries.filter(entry => {
const value = entry?.value(habitId);
return value !== null && value !== undefined && value > 0;
}).length;
// Calculate progress against monthly goal
const progress = (total / habit.monthlyGoal) * 100;
// Calculate daily average based on days with actual data, or 1 if no days have data
const daysForAverage = Math.max(daysWithData, 1);
const dailyAverage = Number((total / daysForAverage).toFixed(2));
// Project monthly total based on daily average
const projection = Number((dailyAverage * daysInMonth).toFixed(2));
const isOnTrack = projection >= habit.monthlyGoal;
return {
total,
progress: Number(Math.min(progress, 100).toFixed(2)),
dailyAverage,
projection,
isOnTrack,
daysWithData
};
};
const getProgressGradient = (isOnTrack) => {
return {
start: isOnTrack ? COLORS.progressBar.gradient.start : COLORS.progressBar.gradient.start,
end: isOnTrack ? COLORS.progressBar.gradient.end : COLORS.progressBar.gradient.end
};
};
return (
<div style={{
padding: '16px',
width: '100%',
maxWidth: '100%'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(450px, 1fr))',
gap: '24px',
width: '100%'
}}>
{habitsWithGoals.map(habit => {
const stats = calculateProgress(habit.id);
const progressColor = stats.isOnTrack ? COLORS.progressBar.high : COLORS.progressBar.low;
const gradient = getProgressGradient(stats.isOnTrack);
return (
<div key={habit.id} style={{
background: 'var(--background-secondary)',
borderRadius: '12px',
padding: '24px',
boxShadow: 'var(--shadow-s)',
minWidth: '450px',
flex: '1 1 auto'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '16px'
}}>
<div style={{
width: '100px',
height: '100px',
position: 'relative'
}}>
<CircularProgress
progress={stats.progress}
size={100}
strokeWidth={10}
circleColor={progressColor}
gradientStart={gradient.start}
gradientEnd={gradient.end}
/>
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '42px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '80%',
height: '80%'
}}>
{habit.emoji}
</div>
</div>
<div style={{ flex: 1 }}>
<h3 style={{
margin: '0 0 8px 0',
textAlign: 'center',
fontSize: '1.4em',
color: 'var(--text-normal)'
}}>{habit.label}</h3>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px'
}}>
<Stat
label="Current"
value={formatMetricValue(stats.total, habit)}
/>
<Stat
label="Goal"
value={formatMetricValue(habit.monthlyGoal, habit)}
/>
<Stat
label="Daily Avg"
value={formatMetricValue(stats.dailyAverage, habit)}
/>
<Stat
label="Projected"
value={formatMetricValue(stats.projection, habit)}
color={stats.isOnTrack ? 'var(--color-green)' : 'var(--color-red)'}
/>
</div>
</div>
</div>
<div style={{
height: '40px',
background: 'var(--background-primary)',
borderRadius: '20px',
overflow: 'hidden',
position: 'relative'
}}>
<div style={{
position: 'absolute',
top: '0',
left: '0',
height: '100%',
width: `${stats.progress}%`,
background: `linear-gradient(90deg, ${gradient.start} 0%, ${gradient.end} 100%)`,
transition: 'width 0.3s ease'
}} />
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: 'var(--text-normal)',
fontWeight: 'bold'
}}>
{stats.progress}%
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
const WeeklyGoalsView = ({ entries }) => {
const habitsWithGoals = HABITS.filter(h => h.monthlyGoal);
const daysInWeek = 7;
const calculateWeeklyProgress = (habitId) => {
const total = entries.reduce((sum, entry) => sum + (entry?.value(habitId) ?? 0), 0);
const habit = HABITS.find(h => h.id === habitId);
// Count days that actually have data
const daysWithData = entries.filter(entry => {
const value = entry?.value(habitId);
return value !== null && value !== undefined && value > 0;
}).length;
// Calculate weekly goal as a proportion of monthly goal
const weeklyGoal = Math.round(habit.monthlyGoal * (daysInWeek / 30));
const progress = (total / weeklyGoal) * 100;
// Calculate daily average based on days with actual data, or 1 if no days have data
const daysForAverage = Math.max(daysWithData, 1);
const dailyAverage = Number((total / daysForAverage).toFixed(2));
const projection = Number((dailyAverage * daysInWeek).toFixed(2));
const isOnTrack = projection >= weeklyGoal;
return {
total,
weeklyGoal,
progress: Number(Math.min(progress, 100).toFixed(2)),
dailyAverage,
projection,
isOnTrack,
daysWithData
};
};
const getProgressGradient = (isOnTrack) => {
return {
start: isOnTrack ? COLORS.progressBar.gradient.start : COLORS.progressBar.gradient.start,
end: isOnTrack ? COLORS.progressBar.gradient.end : COLORS.progressBar.gradient.end
};
};
return (
<div style={{
padding: '16px',
width: '100%',
maxWidth: '100%'
}}>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(450px, 1fr))',
gap: '24px',
width: '100%'
}}>
{habitsWithGoals.map(habit => {
const stats = calculateWeeklyProgress(habit.id);
const progressColor = stats.isOnTrack ? COLORS.progressBar.high : COLORS.progressBar.low;
const gradient = getProgressGradient(stats.isOnTrack);
return (
<div key={habit.id} style={{
background: 'var(--background-secondary)',
borderRadius: '12px',
padding: '24px',
boxShadow: 'var(--shadow-s)',
minWidth: '450px',
flex: '1 1 auto'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '16px'
}}>
<div style={{
width: '100px',
height: '100px',
position: 'relative'
}}>
<CircularProgress
progress={stats.progress}
size={100}
strokeWidth={10}
circleColor={progressColor}
gradientStart={gradient.start}
gradientEnd={gradient.end}
/>
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '42px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '80%',
height: '80%'
}}>
{habit.emoji}
</div>
</div>
<div style={{ flex: 1 }}>
<h3 style={{
margin: '0 0 8px 0',
textAlign: 'center',
fontSize: '1.4em',
color: 'var(--text-normal)'
}}>{habit.label}</h3>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '12px'
}}>
<Stat
label="Current"
value={formatMetricValue(stats.total, habit)}
/>
<Stat
label="Weekly Goal"
value={formatMetricValue(stats.weeklyGoal, habit)}
/>
<Stat
label="Daily Avg"
value={formatMetricValue(stats.dailyAverage, habit)}
/>
<Stat
label="Projected"
value={formatMetricValue(stats.projection, habit)}
color={stats.isOnTrack ? 'var(--color-green)' : 'var(--color-red)'}
/>
</div>
</div>
</div>
<div style={{
height: '40px',
background: 'var(--background-primary)',
borderRadius: '20px',
overflow: 'hidden',
position: 'relative'
}}>
<div style={{
position: 'absolute',
top: '0',
left: '0',
height: '100%',
width: `${stats.progress}%`,
background: `linear-gradient(90deg, ${gradient.start} 0%, ${gradient.end} 100%)`,
transition: 'width 0.3s ease'
}} />
<div style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
color: 'var(--text-normal)',
fontWeight: 'bold'
}}>
{stats.progress}%
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
const Stat = ({ label, value, color }) => (
<div style={{ textAlign: 'center' }}>
<div style={{
fontSize: '0.9em',
color: 'var(--text-muted)',
marginBottom: '4px'
}}>
{label}
</div>
<div style={{
fontSize: '1.1em',
fontWeight: 'bold',
color: color || 'var(--text-normal)'
}}>
{value}
</div>
</div>
);
function HabitTracker() {
// State Management
const [activeView, setActiveView] = dc.useState('weekly');
const [selectedDate, setSelectedDate] = dc.useState(dc.luxon.DateTime.now());
const [editingTime, setEditingTime] = dc.useState(null);
const [currentPage, setCurrentPage] = dc.useState(0);
// Data Queries and Utility Functions
const dailyNotes = dc.useQuery(`
@page
AND path("002 Journal")
`);
const sortedNotes = dc.useMemo(() => {
return [...dailyNotes].sort((a, b) => b.$name.localeCompare(a.$name));
}, [dailyNotes]);
const getNotesForPeriod = (startDate) => {
return sortedNotes.filter(note => {
const noteDate = dc.luxon.DateTime.fromISO(note.$name);
return noteDate >= startDate;
});
};
// Add new function to get notes for specific date range
const getNotesForDateRange = (startDate, endDate) => {
return sortedNotes.filter(note => {
const noteDate = dc.luxon.DateTime.fromISO(note.$name);
// Use startOf('day') and endOf('day') to ensure full day coverage
return noteDate >= startDate.startOf('day') && noteDate <= endDate.endOf('day');
});
};
const last30DaysNotes = dc.useMemo(() =>
getNotesForPeriod(selectedDate.minus({ days: 30 })),
[sortedNotes, selectedDate]
);
const yearToDateNotes = dc.useMemo(() =>
getNotesForPeriod(selectedDate.startOf('year')),
[sortedNotes, selectedDate]
);
const currentMonthNotes = dc.useMemo(() =>
getNotesForPeriod(selectedDate.startOf('month')),
[sortedNotes, selectedDate]
);
const previousMonthNotes = dc.useMemo(() =>
sortedNotes.filter(note => {
const noteDate = dc.luxon.DateTime.fromISO(note.$name);
const monthAgo = selectedDate.minus({ months: 1 });
return noteDate >= monthAgo && noteDate < selectedDate.startOf('month');
}),
[sortedNotes, selectedDate]
);
const getHabitStatus = (entry, habitId) => {
const habit = HABITS.find(h => h.id === habitId);
const value = entry?.value(habitId) ?? 0;
return value >= habit.defaultDuration;
};
const getHabitDuration = (entry, habitId) => {
return entry?.value(habitId) ?? null;
};
const calculateCompletedHabits = (entry) => {
if (!entry) return 0;
return HABITS.reduce((count, habit) =>
count + (getHabitStatus(entry, habit.id) ? 1 : 0), 0);
};
const calculatePerfectDays = (notes) => {
return notes.reduce((count, note) =>
count + (calculateCompletedHabits(note) === HABITS.length ? 1 : 0), 0);
};
const calculateTrends = () => {
const trends = {
last30Days: {
perfectDays: calculatePerfectDays(last30DaysNotes),
habitMetrics: {}
},
yearToDate: {
perfectDays: calculatePerfectDays(yearToDateNotes),
habitMetrics: {}
},
currentMonth: {
perfectDays: calculatePerfectDays(currentMonthNotes),
progress: 0
}
};
trends.currentMonth.progress = (trends.currentMonth.perfectDays / GOALS.perfectDays.monthly) * 100;
HABITS.forEach(habit => {
const last30Total = last30DaysNotes.reduce((sum, note) => {
const value = note?.value(habit.id);
const numValue = value ? Number(value) : 0;
return sum + (isNaN(numValue) ? 0 : numValue);
}, 0);
const ytdTotal = yearToDateNotes.reduce((sum, note) => {
const value = note?.value(habit.id);
const numValue = value ? Number(value) : 0;
return sum + (isNaN(numValue) ? 0 : numValue);
}, 0);
const previousMonthTotal = previousMonthNotes.reduce((sum, note) => {
const value = note?.value(habit.id);
const numValue = value ? Number(value) : 0;
return sum + (isNaN(numValue) ? 0 : numValue);
}, 0);
trends.last30Days.habitMetrics[habit.id] = {
total: last30Total,
previousPeriodTotal: previousMonthTotal
};
trends.yearToDate.habitMetrics[habit.id] = {
total: ytdTotal
};
});
return trends;
};
// Get current week's notes based on selected date
const currentWeekNotes = dc.useMemo(() =>
sortedNotes.filter(note => {
const noteDate = dc.luxon.DateTime.fromISO(note.$name);
const startOfWeek = selectedDate.startOf('week');
const endOfWeek = selectedDate.endOf('week');
return noteDate >= startOfWeek && noteDate <= endOfWeek;
}),
[sortedNotes, selectedDate]
);
// Action Handlers
async function updateHabit(entry, habitId) {
const file = app.vault.getAbstractFileByPath(entry.$path);
await app.fileManager.processFrontMatter(file, (frontmatter) => {
const habit = HABITS.find(h => h.id === habitId);
const currentValue = frontmatter[habitId];
frontmatter[habitId] = currentValue ? 0 : habit.defaultDuration;
});
}
async function updateHabitDuration(entry, habitId, duration) {
const file = app.vault.getAbstractFileByPath(entry.$path);
await app.fileManager.processFrontMatter(file, (frontmatter) => {
frontmatter[habitId] = parseInt(duration) || 0;
});
setEditingTime(null);
}
const navigateDate = (direction) => {
setSelectedDate(prev => prev.plus({ days: direction }));
};
// Main Layout
return (
<div style={{
width: '100%',
margin: '0 auto',
padding: '24px',
display: 'flex',
flexDirection: 'column',
gap: '24px'
}}>
<NavigationControls
selectedDate={selectedDate}
navigateDate={navigateDate}
activeView={activeView}
setActiveView={setActiveView}
/>
<StyledCard>
<CalendarView
selectedDate={selectedDate}
sortedNotes={getNotesForDateRange(selectedDate.minus({ days: 6 }), selectedDate)}
getHabitStatus={getHabitStatus}
calculateCompletedHabits={calculateCompletedHabits}
updateHabit={updateHabit}
getHabitDuration={getHabitDuration}
editingTime={editingTime}
setEditingTime={setEditingTime}
updateHabitDuration={updateHabitDuration}
/>
<div style={{
display: 'flex',
justifyContent: 'center',
gap: '16px',
marginTop: '16px',
paddingTop: '16px',
borderTop: '1px solid var(--background-modifier-border)'
}}>
<ActionButton
icon="📅"
onClick={() => setActiveView(activeView === 'weekly' ? null : 'weekly')}
isActive={activeView === 'weekly'}
extraStyles={{ padding: '12px' }}
/>
<ActionButton
icon="🚀"
onClick={() => setActiveView(activeView === 'goals' ? null : 'goals')}
isActive={activeView === 'goals'}
extraStyles={{ padding: '12px' }}
/>
<ActionButton
icon="🚧"
onClick={() => setActiveView(activeView === 'stats' ? null : 'stats')}
isActive={activeView === 'stats'}
extraStyles={{ padding: '12px' }}
/>
<ActionButton
icon="🎯"
onClick={() => setActiveView(activeView === 'history' ? null : 'history')}
isActive={activeView === 'history'}
extraStyles={{ padding: '12px' }}
/>
</div>
</StyledCard>
{activeView === 'weekly' && (
<WeeklyGoalsView entries={currentWeekNotes} />
)}
{activeView === 'goals' && (
<GoalsView
entries={currentMonthNotes}
daysInMonth={new Date(new Date().getFullYear(), new Date().getMonth() + 1, 0).getDate()}
/>
)}
{activeView === 'stats' && <TrendsView trends={calculateTrends()} />}
{activeView === 'history' && (
<HistoricalView
sortedNotes={sortedNotes}
currentPage={currentPage}
setCurrentPage={setCurrentPage}
updateHabit={updateHabit}
getHabitStatus={getHabitStatus}
getHabitDuration={getHabitDuration}
editingTime={editingTime}
setEditingTime={setEditingTime}
updateHabitDuration={updateHabitDuration}
calculateCompletedHabits={calculateCompletedHabits}
/>
)}
</div>
);
}
return HabitTracker;