Automatic Gantt Chart from Obsidian Tasks & Dataview

I’m very fond of the Obsidian task plugin, and I wonder how to let Obsidian generate a Gantt Chart automatically from my existing tasks generated from this plugin.

I’ve worked on it for a day and a night trying to figure it out (since there’s no existing solutions to my best Googling effort…).

It’s finally done through a piece of dataviewjs codes.
Gibhub Link here

You may change the path of your project folder at the end of the code. My current path is “00 Life/Project Folder/Research”

However, the tickinterval cannot be adjusted through obsidian… thus when you have a long-term project the chart may not look nice… Hopefully mermaid in Obsidian will improve in the future.

19 Likes

This is great! Such a shame tickinterval can’t be adjusted as it really limits the use of the timeline… (I’m trying to mock up a PhD proposal plan)

Any way to split this by year (ie chart for 2023, 2034, etc) as a workaround whilst tickinterval can’t be adjusted? I’m quite new to dataviewjs so having trouble trying to modify the code…

2 Likes

Thank you so much! This is exactly what I was looking for. I’m surprised that there isn’t a plugin to make this easier yet. I find that some tasks really need to be vizualized this way.

The provided code worked for me with a few tweaks, maybe because part of the syntax has been deprecated since, so I’m sharing my version here.

I also added 2 other emojis (added date, done date) because I like the start date to be the day the task was first added. I made it so that when the :heavy_plus_sign: parameter isn’t specified, the date of creation of the note containing the task is used instead, which is why I’m passing noteCreationDate to the textParser function along with pageArray. This creation date is fetched from the note’s metadata, all my notes having a “created” YAML field with a timestamp, so the method here may need some adaptation.

Also I haven’t yet decided on how the dates should be used exactly – I never use start dates, only scheduled and due dates, so I’m still hesitating between switching systems or using the scheduled date as start date for mermaid, and the due or done date as an end date. Right now it’s probably a little messy, I’ll sort it out as I use the tool.

I removed the division of tasks into groups/sections because I found it harder to read that way. I think I left a commented out line that should be restored in order for that behavior to reappear (one would also have to change the return statement, see original script if needed)

I also added to the parser some lines that remove the #task tag, which is set in the Tasks plugin’s settings as the identifier for all tasks in my vault, and convert all other tags into [tags]. This may also not be needed ; however tags preceded with # have to be removed, otherwise the task isn’t properly parsed.

Now I think a great improvement would be to use that info to sort the tasks ; for example you would have a scroll-down menu and filter the task array by tags, so that the chart would only show some categories of tasks. (quite Notion-like, in fact) This is convenient for me because I put tags on tasks that belong to specific projects, but it can surely be adapted to other use cases.

Thanks again @LynnXie00! This has made my day (and I must say, quite literaly)

3 Likes

OK, I spent another day working on this thing. I’m stopping now. Here is an updated script with a lot of minor changes, including a button to toggle visibility of completed tasks. It is now a Templater template prompting for a “scope”, which can be a path (“folder/note” with the quotes), a tag (#tag, without the quotes), or a combination of both. It also creates a metadata field that serves for the on/off hidden tasks thing.

To use without templater, just replace the <% %> placeholder with a path of your choice.

Moderators: please excuse me. :bouquet:

gantt chart.md (5.6 KB)

4 Likes

I’m new to obsidian, and this automatic gantt chart is a large part of why I am giving it a shot.

It wasn’t clear to me how to get started so here are some tips to people similarly lost.
Plugins used:
Tasks - Converts text - [ ] into a task that is clickable and has various properties required for this chart.
Dataview - Lets you query your obsidian vault like a database and display results in notes.
Meta Bind Plugin - (optional) Enables a toggle button for showing/hiding completed tasks. You can remove this button if you skip this plugin.
Templater - (optional) Allows you to use this chart as a template, prompting for the path where tasks are found. You can also manually update the path after applying this template to skip this plugin.

Add “gantt chart.md” to whichever folder Templater is configured to store templates in, add a new note, apply template (“<%” icon), and provide a path (“Projects Folder/Open Projects” with quotes for example) where notes containing tasks are stored or a tag that all notes you want to scrape for tasks have.

I’m not really familiar with Obsidian or Javascript, but I made a few changes to the version @josephtribulat posted above.

  1. Added a few comments to the code. Could use more.
  2. I added sections back in as @LynnXie00 had them. All tasks found on a given note will be in a section named after the note
  3. Commented out the filter for pages with the #task tag. With this filter in place, no tasks were found even when the #task tag was applied to pages. I’m not clear why…
  4. Changed the last resort startDate for a task to use the page.file.ctime rather than the page.created. I am not sure where that value should have come from. Another plugin perhaps?
  5. If no startDate is specified on a task, it will now default to the endDate of the parent task, if the task has a parent. This allows you to have a series of dependent tasks as nested children and have the start date of all succeeding updated if you extend an earlier one. This may break if you specify an exact endDate. Next change helps with that.
  6. Can now provide a duration rather than an explicit end date. With dependent tasks that have no startDate specified and only a duration, you can easily modify the timeline for a nested chain of dependent tasks.

Example of dependent tasks with time ranges

Note: There is a auto-completion dialog when populating task start/end information that I think comes from the Tasks plugin. It doesn’t support durations so you will need to hit escape or click out of the dialog before hitting enter for a new line to avoid selecting a suggestion.

  1. Added a link to view the chart in a browser. This uses the mermaid.ink/img/ service which generates the image from base64 encoded data passed as part of the URL. I had to remove the emoji for the dummy task because btoa() didn’t like non-ascii characters.
  2. Added bugs I assume. There’s not a lot of error checking and there are likely a bunch of ways to break the rendering of the chart.

It would be great if this got further attention and updating. There is a lot more that can be added without making it much more complicated for a user with limited needs (also error checking…). Thanks again to @LynnXie00 and @josephtribulat

gantt chart.md (6.7 KB)

4 Likes

Thanks @pez . Nested tasks support is an interesting idea. I don’t use them the same way as you because the Tasks plugin doesn’t recognize them, so for me they’re more “sub-tasks” than dependent tasks, the parent depending on the children rather than the opposite; but this is a good step towards a system that would make dependencies visible the same way as in the Notion chronology view.

I very much second your opinion that this deserves more attention from seasoned developers.

Added a few comments to the code. Could use more.

Sorry about that, I removed a lot of them at some point because as it was on my daily note I wanted to make the code as compact as possible, I should have put them back before sharing the code.

Commented out the filter for pages with the #task tag. With this filter in place, no tasks were found even when the #task tag was applied to pages. I’m not clear why…

Not sure either, it works for me – but this part only makes sense if your Tasks plugin is configured like mine (i.e. the only - considered as proper tasks are those with a #task tag, the others being just checkboxes – I think it’s the default config).

Changed the last resort startDate for a task to use the page.file.ctime rather than the page.created. I am not sure where that value should have come from. Another plugin perhaps?

Of course. This is specific to my vault where there is a “created” field in every note. Your solution is far better.

As for the option to use durations instead of static due dates, it’s great if it works for you ; potential users of this should just keep in mind that it goes against the logic of the Tasks plugin which is to set static due, scheduled and/or start dates, so I think it shouldn’t be implemented in a use case where all tasks are concerned. In the plugin’s modal, “2 weeks” will resolve as static dates 2 weeks from the day you enter the task. A task such as “- Second Dependent Task :date: 10d” will therefore be considered by the plugin as a task without a due date, and it won’t show in a query targetting tasks due 10 days from now. If you’re a task query user that might be a problem. But I guess it can be useful for specific tasks that you only need to see in the gantt chart.

1 Like

i have been meaning to reply to this thread for a few months now, but i also took a crack at it.,

the script i wrote uses metadata from the to-do lists and file holding the gannt to add some more functionality

  • adjust the axis format based on the “axis_format” field ( a similar thing could be set up to also adjust tick interval, i just never got around to implementing that)
  • automatically set tasks as dependent/starting after a previous task based on task indentation
  • set specific tasks as critical/milestones using modifiers
  • set specific tasks as milestones

The gannt chart will use the to-do lists from all fields in your vault that have the #gannt and are linked to the file holding the gannt chart. Each linked file will be treated as a separate section in the gannt

auto gannt explanation pic 1.md (26.8 KB)

Autogantt.md (4.2 KB)

2 Likes

It looks like the main image is missing in the Excalidraw ‘auto gannt explanation pic’.

oh dang you are right.

here is the image.

1 Like

Thank you so much for your dedication guys! I sooo love the idea but just can’t seem to get any of your templates to work…

@NRemedy @josephtribulat @pez @LynnXie00
Could one of you please explain all necessary steps after putting it in the templates folder? where and in what format exactly do i fill in my folder path for each of your versions? seems to be different in all of them.

Best wishes and happy new year :slight_smile:

Hey there @boomshakalaka

Since the previous posts on this subject, I made this into a CustomJS user script. I find it more convenient. I call the script from within my daily note template like so:

let today = "{{date:YYYY-MM-DD}}";
let tomorrow = "{{date+1:YYYY-MM-DD}}";
let showDone = dv.current().showDone;

function checkAndExecuteGantt() {
    const { dailyGantt } = customJS || {};
    if (dailyGantt && dailyGantt.drawGantt) {
        let parsedToday = new Date(today);
        if (parsedToday.getDay() != 0) {
            dailyGantt.drawGantt(dv, today, tomorrow, moment, showDone);
        }
    } else {
        setTimeout(checkAndExecuteGantt, 500);
    }
}
checkAndExecuteGantt();

and here is the script itself:

class dailyGantt{

	isValidDate(d) {
		return d instanceof Date && !isNaN(d);
	}

	createDateFromString(dateStr) {
		if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr.trim())) {
			console.error("Date string format is incorrect:", dateStr);
			return null;
		}
		const [year, month, day] = dateStr.split('-').map(Number);
		const date = new Date(year, month - 1, day); // month is zero-indexed, day is one-indexed
		if (!this.isValidDate(date)) {
			console.error ("function createDateFromString() — Failed to create a valid date from:", dateStr, "— returning null");
			return null;
		}
		return date;
	}

    extractDate(emoji, taskText) {
        const start = taskText.indexOf(emoji);
         if (start < 0) {
            return "";
            }
            const match = taskText.slice(start + 1).match(/([12]\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01]))(\s|$)/);
            return match ? match[0] : "";
        }
        
    textParser(taskText, noteCreationDate) {
        const emojis = ["📅", "⏳", "🛫", "➕", "✅", "⏫", "🔼", "🔽"];
        const DueText = this.extractDate("📅", taskText);
        const scheduledText = this.extractDate("⏳", taskText);
        const startText = this.extractDate("🛫", taskText);
        let addText = this.extractDate("➕", taskText);
        const doneText = this.extractDate("✅", taskText);

        if (addText === "") {
            addText = noteCreationDate;
        }
    
        let h = taskText.indexOf("⏫");
        let m = taskText.indexOf("🔼");
        let l = taskText.indexOf("🔽");

        let PriorityText="";
        if(h>=0){
            PriorityText="High";
        }
        if(m>=0){
            PriorityText="Medium";
        }
        if(l>=0){
            PriorityText="Low";
        }
        const emojisIndex = emojis.map(emoji => taskText.indexOf(emoji)).filter(index => index >= 0);
        let words;
        if (emojisIndex.length > 0) {
            words = taskText.slice(0, Math.min(...emojisIndex)).split(" ");
        } else {
            words = taskText.split(" ");
        }
        words = words.filter((word) => (word) !== "#task");
        let newWords = words.map(
            (word) => word.startsWith("#") ? `{${word.slice(1)}}` : word);
        let nameText = newWords.join(" ");
        return {
            add: addText,
            done: doneText,
            due: DueText,
            name: nameText,
            priority: PriorityText,
            scheduled: scheduledText,
            start: startText
        };
    }
    
    loopGantt (args){
        const { pageArray, showDone, today, tomorrow, moment } = args;
        let queryGlobal = "";
        let i;
        for (i = 0; i < pageArray.length; i+=1) {
            let taskQuery = "";
            if (!pageArray[i].file.tasks || pageArray[i].file.tasks.length === 0) { 
                continue;
            }
            let taskArray = pageArray[i].file.tasks;
            let taskObjs = [];
            let noteCreationDate = moment(pageArray[i].file.cday).format('YYYY-MM-DD');
            let j;
            for (j = 0; j < taskArray.length; j+=1){

                taskObjs[j] = this.textParser(taskArray[j].text, noteCreationDate);
                let theTask = taskObjs[j];
                let monthLater = new Date(today);
                monthLater.setDate(monthLater.getDate() + 30);
                monthLater = monthLater.toISOString().slice(0, 10);
                let monthBefore = new Date(today);
                monthBefore.setDate(monthBefore.getDate() + 30);
                monthBefore = monthBefore.toISOString().slice(0, 10);

				monthLater = this.createDateFromString(monthLater);
				if (!monthLater) {
					console.error("Failed to create a valid date from monthLater:", monthLater);
				}

                if (theTask.name === "") continue; 
                if (!showDone && theTask.done) continue;
				if (theTask.name.includes("⛔")) continue;

                let taskName = theTask.name
                    .replace(/:/g, '') // Removes colons
                    .replace(/\(http[^\)]+\)/g, '') // Removes anything that starts with "(http" and ends with ")"
                    .replace(/\(file[^\)]+\)/g, '') // Removes anything that starts with "(http" and ends with ")"
                    .replace(/\(obsidian[^\)]+\)/g, '') // Removes anything that starts with "(http" and ends with ")"
                    .replace(/\[\[[^\|]+\|/g, '') // Removes wiki link aliases "[[...|"
                    .replace(/\]\]/g, '') // Removes "]]"
                    .replace(/\[|\]/g, '') // Removes "[" and "]"
                    .replace(/\(file[^\)]+\)/g, ''); // Removes anything that starts with "(file" and ends with ")"

                let startDate = theTask.start || theTask.scheduled || theTask.add || noteCreationDate || today;
				let newStartDate = this.createDateFromString(startDate);
				if (!newStartDate) {
					console.error("Failed to create a valid date from startDate: ", startDate);
				} else {
					startDate = newStartDate;
				}

				if (startDate > monthLater) continue;
				if (startDate < monthBefore) continue;

                let endDate = theTask.done || theTask.due || theTask.scheduled;
                if (!endDate) {
                    if (startDate >= today) {
                        let weekLater = new Date(startDate);
                        weekLater.setDate(weekLater.getDate() + 7);
                        endDate = weekLater.toISOString().slice(0, 10);
						endDate = this.createDateFromString(endDate);
						if (!endDate) {
							console.error("Failed to create a valid date from weekLater:", weekLater);
						}

                    } else {
                        endDate = tomorrow;
   
						endDate = this.createDateFromString(endDate);
						if (!endDate) {
							console.error("Failed to create a valid date from tomorrow:", tomorrow);
						}
					}
                }
                
                // Handling tasks with only a due date and no start date
                if (!theTask.start && !theTask.scheduled && theTask.due) {
                    let weekBefore = new Date(theTask.due);
                    weekBefore.setDate(weekBefore.getDate() - 7);  
                    startDate = weekBefore.toISOString().slice(0, 10);
					newStartDate = this.createDateFromString(startDate);
					if (!newStartDate) {
						console.error("Failed to create a valid date from weekBefore:", weekBefore);
					} else {
						startDate = newStartDate;
					}

                }

		if (endDate == startDate) {
		    let weekLater = new Date(startDate);
		    weekLater.setDate(weekLater.getDate() + 7);
		    endDate = weekLater.toISOString().slice(0, 10);
   
			let newEndDate = this.createDateFromString(endDate);
			if (!newEndDate) {
				console.error("Failed to create a valid date from weekLater:", weekLater);
			} else {
				endDate = newEndDate;
			}
	    }
                        
                // mise des tâches au format mermaid
                if (theTask.due){
                    if (theTask.due < today){
                        taskQuery += taskName  + `    :crit, ` + startDate + `, ` + endDate + `\n\n`;
                    } else {
                        taskQuery += taskName  + `    :active, ` + startDate + `, ` + endDate + `\n\n`;
                    }
                } else if (theTask.scheduled){
                    if (startDate >= today){
                        taskQuery += taskName + `    :active, ` + startDate + `, ` + endDate + `\n\n`;
                    } else {
                        taskQuery += taskName + `    :inactive, ` + startDate + `, ` + endDate + `\n\n`;
                    }
                } else {
                    taskQuery += taskName  + `    :active, ` + startDate + `, ` + endDate + `\n\n`;
                }
            }
            queryGlobal += taskQuery;
        }
        return queryGlobal;
    }
    
    drawGantt(dv, today, tomorrow, moment, showDone) {
        const Mermaid = `gantt
            dateFormat  YYYY-MM-DD
            axisFormat %b\n %d
            `;

        // Get all dashboard pages with status "en cours" (ongoing)
        let dashboardPages = dv.pages().where(p => (p.status == "en cours" || p.status == "permanent") && p.type == "dashboard");

        // Extract tagNames from these dashboard pages
        let tagNames = dashboardPages.map(page => page.tagName);

        // Get all pages that contain tasks with the extracted tagNames and #task
        let filteredPages = [];
        for (let page of dv.pages()) {
            let tasks = page.file.tasks.filter(t => 
                t.status != "-" &&
                t.text.includes("#task") && 
                tagNames.some(tag => t.text.includes(tag)) &&
                !t.text.includes("#someday") &&
                !t.text.includes("#waitingFor") &&
				   !t.text.includes("Revue hebdomadaire")
            );
            if (tasks.length > 0) {
                filteredPages.push({ ...page, file: { ...page.file, tasks: tasks } });
            }
        }
    
        let ganttOutput = this.loopGantt({pageArray:filteredPages, showDone, today, tomorrow, moment});
		// console.log(ganttOutput);
        ganttOutput += "🧘🏻‍♂️ :active, " + today + ", " + today + "\n\n"; // (dummy task to display today's date)
        dv.paragraph("```mermaid\n" + 
            Mermaid + 
            ganttOutput + 
            "\n```");
        // this prints a meta-bind inline field allowing the user to toggle a boolean in the metadata and thus show/hide completed tasks:
        //dv.paragraph(`Montrer les tâches terminées \`INPUT[toggle:showDone]\``);
    }

}


edit: a few corrections brought to the script a few months later
another edit: update to fix date creation logic broken by some change somewhere else + hide blocked tasks

Let me know if you could adapt it to your use case.

Hi everyone, I tried to made the edits such as paths but for some reason I still see this on my screen. I’ve already uploaded my tasks and path to the section so wondering whats the issue here?

Thank you very much,
I’m just afraid I still miss some steps…
What plugins/settings do I need for this?
How do I link to the dailyGantt.js ? just put it in \Templates\templaterjs ?

returns this:

Evaluation Error: ReferenceError: customJS is not defined
    at checkAndExecuteGantt (eval at <anonymous> (plugin:dataview), <anonymous>:6:28)
    at eval (eval at <anonymous> (plugin:dataview), <anonymous>:16: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
1 Like

You have to install CustomJS as well. Then you define a folder in CustomJS’ settings where you put the dailyGantt.js file above (folder relative to your vault root). For testing, you can set the dates in the beginning of the short script to an actual date like so:

let today = "2024-01-23";
let tomorrow = "2024-01-24";

Let me know if it works, because unfortunately it doesn’t for me (I have lots of tasks, but no Gantt is showing up although something is rendered:

grafik

Not sure what to make of this. Will be looking into it some more…

1 Like

@johe How are your tasks formatted? It looks like the parser function doesn’t work as intended, but the Gantt itself is rendered (the zen emoji is a dummy task to show the current date).

@boomshakalaka That’s right, you need CustomJS. const { dailyGantt } = customJS is how the second script is called from within your note. CustomJS will look for a dailyGantt class inside the files in the CustomJS scripts directory.

You could do without customJS and just put the second script into your note, with a little adaptation. I did this because I didn’t like to have that many lines of code in my daily note when I edit it in source mode.

Hey @Brains4Us,

I’m just a Sunday tinkerer and I really don’t know why it doesn’t work on your setup. Maybe add some log statements along the code to see what’s happening exactly? Can you read any error messages in the console?

Also it seems (from the section title “Choses à faire” that you’re using the old version of the script, did you check the one in my second-to-last message?

@josephtribulat Thanks for your “Sunday tinkering” :slight_smile:, the intended functionality looks like something I was searching for. My tasks are formatted according to the “Tasks” community plugin defaults, e.g.:

* [ ] please do something ➕ 2024-01-25 🛫 2024-01-25 📅 2024-01-29

Maybe the problem is that I don’t include the keyword “#task” in my tasks, so I would have to look for the brackets instead. What would I have to change for this to work?

One thing I did already change in the daily note template, is to adapt the {{date:…} placeholders to the format expected by the Templater plugin, but I don’t think that this is breaking the code:

let today = "<% tp.date.now("YYYY-MM-DD") %>";

In this case I would try deleting this line in the for loop that’s in the drawGantt function:

t.text.includes("#task") &&

I tried this, but unfortunately, it doesn’t work. I also added some dummy tasks with the #task tag, which also weren’t found. Might it have to do with the query dv.current().showDone;? This property is nowhere to be found in the metadata (did you add it in your system?). There is a commented line in the .js file, which looks like it’s supposed to add a toggle for showing the finished tasks to the Gantt diagram (uncommenting this doesn’t work either, however).

Edit: Getting closer, commenting some of the other conditions in the task filter had some visible effects. Will update as soon as I know what the problem was.

Okay, I found it, and the result is now absolutely fabulous:

  1. Had to remove both t.text.includes("#task") && and tagNames.some(tag => t.text.includes(tag)) &&
  2. Had to make sure I didn’t have tasks that have no due date (will try and make this more flexible later, but for now this isn’t imposing too many constraints for me)

One thing I will change in the near future is to sort the tasks by due date (probably just have to add a .sort(...something)somewhere. Thank you again, @josephtribulat, for this fantastic tool!