New plugin: Habit Calendar

Inspired by duo’s Habit Tracker plugin I created the Habit Calendar plugin to help conveniently render a calendar in dataviewjs to display all your habits in a month.

Render the calendar

This plugin is supposed to be used alongside DataviewJS.

To use this plugin

  1. create a dataviewjs code block
  2. prepare your data of each day
  3. pass the data to renderHabitCalendar
```dataviewjs
renderHabitCalendar(this.container, {
  year: 2023,
  month: 1,
  entries: [{
    date: '2023-01-01',
    content: '⭐'
  }, {
    date: '2023-01-03',
    content: '⭐'
  }]
})
```

For example, the above code block will be rendered as follow

Link days to pages

Diary is the perfect place to record our habits, so by default, each day is linked to the corresponding diary, if the diary exists. You can either hover on the day number to preview the page or click it to jump right to the page.

hover

The linking is done automatically if your diary name is of YYYY-MM-DD format. In case you are using a different naming format, just pass in the link like:

```
renderHabitCalendar(this.container, {
  year: 2023,
  month: 1,
  entries: [{
    date: '2023-01-01',
    content: '⭐',
    link: '2023年01月01日'  // like this line
  }, {
    date: '2023-01-03',
    content: '⭐',
    link: '2023年01月03日'
  }]
})
```

HTML support

You can fill each day in the calendar with HTML, which I believe will give more freedom to customize the calendar.

```dataviewjs
renderHabitCalendar(this.container, {
  year: 2023,
  month: 1,
  format: 'html',   // set the format to html
  entries: [{
    date: '2023-01-01',
    content: '<a href="https://www.google.com">Google</a>'
  }, {
    date: '2023-01-03',
    content: '⭐',
  }]
})
```

Note: to use the HTML feature, you should enable it in the settings first

Markdown support

If you think writing HTML in a markdown file is too boring, you can also fill the calendar with markdown content.

```dataviewjs
renderHabitCalendar(this.container, {
  year: 2023,
  month: 1,
  format: 'markdown',   // don't forget to change the format~
  entries: [{
    date: '2023-01-01',
    content: '[Google](https://www.google.com)'
  }, {
    date: '2023-01-03',
    content: '⭐',
  }],
  filepath: dv.current().file.path  // also add this line
})
```

Feel free to drop some questions and suggestions about this plugin.:blush:

2 Likes

Do you have an example where you’re using a query to populate your entries? Will it accept the result from a table query, and if so which columns needs to be used?

Do you have an example where you’re using a query to populate your entries?

Yes, typically I will record my habits in my diary like this:

## habits

- [ ] #habit read for (reading:: 30) minutes
- [ ] #habit jog for (jogging:: 30) minutes
- [ ] #habit get up before 8:00 am (wakey:: true)

For each habit, I will check the box if I finished it.

Then I use the following code to collect the data spreading over the diaries and render the calendar(take the reading habit for example):

```dataviewjs
let files = dv.pages(`"diarys"`)
let data = []
for (let file of files) {
	for (let task of file.file.tasks) {
		if (task.tags.contains('#habit') && task.checked && task.reading) { // select only checked habits
			data.push({date: file.file.name, content: `📖 ${task.reading} min`})
		}
	} 
}
renderHabitCalendar(this.container, {year: 2023, month: 2, entries: data, filepath: dv.current().file.path, width: "100%"}) 
```

Will it accept the result from a table query, and if so which columns needs to be used?

Actually I am quite new to Obsidan and Dataview and didn’t use the table query a lot, but I do like the freedom of writing js code to collect data, hence the plugin. Currently the renderHabitCalendar api accepts a list of entris of the following format

{
    date: "date of the habit",
    content: "content you'd like to display in the calendar cell",
    link: "link the calendar cell to some page"
}

So it doesn’t accept table query for now. May be you can show me how you’d like to render the calendar based on a table query?

Sorry if this is going to be a little overwhelming, but there are three main criteria for me to consider a habit tracker plugin, or put another way what I would want out of such a plugin. Two visual criteria, and one data based related:

  • The possibility to track multiple habits in the same view
  • A clear indication of streaks (preferably across weekends, as well)
  • An easy, intuitive(?) way to input the data

Not so important, but nice to have features:

  • A popup showing details for a given day
  • Variable width/weight for the individual habits
  • Potentially a legend showing the color of the various habits

Here is a mockup for a view resembling what I would like:

Habit Calendar view with legend

In this image we see that the green habit, Daily walk, has double the width of the other habits, and there is an ongoing streak from the 13th, through the weekend (shown by “extending” outside of the calendar) into the 19th. The blue habit, Workout, has a gap with a little weekend streak. The orange habit, Forum activity, has a streak starting on 14th going through 18th, but it ended on the 18th.

In addition, the popup shows the details from the 18th, utilising the original data used for creating this view. In image form this would look like:

Habit calendar base data

Mockup of habit data table

Here is a manual mockup of the data table presumably used for the image above. Ideally this is the output from a TABLE query, which would gather the data from the current month.

| Date       | Daily walk (2x) | Workout | ... | Forum Activity |
| ---------- | --------------- | ------- | --- | -------------- |
| ...        | ...             | ...     | ... | ...            |
| 2023-02-13 | 50 min          | 30 min  | ... | -              |
| 2023-02-14 | 54 min          | -       | ... | Obsidian forum |
| 2023-02-15 | 70 min          | -       | ... | Obsidian forum |
| 2023-02-16 | 40 min          | 30 min  | ... | Obsidian forum |
| 2023-02-17 | 50 min          | 30 min  | ... | Obsidian forum |
| 2023-02-18 | 50 min          | 30 min  | ... | Obsidian forum |
| 2023-02-19 | 55 min          | -       | ... | -              |
| ...        | ...             | ...     | ... | ...            |

The idea is simple, to re-use the data straight out of a table (preferably made by dataviewjs, where there is one row for each day with habit data, where the first column is always the date, and the following columns are the habit one would like to track. If a column has any value, it’s shown as a line for that date, and the value is inserted into a div (hidden by default, but visible when hovering the date).

The column header is used to identify the habit (and could possibly be used in showing a legend), and used in the popup. If the header ends with (Nx) it signifies the weight of this habit compared to the others.

Using the output of a table query would make it a whole lot easier to populate a habit calendar, as we could then opt for all kinds of methods for tracking the habits, as long as it’s doable to put it into a table. And we wouldn’t have to “massage”/“manipulate” the data through more or less intricate methods to be accepted by the plugin.

Some practical tips related to coding this

I’m imaging the popup div to be populated but having a display: none when creating the table, and then using :hover on the date cell to display it. This I think would be better than displaying the daily note, as the daily notes could be long and not neccessarily have the “habit data” at the top ready to be shown in the popup. The daily note would still be available by clicking on the day cell.

Regarding the weights, if the inner height of a cell is a multiple of 24, one could easily accommodate weights like 1, 2, 3, and 4, since 123*4 = 24. This would ensure nice crisp lines within the cells. (It would also make weights of 6, 8, and 9 be crisp, so given a little knowledge about this, one could easily allow for neat crisp habit lines).

Using the “any value” to signify a habit completion, would both allow for easy tracking, but also for more interesting popup values, as they could then either be copied verbatim, or prefixed with the habit name. Another option could even be to just join the various values for that day into a simple list, either like “Daily walk, 30 min - Workout, 50 min - Forum Activity, Obsidian forum”, or if skipping the headers using some other texts in the column: “Walked around the lake, workout 50min, a few hours on the Obsidian forum”.

Preferably there would a settings pane allowing to select the various color to maintain a proper scheme. This could possibly utilise color like the already defined var(--color-orange) & co and/or rgb values. Allowing for the variable color definitions, it would be easy to style this plugin accordingly to the color scheme already set for the theme.

Some more details on the TABLE query output

When using dataviewjs you’ve got the option to do queries like:

```dataviewjs
const result = await dv.query(`
  TABLE walk as "Daily Walk (2x)", workout as "Workout", forum as "Forum Activity
 FROM "daily notes"
WHERE period = date(2023-02)
`)

if ( result.successful ) {
  const values = result.value.values
  // For displaying as a table  
  // dv.table(result.value.headers, result.value.values)

  // For display as Habit Calendar (possibly? )
  renderHabitCalendar(this.container, {
   year: 2023, month: 2    // or maybe: month: date(2023-02),
    habits: result.value.headers,
    entries: result.value.values, 
    width: "100%"
  })
} else
  dv.paragraph("~~~~\n" + result.error + "\n~~~~")
```

This would allow for easy verification of your input data, as you could just switch to (or add) the dv.table() variant, and there would be no complications getting to show the habit calendar based on your query.


So that’s some of my thoughts on what I would like out of a habit plugin. If only I had the time to program it, the world would be a better place. But you seem to have gotten a solid base so far, so maybe some of these ideas of mine would be suitable for incorporation into your plugin?

Thanks for sharing the thoughts of habit tracking and detailed explaination as well as the coding part. I think your thoughts make a lot of sense for habit tracking. After checking out your example of table query I realize it’s a great feature for to pass data to the plugin, in a user-friendly way. I think will implement this feature when I have spare time.

For now this plugin is more like a general purpose calendar, allowing user to input daily daily arbitrary content to populate the calendar. The content is not structured. To implement more fancy features like treaks and styling, it may take some time. I am not sure these features will be implemented or not in the near future. If only I’ve got the time to improve the plugin.

Again, appreciate it for your thoughts and coding ideas, benefits me a lot in the way of using obsidian.

1 Like

So I’m trying to implement the plugin as it is today, but I need a little more info to duplicate the results of your posting @Frank_ob . Sorry, I’m a dataview beginner so I’m being tripped up on the basics.
I can get your example for the Reading habit. How about the other habits that were checked for that day, like jogging and wakey? How are you able to show those on the same calendar like you show in the original image? I think I need to include the other habits in an OR statement, but not sure quite how to do it.

You can update the plugin to 1.0.11 and use the following code to display the habits:

```dataviewjs
let pages = dv.pages(`"diarys"`)
const year = 2023
const month = 2

let data = {}
for (let page of pages) {
	let date = page.file.name
	if (!(date in data)) {
		data[date] = ''
	}
	for (let task of page.file.tasks) {
		if (task.tags.contains('#habit') && task.checked && task.reading) { // select only checked habits
			data[date] += `📖x ${task.reading} min\n`
		}
		if (task.tags.contains('#habit') && task.checked && task.jogging) { // select only checked habits
			data[date] += `🏃x ${task.jogging} min\n`
		}
		if (task.tags.contains('#habit') && task.checked && task.wakey) {
			data[date] += '🌞\n'
		}
// more habits can be added here
	} 
}
let calendarData = []
for (let date in data) {
	calendarData.push({date: date, content: data[date]})
}
renderHabitCalendar(this.container, {year, month, entries: calendarData, filepath: dv.current().file.path, width: "100%"}) 
```

This should look like

That did it, thanks!

1 Like

Does the plugin still work? When I try to use it, even in the simplest way possible, I get this error:

Evaluation Error: TypeError: link.fileName is not a function
    at param2CalendarData (plugin:habit-calendar:113:47)
    at window.renderHabitCalendar (plugin:habit-calendar:57:36)
    at eval (eval at <anonymous> (plugin:dataview), <anonymous>:7:1)
    at async DataviewJSRenderer.render (plugin:dataview:18670:13)

With this code :

const table = await dv.query(`
 TABLE WITHOUT ID jour, pompe as "pompe|pompe", abdo as "abdo|abdo", squat as "squat|squat"
 FROM "08📆 Journal"
 WHERE contains(file.name, "01-2024")
`)
console.log(table)
renderHabitCalendar(this.container, dv, {
	year: 2024,
	month: 1,
	data: table
})

and this error:

Evaluation Error: TypeError: dv.current is not a function
    at window.renderHabitCalendar (plugin:habit-calendar:56:20)
    at eval (eval at <anonymous> (plugin:dataview), <anonymous>:19:1)
    at DataviewInlineApi.eval (plugin:dataview:18638:16)
    at evalInContext (plugin:dataview:18639:7)
    at asyncEvalInContext (plugin:dataview:18649:32)
    at DataviewJSRenderer.render (plugin:dataview:18670:19)
    at DataviewJSRenderer.onload (plugin:dataview:18260:14)
    at e.load (app://obsidian.md/app.js:1:1147632)
    at DataviewApi.executeJs (plugin:dataview:19198:18)
    at DataviewPlugin.dataviewjs (plugin:dataview:20068:18)
    at eval (plugin:dataview:19967:124)
    at t.initDOM (app://obsidian.md/app.js:1:1539674)
    at t.toDOM (app://obsidian.md/app.js:1:1141789)
    at t.sync (app://obsidian.md/app.js:1:351034)
    at e.sync (app://obsidian.md/app.js:1:332767)
    at app://obsidian.md/app.js:1:373556
    at e.ignore (app://obsidian.md/app.js:1:452767)
    at t.updateInner (app://obsidian.md/app.js:1:373338)
    at t.update (app://obsidian.md/app.js:1:373093)
    at e.update (app://obsidian.md/app.js:1:461415)
    at e.dispatchTransactions (app://obsidian.md/app.js:1:458098)
    at e.dispatch (app://obsidian.md/app.js:1:459981)
    at app://obsidian.md/app.js:1:1556860

with this code:

let pages = dv.pages(`"08📆 Journal"`)
const year = 2024
const month = 1

let data = {}
for (let page of pages) {
	let date = page.jour
	if (!(date in data)) {
		data[date] = ''
	}
	if (page.pompe) {
			data[date] += `{page.pompe} pompes`
	} 
}
let calendarData = []
for (let date in data) {
	calendarData.push({date: date, content: data[date]})
}
renderHabitCalendar(this.container, {year, month, entries: calendarData, width: "100%"})