Designer and functional habit tracker in Obsidian | установка и настройка
Автор: Шейн Тиммонс
На сайте автора, где была подробная инструкция, перестали работать ссылки, поэтому пришлось искать способы реализации это трекера привычек у других пользователей
Инструкция
Все, что нужно для создания трекера привычек – это плагин Datacore и код трекера. Так как плагина нет в сторонних плагинах в Obsidian, придется устанавливать его вручную на своих страх и риск ❕
1. Скачиваем плагин BRAT из сторонних плагинов в Obsidian. Он нам нужен для установки плагина Datacore
2. Вызываем панель команд в Obsidian (CTRL + P). Прописываем: BRAT: Plugins... Выбираем Add a beta plugin for testing
3. В открывшемся окне вставляем ссылку на github c плагином Datacore: https://github.com/blacksmithgu/datacore. Так мы установим и включим плагин
4. Создаем пустую заметку, называем ее, как удобно. Вставляем код ⬇
```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; ````
Основная настройка трекера
1. Чтобы начать работу с трекером, у вас должны быть настроены Ежедневные заметки (указана папка, куда отправлять Daily notes). Также желательно установить плагин Calendar – так будет проще работать с трекером
2. Дальше в коде трекера ищем query (переходим в исходный код ctrl+e –> поиск ctrl+f –> вводим query). Тут нужно указать название своей папки с Daily notes
3. Проверяем, что все работает. Протыкиваем на календаре текущую неделю = создаем Ежедневные заметки на неделю. В трекере появляются вот такие штучки, которые позволяет отмечать выполнение привычки ⬇
4. Теперь настраиваем то, что будет отображаться в вашем трекере. Это самая первая колонка в коде ⬇
id: так привычка будет отображаться в свойствах Ежедневной заметки
label: название привычки (так она будет отображаться в трекере)
defaultDuration: количество в цифрах (времени, денег, раз)
unit: в чем измеряете? (в долларах, рублях, часах, минутах, секундах и тд)
✔ Telegram-канал Обучение и базы знаний
✔ Telegram-канал Second Brain про управление личными знаниями
✔ Telegram-канал Pro Obsidian про Obsidian и заметковедение
✔ Официальный сайт сообщества Second Brain
✔ Youtube-канал Second brain