diff --git a/app/components/CircularProgressBar.js b/app/components/CircularProgressBar.js
new file mode 100644
index 000000000..b68bd4baa
--- /dev/null
+++ b/app/components/CircularProgressBar.js
@@ -0,0 +1,74 @@
+// @flow
+import React from "react";
+import { useTheme } from "styled-components";
+
+const cleanPercentage = (percentage) => {
+ const tooLow = !Number.isFinite(+percentage) || percentage < 0;
+ const tooHigh = percentage > 100;
+ return tooLow ? 0 : tooHigh ? 100 : +percentage;
+};
+
+const Circle = ({
+ color,
+ percentage,
+ offset,
+}: {
+ color: string,
+ percentage?: number,
+ offset: number,
+}) => {
+ const radius = offset * 0.7;
+ const circumference = 2 * Math.PI * radius;
+ let strokePercentage;
+ if (percentage) {
+ // because the circle is so small, anything greater than 85% appears like 100%
+ percentage = percentage > 85 && percentage < 100 ? 85 : percentage;
+ strokePercentage = percentage
+ ? ((100 - percentage) * circumference) / 100
+ : 0;
+ }
+
+ return (
+
+ );
+};
+
+const CircularProgressBar = ({
+ percentage,
+ size = 16,
+}: {
+ percentage: number,
+ size?: number,
+}) => {
+ const theme = useTheme();
+ percentage = cleanPercentage(percentage);
+ const offset = Math.floor(size / 2);
+
+ return (
+
+ );
+};
+
+export default CircularProgressBar;
diff --git a/app/components/DocumentMeta.js b/app/components/DocumentMeta.js
index b1b79964d..fd65f5de2 100644
--- a/app/components/DocumentMeta.js
+++ b/app/components/DocumentMeta.js
@@ -6,6 +6,7 @@ import { Link } from "react-router-dom";
import styled from "styled-components";
import Document from "models/Document";
import DocumentBreadcrumb from "components/DocumentBreadcrumb";
+import DocumentTasks from "components/DocumentTasks";
import Flex from "components/Flex";
import Time from "components/Time";
import useCurrentUser from "hooks/useCurrentUser";
@@ -64,6 +65,8 @@ function DocumentMeta({
deletedAt,
isDraft,
lastViewedAt,
+ isTasks,
+ isTemplate,
} = document;
// Prevent meta information from displaying if updatedBy is not available.
@@ -114,6 +117,11 @@ function DocumentMeta({
);
}
+ const nestedDocumentsCount = collection
+ ? collection.getDocumentChildren(document.id).length
+ : 0;
+ const canShowProgressBar = isTasks && !isTemplate;
+
const timeSinceNow = () => {
if (isDraft || !showLastViewed) {
return null;
@@ -133,10 +141,6 @@ function DocumentMeta({
);
};
- const nestedDocumentsCount = collection
- ? collection.getDocumentChildren(document.id).length
- : 0;
-
return (
{lastUpdatedByCurrentUser ? t("You") : updatedBy.name}
@@ -156,6 +160,12 @@ function DocumentMeta({
)}
{timeSinceNow()}
+ {canShowProgressBar && (
+ <>
+ •
+
+ >
+ )}
{children}
);
diff --git a/app/components/DocumentTasks.js b/app/components/DocumentTasks.js
new file mode 100644
index 000000000..c512862dd
--- /dev/null
+++ b/app/components/DocumentTasks.js
@@ -0,0 +1,33 @@
+// @flow
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import CircularProgressBar from "components/CircularProgressBar";
+import Document from "../models/Document";
+
+type Props = {|
+ document: Document,
+|};
+
+function DocumentTasks({ document }: Props) {
+ const { tasks, tasksPercentage } = document;
+ const { t } = useTranslation();
+ const { completed, total } = tasks;
+ const message =
+ completed === 0
+ ? t(`{{ total }} tasks`, { total })
+ : completed === total
+ ? t(`{{ completed }} tasks done`, { completed })
+ : t(`{{ completed }} of {{ total }} tasks`, {
+ total,
+ completed,
+ });
+
+ return (
+ <>
+
+ {message}
+ >
+ );
+}
+
+export default DocumentTasks;
diff --git a/app/models/Document.js b/app/models/Document.js
index 26c384995..93286dca4 100644
--- a/app/models/Document.js
+++ b/app/models/Document.js
@@ -1,6 +1,7 @@
// @flow
import { addDays, differenceInDays } from "date-fns";
import invariant from "invariant";
+import { floor } from "lodash";
import { action, computed, observable, set } from "mobx";
import parseTitle from "shared/utils/parseTitle";
import unescape from "shared/utils/unescape";
@@ -43,6 +44,7 @@ export default class Document extends BaseModel {
deletedAt: ?string;
url: string;
urlId: string;
+ tasks: { completed: number, total: number };
revision: number;
constructor(fields: Object, store: DocumentsStore) {
@@ -149,6 +151,20 @@ export default class Document extends BaseModel {
get isFromTemplate(): boolean {
return !!this.templateId;
}
+
+ @computed
+ get isTasks(): boolean {
+ return !!this.tasks.total;
+ }
+
+ @computed
+ get tasksPercentage(): number {
+ if (!this.isTasks) {
+ return 0;
+ }
+ return floor((this.tasks.completed / this.tasks.total) * 100);
+ }
+
@action
share = async () => {
return this.store.rootStore.shares.create({ documentId: this.id });
diff --git a/app/scenes/Document/components/Document.js b/app/scenes/Document/components/Document.js
index 0c6499e76..d825da7ac 100644
--- a/app/scenes/Document/components/Document.js
+++ b/app/scenes/Document/components/Document.js
@@ -10,6 +10,7 @@ import { Prompt, Route, withRouter } from "react-router-dom";
import type { RouterHistory, Match } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
+import getTasks from "shared/utils/getTasks";
import AuthStore from "stores/AuthStore";
import ToastsStore from "stores/ToastsStore";
import UiStore from "stores/UiStore";
@@ -223,6 +224,8 @@ class DocumentScene extends React.Component {
this.isSaving = true;
this.isPublishing = !!options.publish;
+ document.tasks = getTasks(document.text);
+
try {
const savedDocument = await document.save({
...options,
diff --git a/server/models/Document.js b/server/models/Document.js
index 472aab664..78e5cff57 100644
--- a/server/models/Document.js
+++ b/server/models/Document.js
@@ -6,6 +6,7 @@ import Sequelize, { Transaction } from "sequelize";
import MarkdownSerializer from "slate-md-serializer";
import isUUID from "validator/lib/isUUID";
import { MAX_TITLE_LENGTH } from "../../shared/constants";
+import getTasks from "../../shared/utils/getTasks";
import parseTitle from "../../shared/utils/parseTitle";
import { SLUG_URL_REGEX } from "../../shared/utils/routeHelpers";
import unescape from "../../shared/utils/unescape";
@@ -106,6 +107,9 @@ const Document = sequelize.define(
const slugifiedTitle = slugify(this.title);
return `/doc/${slugifiedTitle}-${this.urlId}`;
},
+ tasks: function () {
+ return getTasks(this.text || "");
+ },
},
}
);
diff --git a/server/models/Document.test.js b/server/models/Document.test.js
index 6f24a47b1..16f6e126b 100644
--- a/server/models/Document.test.js
+++ b/server/models/Document.test.js
@@ -430,3 +430,79 @@ describe("#findByPk", () => {
expect(response.id).toBe(document.id);
});
});
+
+describe("tasks", () => {
+ test("should consider all the possible checkTtems", async () => {
+ const document = await buildDocument({
+ text: `- [x] test
+ - [X] test
+ - [ ] test
+ - [-] test
+ - [_] test`,
+ });
+
+ const tasks = document.tasks;
+
+ expect(tasks.completed).toBe(4);
+ expect(tasks.total).toBe(5);
+ });
+
+ test("should return tasks keys set to 0 if checkItems isn't present", async () => {
+ const document = await buildDocument({
+ text: `text`,
+ });
+
+ const tasks = document.tasks;
+
+ expect(tasks.completed).toBe(0);
+ expect(tasks.total).toBe(0);
+ });
+
+ test("should return tasks keys set to 0 if the text contains broken checkItems", async () => {
+ const document = await buildDocument({
+ text: `- [x ] test
+ - [ x ] test
+ - [ ] test`,
+ });
+
+ const tasks = document.tasks;
+
+ expect(tasks.completed).toBe(0);
+ expect(tasks.total).toBe(0);
+ });
+
+ test("should return tasks", async () => {
+ const document = await buildDocument({
+ text: `- [x] list item
+ - [ ] list item`,
+ });
+
+ const tasks = document.tasks;
+
+ expect(tasks.completed).toBe(1);
+ expect(tasks.total).toBe(2);
+ });
+
+ test("should update tasks on save", async () => {
+ const document = await buildDocument({
+ text: `- [x] list item
+ - [ ] list item`,
+ });
+
+ const tasks = document.tasks;
+
+ expect(tasks.completed).toBe(1);
+ expect(tasks.total).toBe(2);
+
+ document.text = `- [x] list item
+ - [ ] list item
+ - [ ] list item`;
+
+ await document.save();
+
+ const newTasks = document.tasks;
+
+ expect(newTasks.completed).toBe(1);
+ expect(newTasks.total).toBe(3);
+ });
+});
diff --git a/server/presenters/document.js b/server/presenters/document.js
index c98893d72..3a83716c7 100644
--- a/server/presenters/document.js
+++ b/server/presenters/document.js
@@ -44,6 +44,7 @@ export default async function present(document: Document, options: ?Options) {
title: document.title,
text,
emoji: document.emoji,
+ tasks: document.tasks,
createdAt: document.createdAt,
createdBy: undefined,
updatedAt: document.updatedAt,
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index 4e7022d93..33a916485 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -34,6 +34,9 @@
"only you": "only you",
"person": "person",
"people": "people",
+ "{{ total }} tasks": "{{ total }} tasks",
+ "{{ completed }} tasks done": "{{ completed }} tasks done",
+ "{{ completed }} of {{ total }} tasks": "{{ completed }} of {{ total }} tasks",
"Currently editing": "Currently editing",
"Currently viewing": "Currently viewing",
"Viewed {{ timeAgo }} ago": "Viewed {{ timeAgo }} ago",
diff --git a/shared/theme.js b/shared/theme.js
index 9b64c4f9e..496f2e42e 100644
--- a/shared/theme.js
+++ b/shared/theme.js
@@ -179,6 +179,7 @@ export const light = {
noticeInfoBackground: colors.warmGrey,
noticeInfoText: colors.almostBlack,
+ progressBarBackground: colors.slateLight,
scrollbarBackground: colors.smoke,
scrollbarThumb: darken(0.15, colors.smokeDark),
@@ -241,6 +242,7 @@ export const dark = {
noticeInfoBackground: colors.white10,
noticeInfoText: colors.almostWhite,
+ progressBarBackground: colors.slate,
scrollbarBackground: colors.black,
scrollbarThumb: colors.lightBlack,
diff --git a/shared/utils/getTasks.js b/shared/utils/getTasks.js
new file mode 100644
index 000000000..2a9fe6349
--- /dev/null
+++ b/shared/utils/getTasks.js
@@ -0,0 +1,21 @@
+// @flow
+
+const CHECKBOX_REGEX = /\[(X|\s|_|-)\]\s(.*)?/gi;
+
+export default function getTasks(text: string) {
+ const matches = [...text.matchAll(CHECKBOX_REGEX)];
+ let total = matches.length;
+ if (!total) {
+ return {
+ completed: 0,
+ total: 0,
+ };
+ } else {
+ const notCompleted = matches.reduce(
+ (accumulator, match) =>
+ match[1] === " " ? accumulator + 1 : accumulator,
+ 0
+ );
+ return { completed: total - notCompleted, total };
+ }
+}