DataView Habit Tracker

Things I have tried

Reading the docs

What I’m trying to do

I’m trying to build a habit tracker into my daily note.

In my template, I’ve got a heading for habits and a few tasks under it that I’m trying to build/track.

## Habits
- [ ] Habit 1
- [ ] Habit 2

Below this, I’d like to display a table using dataview that has each habit as a column and each of the past 7 or so days as rows, with the table entries containing whether it was completed, with the date in the first column.

Ideally, I’d like it to automatically select the habits based on what’s currently under the ## Habits subheading in the current note, so that in the future if I change habits I don’t need to update the code. However, since it’s in the template, it wouldn’t be too much trouble to manually edit the code whenever I change which habits I’m tracking.

Even better would be if I can check/uncheck the tasks from this summary. So if I make my daily note today and see that I forgot to check off yesterday’s habit (but remember doing it), I can check it off without switching to yesterday’s note.

Is there any way to implement this?

2 Likes

Hi. Welcome to the Forum.

A simple question to start: what’s your knowledge of dataview?

Did you make any dataview query? How many reads you done of the plugin documentation?
https://blacksmithgu.github.io/obsidian-dataview/

Why all these questions? Don’t get me wrong, but I see a multiple wishes (“I’d like this” and “I’d like that”…) without any report of attempts and identification of limitations.

Do you know the differences between table queries and tasks queries? (I can add this info: in task queries you can check/uncheck tasks; in tables no, you have only the text)

Do you know how columns work in table queries? Do you know how works the relation between columns and rows in the dataview queries output? How do you add the date information to your note or, specifically, to your tasks? What’s the format of the title of your daily note?

You can start with this (more basic) table:

```dataview
TABLE file.tasks.text[0] as H1, file.tasks.text[1] as H2, file.tasks.text[2] as H3
WHERE file.path = this.file.path
```

Or with this task query:

```dataview
TASK
WHERE file.path = this.file.path
WHERE meta(section).subpath = "Habits"
```
2 Likes

Thanks for the reply. I’ve read through the docs, and looked at some similar examples.

The closest to an ideal solution I’ve been able to come up with is this:

TABLE WITHOUT ID
	link(file.name) as "Day",
	file.tasks[0].completed AS "HABIT 1",
	file.tasks[1].completed AS "HABIT 2"
	FROM "2022" 
	WHERE date(file.name) < date(this.file.name)
	SORT file.name DESC
	LIMIT 10

This is what I’m using so far. It’s not pretty but it works as long the task list on the past 10 days is identical to the current one. It has a few issues though:

  1. It doesn’t work properly if the previous days have different tasks or the tasks are in a different order because they are just referenced by number. Instead of referencing the tasks by number, is there a way to reference them by name?
  2. It doesn’t look very nice as each is listed as “true” or “false”. Is there any way convert the true/false into a green check/red x (or checked and unchecked boxes)? (and ideally leave it blank or put some other indication if the task doesn’t exist on that day)
  3. It needs to be manually updated whenever I modify which habits are tracked. Can I set up a loop over the tasks in the current file/header to populate the contents and headers?

I can also do a list of tasks similar to your second example, but then they’re sorted by day and not by each task. I tried this to group them by task

TASK 
FROM "2022"
GROUP BY text

But then this loses the source filename and adds an extra group if they have a specified completion date. Even if I could make this method work, the list of tasks isn’t as nicely organized as the table, so I haven’t spent much time on this approach.

I’m not sure whether there’s any way to combine a TABLE query with a TASK query to get the tasks (ideally just the checkboxes, no names or completion dates) into the table? If there isn’t and I have to choose between the two, I’ll stick with the table layout and just click through to the source note if I need to update anything.

I imagine much of this could be done with the dataview javascript, but you can assume I know little-to-nothing about javascript.

Bonus question: Is it possible to transclude dataview code and have it reference the note it’s transcluded into? So for my daily note I could just have ![[Habit Tracker]] and it would list the habits for the 10 days prior to the date of the note it’s displayed on? So that if I decide to improve my habit tracker layout/code, it can automagically fix itself on all previous notes? Currently if I try this, any mentions of this appear to reference the source note rather than the current note.

Hi, again.

You have many questions and it isn’t practical try to answer to all in one post: I would lose the patience to explain all the steps clearly and you would lose the necessary attention to understand each step/option.

Let’s start fixing some points:

  1. Considering your explanation, let’s exclude the tasks queries option, choosing in the TABLE option. (with this option we assume the lack of the interaction check/uncheck, just the content/metadata)
  2. Dates are a important thing to tracking queries. Without a specific field for date, where we can “extract” this data? If we are talking about daily notes, what’s your title format? You use “YYYY-MM-DD” format in title? If yes, we can work with the implicit fiel file.day directly, instead of the date(file.name).
  3. With the previous option, you can define the number of the days, instead of a defined LIMIT 10. For example, for the previous 7 days:
...
WHERE file.day > this.file.day - dur(7days)
WHERE file.day != this.file.day
...

(the second command is an option to exclude the current note)

  1. About the main content - the tasks -, you need to choose a way to fix a metadata to each habit to avoid the order issue. I suggest something independent from headers, a “free style” task appointment, i.e., placing your tasks where you want in your daily note, not necessarily in a specific order or as a group under a header. For that we need to add another “metadata” to each task, using an inline field. Something like this:
- [ ] Run with Mary (exe:: run) 
- [ ] Walking 2km with my dog [exe:: walking]

With this method we define the type of exe(rcise): run, walking, etc. In the example, with the field exe we can add the wanted value to each type of habit. (to see the difference between the use of the square brackets or round brackets in inline-fields - a style thing -, go to settings > dataview and enable inline field highlighting).

  1. With this method, you can explore some ways to work with the task metadata in a table. For example:
TABLE WITHOUT ID
	filter(file.tasks, (t) => t.exe = "run").text AS Run,
	choice(filter(file.tasks, (t) => t.exe = "run").completed, "🟢", "🔴") AS Run2,
	filter(file.tasks, (t) => t.exe = "walking").text AS Walking,
	choice(filter(file.tasks, (t) => t.exe = "walking").completed, "🏃🏻", "🙉") AS Walking2
...
  1. For now, it’s all. Play and return with your doubts.
1 Like

Thanks for the choice and filter for converting from true/false to symbols, that works perfectly for that portion.

I am using the daily note with filename YYYY-MM-DD. I assume it’s faster to use file.day instead of date(file.name) and that’s why you recommend it? I’ve switched.

I figured I could do away with the text entirely, so I modified my checkboxes to

- [ ] [habit:: habit 1]
- [ ] [habit:: habit 2]

This works well for me, it means I don’t need to put all the habits in the same order or under a specific heading.

And for my summary table I’ve got

TABLE WITHOUT ID
	link(file.name) as "Day",
	choice(filter(file.tasks, (t) => t.habit = "habit 1").completed, "✔️", "❌") AS "habit 1",
	choice(filter(file.tasks, (t) => t.habit = "habit 2").completed, "✔️", "❌") AS "habit 2"
	FROM "2022"
	WHERE file.day < this.file.day
	WHERE file.day >= this.file.day - dur(7days)
	SORT file.name DESC

Which gives:
image

Which will work well, though I’m not sure why it has bullet points for each entry.

Is there any way to replace the choice(filter(...)) AS "..." with something that will automatically produce this for all the “habit” values on the current note?

I tried to use something with map

	map(this.file.tasks.habit, (h) => (choice(filter(file.tasks, (t) => t.habit = h).completed, "✔️", "❌") AS h))

But it won’t parse. Even if it would this.file.tasks.habit also returns “-” for any tasks that don’t have a “habit” tag.

Thanks for your help getting this working. I completely understand if you don’t have more time to spend on my issue, you’ve already gotten me to a working solution.

Ok, so playing with it a bit more, I can filter out the “-” (which is actually null) with

filter(this.file.tasks.habit, (h) => h != null)

But I still can’t get it working with map.

For me file.day it’s a more flexible field to work because in some cases you can use a title like “2022-03-27 Sunday” or “2022-03-24 - the big day”… and it works. It’s a date field by default and can be formatted - for example, if you want a different output for your file name you can use another date format as output for your file link in the table:
link(file.link, dateformat(file.day, "yyyy-MM-dd cccc")) AS "File".

file.tasks is (or are) by default an array/list (as file.inlinks, file.outlinks, file.etags, etc.). And for lists/arrays you get the bullet point. I’m accustomed to not see them because I use the Minimal theme (where by default, via css, this points are removed). You can try the theme or define a css snippet for that.

I think you can’t (with dql, about js I don’t know… because I’m not a coder or similar, just an user with some experience in playing with dql queries…), because you want different habits in diffeerent columns, not in the same column, right?

h != null means «where h exists» (empty field or no field is the same), so you can replace that only by filter(this.file.tasks.habit, (h) => h)

1 Like

Thanks. I encased the whole thing in string to convert from a list to a string without having to mess with CSS.

So

choice(filter(file.tasks, (t) => t.habit = "habit 1").completed, "✔️", "❌") AS "habit 1"

becomes

string(choice(filter(file.tasks, (t) => t.habit = "habit 1").completed, "✔️", "❌")) AS "habit 1"

and the bullets disappear.

Maybe when I have some time in the future, I’ll try the javascript to see if I can muddle my way through making an improved version of this.

Thanks again for your help!

Yes, you can do that… but if more than one value (for example a repeated habit) then you get a “flatten” output with commas (as join()).

I don’t have any like that at the moment, but I’ll consider editing it if/when it comes up. I think having them in a comma-separated list for each day might be best anyways.

Thanks again for all your help!

Is there an advantage to using this as opposed to LIMIT 7?

Well, it depends on you.
On technical side, I don’t know if any difference between WHERE and LIMIT in performance level.
On the rigor side, I prefer to work with dates.
But if you are tracking something based on habits in daily notes without any filter (i.e., you get all daily notes), you can use the limit command.
But one day, when you prefer a table for a specific month, period or a year, then you will need file.day

@DevinBaillie

I wrote a DataviewJS query that I think does what you’re looking for. Hopefully others find it useful as well.

Some notes:

  • It assumes every habit is a task under a header named “Habits”. (This can be changed pretty easily).
  • It automatically creates table headers based on unique task names (no duplicates).
  • This means you don’t need to define any specific Dataview annotations. Just normal tasks.
  • If a task didn’t exist previously (or no longer exists), you’ll see a “:heavy_minus_sign:” for its status. Otherwise, “:heavy_check_mark:” for complete and “:x:” for incomplete.

For example, this is how my template is setup:

# {{date:MMMM D, YYYY}}

---

## Habits
- [ ] Exercise
- [ ] Read
- [ ] Study
- [ ] New Habit

<dataviewjs code block here>

Code:

const habits = [] // Array of objects for each page's tasks.
const defaultHeaders = ["Day"]
const headers = new Set(defaultHeaders) // Set of task names to be used as table headers.
const rows = []

const noteDay = dv.current().file.day
const pages = dv
  .pages('"Daily Notes"')
  .where((p) => p.file.day < noteDay) // Excludes current note in table.
  .where((p) => p.file.day >= noteDay.minus({ days: 7 })) // Only include previous week in table.
  .sort((p) => p.file.day, "desc") // Sort table by most recent day.

for (const page of pages) {
  // Only include tasks under a header named "Habits".
  const pageHabits = page.file.tasks.filter(
    (t) => t.header.subpath == "Habits"
  )

  const noteLink = page.file.link
  noteLink.display = page.file.day.weekdayLong // Set display name of the note link to the day of the week.
  const habitsObject = { noteLink }

  for (const habit of pageHabits) {
    habitsObject[habit.text] = habit.completed // Build habitsObject. Key is the task's text. Value is tasks's completion.
    headers.add(habit.text) // Build headers set where each header is the task's text.
  }

  habits.push(habitsObject)
}

for (const habitsObject of habits) {
  const row = [habitsObject.noteLink] // Start building row data. Fill in first value (Day) with note link.
  for (const header of headers) {
    if (defaultHeaders.includes(header)) continue // Don't overwrite default headers.

    let habitStatus = "➖" // This emoji is seen if a corresponding task doesn't exist for a header (e.g. task didn't previously exist).
    if (Object.hasOwn(habitsObject, header)) // If task exists, we know it must be complete or incomplete.
      habitStatus = habitsObject[header] ? "✔" : "❌"
    row.push(habitStatus)
  }
  rows.push(row)
}

dv.table(headers, rows)

If anyone has any questions or suggestions please let me know!

6 Likes

Thank you for sharing javascipt.
However I am get following error

Evaluation Error: TypeError: Cannot read property 'minus' of undefined

I got past this error by having Date in “YYYYMMDD” format in file title.
After that I got error for hasOwn()

Object.hasOwn is not a function

which I changed it to habitsObject.hasOwnProperty(header).
Script is working.

Just curious, what version of Obsidian are you using?

I am using obsidian v0.14.2

Sorry for the delay in replying, I’ve been away on vacation and haven’t been on my computer.

Thanks for the script, this looks great. I had the same issue, version number, and solution as Jigar.

One additional issue that I’m running into. I use the Tasks plugin, which adds a completion date after checking off each task, so after checking off a task it becomes: - [x] Testing ✅ 2022-04-04

This means that each day’s tasks become their own unique column on the table, and includes the completion date in the table header. Is there any way to remove the check and completion date from the task name? Edit: To be clear, I want to filter out the check and completion date in the dataviewjs script, while keeping them in the original task name, rather than remove them from the original task name.

Other than that, this works perfectly for my needs!

Ok, so I figured that part out.

I changed

  for (const habit of pageHabits) {
    habitsObject[habit.text] = habit.completed // Build habitsObject. Key is the task's text. Value is tasks's completion.
    headers.add(habit.text) // Build headers set where each header is the task's text.
  }

to

	for (const habit of pageHabits) {
		const habitStr = habit.text.split(' ✅')[0];
		habitsObject[habitStr] = habit.completed // Build habitsObject. Key is the task's text. Value is tasks's completion.
		headers.add(habitStr) // Build headers set where each header is the task's text.
	}

So now it correctly throws out all the text after ’ :white_check_mark:’ and groups them into a single column.

But for some reason it’s not actually getting the completion status of the tasks and is leaving everything as ‘:heavy_minus_sign:’, regardless of whether it’s on the page or not. For some reason the

		if (Object.hasOwnProperty(habitsObject, header)) // If task exists, we know it must be complete or incomplete.
			habitStatus = habitsObject[header] ? "✔" : "❌"

Is always returning false, so it’s never making the substitution.

If I move the statement

habitStatus = habitsObject[header] ? "✔" : "❌"

outside the if statement, then it makes the substitution for everything, putting “:x:” for days that the habit doesn’t exist and for days where the habit isn’t completed.

Edit: Never mind, I realized I mucked up the if statement. It should be

		if (habitsObject.hasOwnProperty(header)) // If task exists, we know it must be complete or incomplete.
			habitStatus = habitsObject[header] ? "✔" : "❌"

And it works perfectly!

1 Like

In case anyone else comes along looking for something like this, this is what I went with. Tasks are placed under a heading or subheading “Habits” in my daily note. The script is almost identical to Genshii’s, but includes the fix from Jigar above, and has been tweaked to include new habits added today as columns (that are filled with “:heavy_minus_sign:”) and filter out the completion date added by the “Tasks” plugin. Thanks for the help everyone!

const habits = [] // Array of objects for each page's tasks.
const defaultHeaders = ["Day"]
const headers = new Set(defaultHeaders) // Set of task names to be used as table headers.
const rows = []

for (const habit of dv.current().file.tasks.filter((t) => t.header.subpath == "Habits")){
	const habitStr = habit.text.split(' ✅')[0];
	headers.add(habitStr)
}

const noteDay = dv.current().file.day
const pages = dv
	.pages('"2022"')
	.where((p) => p.file.day < noteDay) // Excludes current note in table.
	.where((p) => p.file.day >= noteDay.minus({ days: 7 })) // Only include previous week in table.
	.sort((p) => p.file.day, "desc") // Sort table by most recent day.

for (const page of pages) {
	// Only include tasks under a header named "Habits".
	const pageHabits = page.file.tasks.filter(
		(t) => t.header.subpath == "Habits"
	)
	
	const noteLink = page.file.link
	noteLink.display = page.file.day.weekdayLong // Set display name of the note link to the day of the week.
	const habitsObject = { noteLink }
	
	for (const habit of pageHabits) {
		const habitStr = habit.text.split(' ✅')[0];
		habitsObject[habitStr] = habit.completed // Build habitsObject. Key is the task's text. Value is tasks's completion.
		headers.add(habitStr) // Build headers set where each header is the task's text.
	}
	
	habits.push(habitsObject)
}

for (const habitsObject of habits) {
	const row = [habitsObject.noteLink] // Start building row data. Fill in first value (Day) with note link.
	for (const header of headers) {
		if (defaultHeaders.includes(header)) continue // Don't overwrite default headers.
		
		let habitStatus = "➖" // This emoji is seen if a corresponding task doesn't exist for a header (e.g. task didn't previously exist).
		if (habitsObject.hasOwnProperty(header)) // If task exists, we know it must be complete or incomplete.
			habitStatus = habitsObject[header] ? "✔️" : "❌"
		row.push(habitStatus)
	}
	rows.push(row)
}

dv.table(headers, rows)
3 Likes

Would it be possible to adapt the code so that it can be in a separate note?
And in that note, we could define the habits that interest us.