+
{canEdit ? (
-
-
+
-
+
+
+
+ >
) : (
-
+
)}
@@ -576,7 +580,7 @@ CardModal.propTypes = {
name: PropTypes.string.isRequired,
description: PropTypes.string,
dueDate: PropTypes.instanceOf(Date),
- dueCompleted: PropTypes.bool,
+ isDueDateCompleted: PropTypes.bool,
stopwatch: PropTypes.object, // eslint-disable-line react/forbid-prop-types
isSubscribed: PropTypes.bool.isRequired,
isActivitiesFetching: PropTypes.bool.isRequired,
@@ -631,7 +635,7 @@ CardModal.propTypes = {
CardModal.defaultProps = {
description: undefined,
dueDate: undefined,
- dueCompleted: false,
+ isDueDateCompleted: false,
stopwatch: undefined,
};
diff --git a/client/src/components/CardModal/CardModal.module.scss b/client/src/components/CardModal/CardModal.module.scss
index 7b20e9023..fc03472f3 100644
--- a/client/src/components/CardModal/CardModal.module.scss
+++ b/client/src/components/CardModal/CardModal.module.scss
@@ -58,6 +58,12 @@
max-width: 100%;
}
+ .attachmentDueDate {
+ align-items: center;
+ display: flex;
+ gap: 4px;
+ }
+
.attachments {
display: inline-block;
margin: 0 8px 8px 0;
diff --git a/client/src/components/DueDate/DueDate.jsx b/client/src/components/DueDate/DueDate.jsx
index 8c4d78d33..34f8b3d82 100644
--- a/client/src/components/DueDate/DueDate.jsx
+++ b/client/src/components/DueDate/DueDate.jsx
@@ -1,9 +1,10 @@
import upperFirst from 'lodash/upperFirst';
-import React, { useCallback } from 'react';
+import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
-import { Checkbox } from 'semantic-ui-react';
+import { Icon } from 'semantic-ui-react';
+import { useForceUpdate } from '../../lib/hooks';
import getDateFormat from '../../utils/get-date-format';
@@ -15,6 +16,12 @@ const SIZES = {
MEDIUM: 'medium',
};
+const STATUSES = {
+ DUE_SOON: 'dueSoon',
+ OVERDUE: 'overdue',
+ COMPLETED: 'completed',
+};
+
const LONG_DATE_FORMAT_BY_SIZE = {
tiny: 'longDate',
small: 'longDate',
@@ -27,96 +34,121 @@ const FULL_DATE_FORMAT_BY_SIZE = {
medium: 'fullDateTime',
};
-const getDueClass = (value) => {
- const now = new Date();
- const tomorrow = new Date(now).setDate(now.getDate() + 1);
+const STATUS_ICON_PROPS_BY_STATUS = {
+ [STATUSES.DUE_SOON]: {
+ name: 'hourglass half',
+ color: 'orange',
+ },
+ [STATUSES.OVERDUE]: {
+ name: 'hourglass end',
+ color: 'red',
+ },
+ [STATUSES.COMPLETED]: {
+ name: 'checkmark',
+ color: 'green',
+ },
+};
+
+const getStatus = (dateTime, isCompleted) => {
+ if (isCompleted) {
+ return STATUSES.COMPLETED;
+ }
+
+ const secondsLeft = Math.floor((dateTime.getTime() - new Date().getTime()) / 1000);
+
+ if (secondsLeft <= 0) {
+ return STATUSES.OVERDUE;
+ }
+
+ if (secondsLeft <= 24 * 60 * 60) {
+ return STATUSES.DUE_SOON;
+ }
- if (now > value) return styles.overdue;
- if (tomorrow > value) return styles.soon;
return null;
};
-const DueDate = React.memo(
- ({ value, completed, size, isDisabled, onClick, onUpdateCompletion }) => {
- const [t] = useTranslation();
-
- const dateFormat = getDateFormat(
- value,
- LONG_DATE_FORMAT_BY_SIZE[size],
- FULL_DATE_FORMAT_BY_SIZE[size],
- );
-
- const classes = [
- styles.wrapper,
- styles[`wrapper${upperFirst(size)}`],
- onClick && styles.wrapperHoverable,
- completed ? styles.completed : getDueClass(value),
- ];
-
- const handleToggleChange = useCallback(
- (event) => {
- event.preventDefault();
- event.stopPropagation();
- if (!isDisabled) onUpdateCompletion(!completed);
- },
- [onUpdateCompletion, completed, isDisabled],
- );
-
- return onClick ? (
-
-
-
-
- ) : (
-
- {t(`format:${dateFormat}`, {
- value,
- postProcess: 'formatDate',
- })}
-
- );
- },
-);
+const DueDate = React.memo(({ value, size, isCompleted, isDisabled, withStatusIcon, onClick }) => {
+ const [t] = useTranslation();
+ const forceUpdate = useForceUpdate();
+
+ const statusRef = useRef(null);
+ statusRef.current = getStatus(value, isCompleted);
+
+ const intervalRef = useRef(null);
+
+ const dateFormat = getDateFormat(
+ value,
+ LONG_DATE_FORMAT_BY_SIZE[size],
+ FULL_DATE_FORMAT_BY_SIZE[size],
+ );
+
+ useEffect(() => {
+ if ([null, STATUSES.DUE_SOON].includes(statusRef.current)) {
+ intervalRef.current = setInterval(() => {
+ const status = getStatus(value, isCompleted);
+
+ if (status !== statusRef.current) {
+ forceUpdate();
+ }
+
+ if (status === STATUSES.OVERDUE) {
+ clearInterval(intervalRef.current);
+ }
+ }, 1000);
+ }
+
+ return () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ }
+ };
+ }, [value, isCompleted, forceUpdate]);
+
+ const contentNode = (
+
+ {t(`format:${dateFormat}`, {
+ value,
+ postProcess: 'formatDate',
+ })}
+ {withStatusIcon && statusRef.current && (
+ // eslint-disable-next-line react/jsx-props-no-spreading
+
+ )}
+
+ );
+
+ return onClick ? (
+
+ ) : (
+ contentNode
+ );
+});
DueDate.propTypes = {
value: PropTypes.instanceOf(Date).isRequired,
size: PropTypes.oneOf(Object.values(SIZES)),
+ isCompleted: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool,
- completed: PropTypes.bool,
+ withStatusIcon: PropTypes.bool,
onClick: PropTypes.func,
- onUpdateCompletion: PropTypes.func,
+ onCompletionToggle: PropTypes.func,
};
DueDate.defaultProps = {
size: SIZES.MEDIUM,
isDisabled: false,
- completed: false,
+ withStatusIcon: false,
onClick: undefined,
- onUpdateCompletion: undefined,
+ onCompletionToggle: undefined,
};
export default DueDate;
diff --git a/client/src/components/DueDate/DueDate.module.scss b/client/src/components/DueDate/DueDate.module.scss
index 42dbae476..62ed7cf9e 100644
--- a/client/src/components/DueDate/DueDate.module.scss
+++ b/client/src/components/DueDate/DueDate.module.scss
@@ -8,16 +8,17 @@
padding: 0;
}
+ .statusIcon {
+ line-height: 1;
+ margin: 0 0 0 8px;
+ }
+
.wrapper {
background: #dce0e4;
- border: none;
border-radius: 3px;
color: #6a808b;
display: inline-block;
- outline: none;
- text-align: left;
transition: background 0.3s ease;
- vertical-align: top;
}
.wrapperHoverable:hover {
@@ -25,57 +26,18 @@
color: #17394d;
}
- .wrapperGroup {
- display: flex;
- align-items: stretch;
- justify-content: stretch;
- }
-
- .overdue {
- background: #db2828;
- color: #ffffff;
- &.wrapperHoverable:hover {
- background: #d01919;
- color: #ffffff;
- }
- }
-
- .soon {
- background: #fbbd08;
- color: #ffffff;
- &.wrapperHoverable:hover {
- background: #eaae00;
- color: #ffffff;
- }
- }
-
- .completed {
- background: #21ba45;
- color: #ffffff;
- &.wrapperHoverable:hover {
- background: #16ab39;
- color: #ffffff;
- }
- }
-
/* Sizes */
.wrapperTiny {
font-size: 12px;
line-height: 20px;
padding: 0px 6px;
- &.wrapperCheckbox {
- padding-right: 6px;
- }
}
.wrapperSmall {
font-size: 12px;
line-height: 20px;
padding: 2px 6px;
- &.wrapperCheckbox {
- padding-right: 6px;
- }
}
.wrapperMedium {
@@ -83,17 +45,20 @@
padding: 6px 12px;
}
- .wrapperCheckbox {
- padding-right: 12px;
- border-top-right-radius: 0;
- border-bottom-right-radius: 0;
- cursor: pointer;
+ /* Statuses */
+
+ .wrapperDueSoon {
+ background: #f2711c;
+ color: #fff;
}
- .checkbox {
- display: block;
+
+ .wrapperOverdue {
+ background: #db2828;
+ color: #fff;
}
- .wrapperButton {
- border-top-left-radius: 0;
- border-bottom-left-radius: 0;
+
+ .wrapperCompleted {
+ background: #21ba45;
+ color: #fff;
}
}
diff --git a/client/src/components/Label/Label.jsx b/client/src/components/Label/Label.jsx
index dbd796ef2..28e6db755 100644
--- a/client/src/components/Label/Label.jsx
+++ b/client/src/components/Label/Label.jsx
@@ -17,7 +17,7 @@ const SIZES = {
const Label = React.memo(({ name, color, size, isDisabled, onClick }) => {
const contentNode = (
- {
)}
>
{name || '\u00A0'}
-
+
);
return onClick ? (
diff --git a/client/src/components/Label/Label.module.scss b/client/src/components/Label/Label.module.scss
index 364c2c7e7..7e0c12194 100644
--- a/client/src/components/Label/Label.module.scss
+++ b/client/src/components/Label/Label.module.scss
@@ -11,17 +11,17 @@
.wrapper {
border-radius: 3px;
- box-sizing: border-box;
color: #fff;
+ display: inline-block;
font-weight: 400;
- outline: none;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 1px 1px 0 rgba(0, 0, 0, 0.2);
+ vertical-align: top;
white-space: nowrap;
}
- .wrapperNameless{
+ .wrapperNameless {
width: 40px;
}
diff --git a/client/src/components/Stopwatch/Stopwatch.module.scss b/client/src/components/Stopwatch/Stopwatch.module.scss
index ce7653150..c34ccea6a 100644
--- a/client/src/components/Stopwatch/Stopwatch.module.scss
+++ b/client/src/components/Stopwatch/Stopwatch.module.scss
@@ -10,13 +10,10 @@
.wrapper {
background: #dce0e4;
- border: none;
border-radius: 3px;
color: #6a808b;
display: inline-block;
font-variant-numeric: tabular-nums;
- outline: none;
- text-align: left;
transition: background 0.3s ease;
vertical-align: top;
}
diff --git a/client/src/components/User/User.module.scss b/client/src/components/User/User.module.scss
index 9d8cf9ea1..8c5eddc52 100644
--- a/client/src/components/User/User.module.scss
+++ b/client/src/components/User/User.module.scss
@@ -14,12 +14,10 @@
}
.wrapper {
- border: none;
border-radius: 50%;
color: #fff;
display: inline-block;
line-height: 1;
- outline: none;
text-align: center;
vertical-align: top;
}
diff --git a/client/src/containers/CardContainer.js b/client/src/containers/CardContainer.js
index 7c58e549d..16e663bcb 100755
--- a/client/src/containers/CardContainer.js
+++ b/client/src/containers/CardContainer.js
@@ -20,7 +20,7 @@ const makeMapStateToProps = () => {
const allLabels = selectors.selectLabelsForCurrentBoard(state);
const currentUserMembership = selectors.selectCurrentUserMembershipForCurrentBoard(state);
- const { name, dueDate, dueCompleted, stopwatch, coverUrl, boardId, listId, isPersisted } =
+ const { name, dueDate, isDueDateCompleted, stopwatch, coverUrl, boardId, listId, isPersisted } =
selectCardById(state, id);
const users = selectUsersByCardId(state, id);
@@ -36,7 +36,7 @@ const makeMapStateToProps = () => {
index,
name,
dueDate,
- dueCompleted,
+ isDueDateCompleted,
stopwatch,
coverUrl,
boardId,
diff --git a/client/src/containers/CardModalContainer.js b/client/src/containers/CardModalContainer.js
index 05ed8a4cb..6a343699f 100755
--- a/client/src/containers/CardModalContainer.js
+++ b/client/src/containers/CardModalContainer.js
@@ -21,7 +21,7 @@ const mapStateToProps = (state) => {
name,
description,
dueDate,
- dueCompleted,
+ isDueDateCompleted,
stopwatch,
isSubscribed,
isActivitiesFetching,
@@ -50,7 +50,7 @@ const mapStateToProps = (state) => {
name,
description,
dueDate,
- dueCompleted,
+ isDueDateCompleted,
stopwatch,
isSubscribed,
isActivitiesFetching,
diff --git a/client/src/models/Card.js b/client/src/models/Card.js
index f811b3dc7..ae60c4a43 100755
--- a/client/src/models/Card.js
+++ b/client/src/models/Card.js
@@ -20,9 +20,7 @@ export default class extends BaseModel {
relatedName: 'ownCards',
}),
dueDate: attr(),
- dueCompleted: attr({
- getDefault: () => false,
- }),
+ isDueDateCompleted: attr(),
stopwatch: attr(),
isSubscribed: attr({
getDefault: () => false,
@@ -211,7 +209,16 @@ export default class extends BaseModel {
if (payload.data.boardId && payload.data.boardId !== cardModel.boardId) {
cardModel.deleteWithRelated();
} else {
- cardModel.update(payload.data);
+ cardModel.update({
+ ...payload.data,
+ ...(payload.data.dueDate === null && {
+ isDueDateCompleted: null,
+ }),
+ ...(payload.data.dueDate &&
+ !cardModel.dueDate && {
+ isDueDateCompleted: false,
+ }),
+ });
}
break;
@@ -251,7 +258,7 @@ export default class extends BaseModel {
'name',
'description',
'dueDate',
- 'dueCompleted',
+ 'isDueDateCompleted',
'stopwatch',
]),
...payload.card,
diff --git a/server/api/controllers/cards/create.js b/server/api/controllers/cards/create.js
index 28285cb9e..a89cdbbc6 100755
--- a/server/api/controllers/cards/create.js
+++ b/server/api/controllers/cards/create.js
@@ -56,9 +56,11 @@ module.exports = {
dueDate: {
type: 'string',
custom: dueDateValidator,
+ allowNull: true,
},
- dueCompleted: {
+ isDueDateCompleted: {
type: 'boolean',
+ allowNull: true,
},
stopwatch: {
type: 'json',
@@ -103,7 +105,7 @@ module.exports = {
'name',
'description',
'dueDate',
- 'dueCompleted',
+ 'isDueDateCompleted',
'stopwatch',
]);
diff --git a/server/api/controllers/cards/update.js b/server/api/controllers/cards/update.js
index 763057a6f..df1c8c66b 100755
--- a/server/api/controllers/cards/update.js
+++ b/server/api/controllers/cards/update.js
@@ -80,8 +80,9 @@ module.exports = {
custom: dueDateValidator,
allowNull: true,
},
- dueCompleted: {
+ isDueDateCompleted: {
type: 'boolean',
+ allowNull: true,
},
stopwatch: {
type: 'json',
@@ -176,7 +177,7 @@ module.exports = {
'name',
'description',
'dueDate',
- 'dueCompleted',
+ 'isDueDateCompleted',
'stopwatch',
'isSubscribed',
]);
diff --git a/server/api/helpers/cards/create-one.js b/server/api/helpers/cards/create-one.js
index 36cdb81aa..7f1e2652a 100644
--- a/server/api/helpers/cards/create-one.js
+++ b/server/api/helpers/cards/create-one.js
@@ -49,6 +49,14 @@ module.exports = {
throw 'positionMustBeInValues';
}
+ if (values.dueDate) {
+ if (_.isNil(values.isDueDateCompleted)) {
+ values.isDueDateCompleted = false;
+ }
+ } else {
+ delete values.isDueDateCompleted;
+ }
+
const cards = await sails.helpers.lists.getCards(values.list.id);
const { position, repositions } = sails.helpers.utils.insertToPositionables(
diff --git a/server/api/helpers/cards/duplicate-one.js b/server/api/helpers/cards/duplicate-one.js
index 3ea304439..55fd6c984 100644
--- a/server/api/helpers/cards/duplicate-one.js
+++ b/server/api/helpers/cards/duplicate-one.js
@@ -77,7 +77,7 @@ module.exports = {
'name',
'description',
'dueDate',
- 'dueCompleted',
+ 'isDueDateCompleted',
'stopwatch',
]),
...values,
diff --git a/server/api/helpers/cards/update-one.js b/server/api/helpers/cards/update-one.js
index cd978b7e4..9be5c5764 100644
--- a/server/api/helpers/cards/update-one.js
+++ b/server/api/helpers/cards/update-one.js
@@ -135,6 +135,20 @@ module.exports = {
});
}
+ const dueDate = _.isUndefined(values.dueDate) ? inputs.record.dueDate : values.dueDate;
+
+ if (dueDate) {
+ const isDueDateCompleted = _.isUndefined(values.isDueDateCompleted)
+ ? inputs.record.isDueDateCompleted
+ : values.isDueDateCompleted;
+
+ if (_.isNull(isDueDateCompleted)) {
+ values.isDueDateCompleted = false;
+ }
+ } else {
+ values.isDueDateCompleted = null;
+ }
+
let card;
if (_.isEmpty(values)) {
card = inputs.record;
diff --git a/server/api/models/Card.js b/server/api/models/Card.js
index fe6738d67..c33763009 100755
--- a/server/api/models/Card.js
+++ b/server/api/models/Card.js
@@ -28,10 +28,10 @@ module.exports = {
type: 'ref',
columnName: 'due_date',
},
- dueCompleted: {
+ isDueDateCompleted: {
type: 'boolean',
- defaultsTo: false,
- columnName: 'due_completed',
+ allowNull: true,
+ columnName: 'is_due_date_completed',
},
stopwatch: {
type: 'json',
diff --git a/server/db/migrations/20240812065305_add_due_completion.js.js b/server/db/migrations/20240812065305_add_due_completion.js.js
deleted file mode 100644
index 331782aec..000000000
--- a/server/db/migrations/20240812065305_add_due_completion.js.js
+++ /dev/null
@@ -1,12 +0,0 @@
-module.exports.up = async (knex) =>
- knex.schema.table('card', (table) => {
- /* Columns */
-
- table.boolean('due_completed').notNullable().defaultTo(false);
- });
-
-module.exports.down = async (knex) => {
- await knex.schema.table('card', (table) => {
- table.dropColumn('due_completed');
- });
-};
diff --git a/server/db/migrations/20240812065305_make_due_date_toggleable.js b/server/db/migrations/20240812065305_make_due_date_toggleable.js
new file mode 100644
index 000000000..bc55c0c25
--- /dev/null
+++ b/server/db/migrations/20240812065305_make_due_date_toggleable.js
@@ -0,0 +1,18 @@
+module.exports.up = async (knex) => {
+ await knex.schema.table('card', (table) => {
+ /* Columns */
+
+ table.boolean('is_due_date_completed');
+ });
+
+ return knex('card')
+ .update({
+ isDueDateCompleted: false,
+ })
+ .whereNotNull('due_date');
+};
+
+module.exports.down = (knex) =>
+ knex.schema.table('card', (table) => {
+ table.dropColumn('is_due_date_completed');
+ });