Habit Tracker Plugin

name

Habit Tracker

Information

description

Tracks habits from the daily jots.
Supports tracking "this week", "last week", "this month", and "last month".
Companion to the Inline Checkbox plugin by collecting stats for properly formatted habit check items.


icon

query_stats


instructions


* Use inline tags to host each habit in its own note. A perfect place to put context about the habit itself.
* Tag the habit note with the "habit" tag. You can use a custom one if you'd like, and remember to specify it in the plugin settings.

* When referencing to that habit in a daily jot, place a checkbox in front of it to turn it into a "checklist item". That will enable picking it up for counting by this plugin. Alternatively, place the checkmark in the original habit name itself and the inline tags will automatically be considered a checklist item where used. In the latter case, remember to remove the checkmark when the intention is to use the inline tag after the counter, or else it will count as a checklist item too!

* Note: the habit needs to be used in a daily jot to detect the date properly.

* Add the {Habit Tracker} shortcut to insert a counter in any note, and follow the dialog to select which habit and which time span to use. If you want to keep your fingers on the keyboard, type the inline tag directly and insert a "0/0 this week |" at its start to turn it into a counter.

* Use "last week", "this month", or "last month" as other options.

* Tap on any counter or the inline tag in the note and click on "Update" button to update the counters in the whole of the note.

* For easier use of the habit check marking, use the Inline Checkbox plugin

Supports placing the counter in a table cell. Just enable the "In table?" option in the insert dialog. The inline tag is expected to be on the adjacent cell on the right to work properly.

Please remember: this is a hack and the user experience is not awesome.
Known issues: Amplenote doesn't seem to handle importing markdown with backticks (literal text) well, and replacing content that includes such breaks that content!
Similar with tables, updating the counter inside a table cell sometimes breaks the table due to column width formatting.

Not supported (yet anyway): counter in Rich footnotes



setting

The tag the habits are denoted with (leave empty for 'habit')


setting

The daily-jot tag you are using (leave empty for 'daily-jots')


setting

Switch day the week start (leave empty for locale default, otherwise enter anything here)


setting

Unticked checkmark (leave empty for 🔲)


setting

Ticked checkmark (leave empty for ✔)


linkVersion History

10 April 2024: Hello world

17 April 2024: Ability to update the counters.

19 April 2024: Dialog to insert a stat; Settings for specifying the daily jots tag, and the checkmarks.

21 April 2024: Option to change whether Sunday or Monday is the week start, to deviate from the locale if needed. Also added a progress bar.

linkCode Block

{
defaultUnchecked: "🔲",
defaultChecked: "✔",
defaultHabitsTag: "habit",
defaultDailyJotsTag: "daily-jots",
 
_loadMoment() {
if (this._haveLoadedMoment) return Promise.resolve(true);
 
return new Promise(function(resolve) {
const script = document.createElement("script");
script.setAttribute("type", "text/javascript");
script.setAttribute("src", "https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.30.1/moment-with-locales.min.js");
script.addEventListener("load", function() {
this._haveLoadedMoment = true;
resolve(true); });
});
document.body.appendChild(script); }); },
}); },
},
 
nums: [..."𝟬𝟭𝟮𝟯𝟰𝟱𝟲𝟳𝟴𝟵"],
toSmallNumerals(naturalNumber) {
return [...naturalNumber+''].map(n => this.nums[(+n)]).join(''); },
},
 
habitsTag(app) {
const key = "The tag the habits are denoted with (leave empty for 'habit')";
return app.settings[key] || this.defaultHabitsTag; },
},
 
dailyJotsTag(app) {
const key = "The daily-jot tag you are using (leave empty for 'daily-jots')";
return app.settings[key] || this.defaultDailyJotsTag; },
},
 
checkmark(app, checked) {
const key = checked ? "Ticked checkmark (leave empty for ✔)" : "Unticked checkmark (leave empty for 🔲)";
return app.settings[key] || (checked ? this.defaultChecked : this.defaultUnchecked); },
},
 
switchWeekStart(app) {
const key = "Switch day the week start (leave empty for locale default, otherwise enter anything here)";
return app.settings[key]; },
},
 
markdown(count, habit, timeSpan, habitUUID, standalone) {
const num = this.toSmallNumerals(count);
if (standalone)
return `[${num}/${num} ${timeSpan}][^1]\n\n[^1]: [${num}/${num} ${timeSpan}]()\n\n Click the button below to refresh.\n\n`;
else
return `[⬜️⬜️⬜️⬜️⬜️⬜️⬜️ ${num}/${num} ${timeSpan} |${habit ? ` ${habit}` : ''}](https:\/\/www.amplenote.com\/notes\/${habitUUID})`; },
},
 
inTimeSpan: {
_startOfThisWeek(switchWeekStart) {
const today = moment().startOf('day');
const isTodayLocalesStartOfWeek = today.isSame(today.weekday(0));
if (isTodayLocalesStartOfWeek && switchWeekStart) {
if (today.locale('en').format('dd') === 'Su') {
const ret = today.subtract(6, 'days');
return ret; }
}
if (today.locale('en').format('dd') === 'Mo') {
const ret = today.subtract(1, 'days');
return ret; } }
} }
}
return today.weekday(0); },
},
"this week": function(dateToCheck, switchWeekStart) {
const startOfThisWeek = this._startOfThisWeek(switchWeekStart);
return { isIt: dateToCheck.isSameOrAfter(startOfThisWeek), spanSize: 7 }; },
},
"last week": function(dateToCheck, switchWeekStart) {
const startOfThisWeek = this._startOfThisWeek(switchWeekStart);
const startOfLastWeek = startOfThisWeek.clone().subtract(7, 'days');
return { isIt: dateToCheck.isBefore(startOfThisWeek) && dateToCheck.isSameOrAfter(startOfLastWeek), spanSize: 7 }; },
},
"this month": function(dateToCheck, switchWeekStart) {
const startOfThisMonth = moment().startOf('month');
return { isIt: dateToCheck.isSameOrAfter(startOfThisMonth), spanSize: startOfThisMonth.daysInMonth() }; },
},
"last month": function(dateToCheck, switchWeekStart) {
const startOfThisMonth = moment().startOf('month');
const startOfLastMonth = moment().subtract(1, 'months').startOf('month');
return { isIt: dateToCheck.isBefore(startOfThisMonth) && dateToCheck.isSameOrAfter(startOfLastMonth), spanSize: startOfLastMonth.daysInMonth() }; } },
} },
},
 
insertText: {
run: async function(app) {
const habitHandles = await app.filterNotes({ tag: this.habitsTag(app) });
const timeSpanOptions = Object.keys(this.inTimeSpan).reduce( (
(acc, val) => { if (!val.startsWith('_')) acc.push({label: val, value: val}); return acc; }, []);
const habitOptions = habitHandles.reduce( (
(acc, val) => { acc.push({label: val.name, value: val.uuid}); return acc; }, []);
const result = await app.prompt("", {
inputs: [
// { label: "Free floating (won't include the habit inline tag; tag expected right after the counter)", type: "checkbox" },
{ label: "Which time span to track?", type: "select", options: timeSpanOptions }, {
{ label: "Which habit to track?", type: "select", options: habitOptions }, ] });
] });
});
 
if (result) {
const [ /*standalone,*/ timeSpanOption, habitOption ] = result;
const repl = this.markdown(
0,
habitOptions.find(val => val.value === habitOption).label,
timeSpanOptions.find(val => val.value === timeSpanOption).label,
habitOption,
false /*standalone*/);
await app.context.replaceSelection(repl); // using replaceSelection() to parse markdown.
 
this.updateStats(app); } } },
} } },
} },
},
 
habitToCalculateRegex: /(?<beforeCount>(\\\|)*?\s*?\[(\\\|)*?\s*?)(?<habitTickedCount>[🟩⬜️]*?\s*?[𝟬𝟭𝟮𝟯𝟰𝟱𝟲𝟳𝟴𝟵\/]+)(?<afterCount>(\s*?(?<timeSpan>((this week)|(last week)|(this month)|(last month)))\s*?(\\\||\|)\s*?|\]\[\^.*?\]\s*?(\\\||\|)\s*?\[)(?<habitName>.*?)\]\((?<habitURL>https:\/\/www.amplenote.com\/notes\/(?<habitUUID>.*?))\))/g,
 
async updateStats(app) {
// ensure momentjs is loaded
await this._loadMoment();
 
const untickedMark = this.checkmark(app, false);
const tickedMark = this.checkmark(app, true);
const switchWeekStart = this.switchWeekStart(app);
const currentContent = await app.getNoteContent({ uuid: app.context.noteUUID });
const counts = {};
 
const dailyJotHandles = await app.filterNotes({ tag: this.dailyJotsTag(app) });
 
// search for habit tracker widgets in the current note
for (const match of currentContent.matchAll(this.habitToCalculateRegex)) {
if (!match || !match.groups.habitUUID) {
return false; }
}
 
const checkboxInsideRegex = new RegExp(`\\[\\s*?(${untickedMark}|${tickedMark})\\s*?[^\\].]*?\\]\\(${match.groups.habitURL}.*?\\)`, "g");
const checkboxBeforeRegex = new RegExp(`\\[(${untickedMark}|${tickedMark})\\]\\[\\^\\d*?\\]\\s*?\\[[^\\].]*?\\]\\(${match.groups.habitURL}.*?\\)`, "g");
 
var untickedCount = 0, tickedCount = 0;
 
const habitNoteHandle = await app.findNote({ uuid: match.groups.habitUUID });
 
// filter all the backlinks to the ones that are daily jots and in the time span specified
const backlinks = await app.getNoteBacklinks({ uuid: habitNoteHandle.uuid });
const jotsFound = backlinks.filter(backlink => {
return dailyJotHandles.find(jot => {
if (jot.uuid !== backlink.uuid) return false // return early if not the note we're looking for
 
const jotDate = moment(jot.name, "MMMM Do, YYYY");
return this.inTimeSpan[match.groups.timeSpan](jotDate, switchWeekStart).isIt; }) });
}) });
});
 
// Amplenote seems to have a bug on mobile and getNoteBacklinks can return
// the same note more than once so, make the list unique
const jots = jotsFound.reduce((map, jot) => map.set(jot.uuid, jot), new Map());
 
// loop over the backlinks and count the habit occurances and marked done
for (const backlink of jots.values()) {
const refContent = await app.getNoteContent({ uuid: backlink.uuid });
 
for (const matchInRef of refContent.matchAll(checkboxInsideRegex)) {
matchInRef[1] == untickedMark ? untickedCount++ : tickedCount++; }
}
for (const matchInRef of refContent.matchAll(checkboxBeforeRegex)) {
matchInRef[1] == untickedMark ? untickedCount++ : tickedCount++; } }
} }
}
counts[`${match.groups.habitURL}_${match.groups.timeSpan}`] = {
tickedCount,
total: tickedCount+untickedCount,
spanSize: this.inTimeSpan[match.groups.timeSpan](moment(), switchWeekStart).spanSize
}; }
}
 
// update all the stat counters in the note
const edited = currentContent.replaceAll(this.habitToCalculateRegex, (
(match, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15, p16, p17,
offset, string, groups) => {
const count = counts[`${groups.habitURL}_${groups.timeSpan}`];
const tickedRaw = count.tickedCount / (count.spanSize > 7 ? count.spanSize/7.0 : 1);
const tickedLength = tickedRaw < 7 ? Math.ceil(tickedRaw) : tickedRaw; // round up if not at the very full, so 0.1 shows up as 1, but 6.5 is not yet full
const progressBar =
'🟩'.repeat(tickedLength)
+ '⬜️'.repeat(7 - tickedLength)
+ " "
const replacement =
groups.beforeCount
+ progressBar
+ this.toSmallNumerals(count.tickedCount)
+ "/"
+ this.toSmallNumerals(count.spanSize)
+ groups.afterCount;
return replacement; });
});
const note = await app.notes.find(app.context.noteUUID);
await note.replaceContent(edited);
 
// todo: consider treating any occurance of the habit name as a checkbox if a full task or completed task
},
 
linkOption: {
"Refresh": {
check: async function(app, link) {
//load momentjs early
await this._loadMoment();
 
const currentContent = await app.getNoteContent({ uuid: app.context.noteUUID });
 
// search for habit tracker widgets in the current note
// todo: consider treating any occurance of the habit name as a checkbox if a full task or completed task
for (const match of currentContent.matchAll(this.habitToCalculateRegex)) {
if (match && match.groups.habitUUID) return true; // bail early since we found at least one.
} },
},
 
run: async function(app, link) {
this.updateStats(app);
}
}
}
}