From c3fceeae7664490a80f3c5c7881bade658eca9cf Mon Sep 17 00:00:00 2001 From: Joe Carstairs <118172583+jcarstairs-scottlogic@users.noreply.github.com> Date: Sun, 4 Aug 2024 18:08:56 +0100 Subject: [PATCH] WIP: When friendly dates are enabled, group elements use friendly dates (#717) * Adds missing `import` statement * Gentle refactoring in `updateAttributes()` * Removes redundant assignment of `attributes` to itself In more detail, it assigns to `attributes` a new Object whose keys are all the values of `sorting[i].value`. These values should be all the possible attributes, so in effect, the keys don't change. The value assigned to each of these keys is `attributes[key]`, in other words, the value which that key **already has** in `attributes`. So, `attributes` should not change. So the line is redundant. As it prevents `attributes` from being made `const`, I've removed it. * Const-ifies `attributes` * Adds AttributeKey and DateAttributeKey types Also gently refactors updateAttributes() to take advantage of the stronger typing * Adds FriendlyDateGroup type * Refactors friendlyDate() Separates out the step which find the right translation key for the friendly date from the stage which translates that key. In particular, adds a new function, `friendlyDateTranslationKey()`, which just finds the right translation key. `friendlyDate()` now just calls the first function and applies the translation function from i18next. * Defines `friendlyDateGroup()` * Refactors DateAttribute and DateAttributeKey types * Defines `getDateAttributes()` This will be useful for grouping by friendly date, since we need to identify whether the attribute key associated with a given group is a date attribute key. * Groups date attributes by friendly date translation key when friendly dates are enabled. As before, when the user hasn't got friendly dates enabled, todo objects will be grouped by the raw value of date attributes, hopefully an ISO string like `2024-07-10`. But when the user enables friendly dates, todos will instead be grouped by the friendly date translation key, such as `drawer.attributes.today`. * Group titles in UI use friendly dates when enabled --------- Co-authored-by: Joe Carstairs Co-authored-by: ransome <11188741+ransome1@users.noreply.github.com> --- src/main/modules/Attributes.tsx | 48 +++++++----- .../modules/DataRequest/CreateTodoObjects.tsx | 14 ++-- src/main/modules/DataRequest/SortAndGroup.tsx | 19 ++++- src/main/modules/Date.tsx | 78 +++++++++++++++---- src/renderer/Grid/Group.tsx | 10 ++- src/renderer/Shared.tsx | 78 +++++++++---------- src/types.tsx | 48 +++++++++--- 7 files changed, 197 insertions(+), 98 deletions(-) diff --git a/src/main/modules/Attributes.tsx b/src/main/modules/Attributes.tsx index 4464b87b..e5ab34a2 100644 --- a/src/main/modules/Attributes.tsx +++ b/src/main/modules/Attributes.tsx @@ -1,15 +1,24 @@ -let attributes: Attributes = { +const attributes: Attributes = { priority: {}, projects: {}, contexts: {}, - due: {}, - t: {}, + due: {} as DateAttribute, + t: {} as DateAttribute, rec: {}, pm: {}, - created: {}, - completed: {}, + created: {} as DateAttribute, + completed: {} as DateAttribute, }; +function getDateAttributes(): DateAttributes { + return { + due: attributes.due, + t: attributes.t, + created: attributes.created, + completed: attributes.completed, + }; +} + function incrementCount(countObject: any, key: any | null, notify: boolean): void { if(key) { let previousCount: number = parseInt(countObject[key]?.count) || 0; @@ -21,34 +30,35 @@ function incrementCount(countObject: any, key: any | null, notify: boolean): voi } function updateAttributes(todoObjects: TodoObject[], sorting: Sorting[], reset: boolean) { - - Object.keys(attributes).forEach((key) => { - - Object.keys(attributes[key]).forEach((attributeKey) => { + + const attributeKeys = Object.keys(attributes) as AttributeKey[]; + + for (const key of attributeKeys) { + + for (const attributeKey in attributes[key]) { (reset) ? attributes[key] = {} : attributes[key][attributeKey].count = 0 - }); + }; - todoObjects.forEach((todoObject: TodoObject) => { + for (const todoObject of todoObjects) { const value = todoObject[key as keyof TodoObject]; const notify: boolean = (key === 'due') ? !!todoObject?.notify : false; - + if(Array.isArray(value)) { - value.forEach((element) => { + for (const element of value) { if(element !== null) { const attributeKey = element as keyof Attribute; - + incrementCount(attributes[key], attributeKey, notify); } - }); + } } else { if(value !== null) { incrementCount(attributes[key], value, notify); } } - }); + } attributes[key] = Object.fromEntries(Object.entries(attributes[key]).sort(([a], [b]) => a.localeCompare(b))); - }); - attributes = Object.fromEntries(sorting.map((item) => [item.value, attributes[item.value]])); + } } -export { attributes, updateAttributes }; +export { attributes, getDateAttributes, updateAttributes }; diff --git a/src/main/modules/DataRequest/CreateTodoObjects.tsx b/src/main/modules/DataRequest/CreateTodoObjects.tsx index 19546210..1f1d05fe 100644 --- a/src/main/modules/DataRequest/CreateTodoObjects.tsx +++ b/src/main/modules/DataRequest/CreateTodoObjects.tsx @@ -31,12 +31,12 @@ function createTodoObject(lineNumber: number, string: string, attributeType?: st content = JsTodoTxtObject.toString().replaceAll(' [LB] ', String.fromCharCode(16)); const body = JsTodoTxtObject.body().replaceAll(' [LB] ', ' '); - const speakingDates: DateAttributes = extractSpeakingDates(body); - const due = speakingDates['due:']?.date || null; - const dueString = speakingDates['due:']?.string || null; - const notify = speakingDates['due:']?.notify || false; - const t = speakingDates['t:']?.date || null; - const tString = speakingDates['t:']?.string || null; + const speakingDates = extractSpeakingDates(body); + const due = speakingDates.due?.date || null; + const dueString = speakingDates.due?.string || null; + const notify = speakingDates.due?.notify || false; + const t = speakingDates.t?.date || null; + const tString = speakingDates.t?.string || null; const hidden = extensions.some(extension => extension.key === 'h' && extension.value === '1'); const pm: string | number | null = extensions.find(extension => extension.key === 'pm')?.value || null; const rec = extensions.find(extension => extension.key === 'rec')?.value || null; @@ -97,4 +97,4 @@ function createTodoObjects(fileContent: string | null): TodoObject[] | [] { return todoObjects; } -export { createTodoObjects, createTodoObject, linesInFile }; \ No newline at end of file +export { createTodoObjects, createTodoObject, linesInFile }; diff --git a/src/main/modules/DataRequest/SortAndGroup.tsx b/src/main/modules/DataRequest/SortAndGroup.tsx index c3b717d5..59b72668 100644 --- a/src/main/modules/DataRequest/SortAndGroup.tsx +++ b/src/main/modules/DataRequest/SortAndGroup.tsx @@ -1,4 +1,6 @@ import { config } from '../../config'; +import { getDateAttributes } from '../Attributes'; +import { friendlyDateGroup } from '../Date'; function sortAndGroupTodoObjects(todoObjects: TodoObject[], sorting: Sorting[]): TodoGroup { const fileSorting: boolean = config.get('fileSorting'); @@ -31,13 +33,24 @@ function sortAndGroupTodoObjects(todoObjects: TodoObject[], sorting: Sorting[]): return 0; } + function getGroupKey(todoObject: TodoObject, attributeKey: string) { + const useFriendlyDates = config.get('useHumanFriendlyDates'); + const isDateAttribute = Object.keys(getDateAttributes()).includes(attributeKey); + + if (useFriendlyDates && isDateAttribute) { + return friendlyDateGroup(todoObject[attributeKey]); + } + + return todoObject[attributeKey]; + } + function groupTodoObjectsByKey(todoObjects: TodoObject[], attributeKey: string) { const grouped: TodoGroup = {}; for (const todoObject of todoObjects) { - const groupKey = todoObject[attributeKey] || null; + const groupKey = getGroupKey(todoObject, attributeKey); if (!grouped[groupKey]) { grouped[groupKey] = { - title: groupKey, + title: todoObject[attributeKey], todoObjects: [], visible: false }; @@ -70,4 +83,4 @@ function sortAndGroupTodoObjects(todoObjects: TodoObject[], sorting: Sorting[]): return sortedAndGroupedTodoObjects; } -export { sortAndGroupTodoObjects }; \ No newline at end of file +export { sortAndGroupTodoObjects }; diff --git a/src/main/modules/Date.tsx b/src/main/modules/Date.tsx index fd51ca31..da87b4de 100644 --- a/src/main/modules/Date.tsx +++ b/src/main/modules/Date.tsx @@ -1,5 +1,5 @@ import Sugar from 'sugar'; -import dayjs from 'dayjs'; +import dayjs, { Dayjs } from 'dayjs'; import { config } from '../config'; function mustNotify(date: Date): boolean { @@ -9,9 +9,9 @@ function mustNotify(date: Date): boolean { } function replaceSpeakingDatesWithAbsoluteDates(string: string): string { - const speakingDates: DateAttributes = extractSpeakingDates(string); - const due: DateAttribute = speakingDates['due:']; - const t: DateAttribute = speakingDates['t:']; + const speakingDates = extractSpeakingDates(string); + const due: DateAttribute = speakingDates.due; + const t: DateAttribute = speakingDates.t; if(due.date) { string = string.replace(due.string!, due.date); } @@ -50,22 +50,22 @@ function processDateWithSugar(string: string, type: string): DateAttribute | nul return lastMatch; } -function extractSpeakingDates(body: string): DateAttributes { - const expressions = [ - { pattern: /due:(?!(\d{4}-\d{2}-\d{2}))(.*?)(?=t:|$)/g, key: 'due:', type: 'relative' }, - { pattern: /due:(\d{4}-\d{2}-\d{2})/g, key: 'due:', type: 'absolute' }, - { pattern: /t:(?!(\d{4}-\d{2}-\d{2}))(.*?)(?=due:|$)/g, key: 't:', type: 'relative' }, - { pattern: /t:(\d{4}-\d{2}-\d{2})/g, key: 't:', type: 'absolute' }, +function extractSpeakingDates(body: string): Pick { + const expressions: { pattern: RegExp, key: 'due'|'t', type: string }[] = [ + { pattern: /due:(?!(\d{4}-\d{2}-\d{2}))(.*?)(?=t:|$)/g, key: 'due', type: 'relative' }, + { pattern: /due:(\d{4}-\d{2}-\d{2})/g, key: 'due', type: 'absolute' }, + { pattern: /t:(?!(\d{4}-\d{2}-\d{2}))(.*?)(?=due:|$)/g, key: 't', type: 'relative' }, + { pattern: /t:(\d{4}-\d{2}-\d{2})/g, key: 't', type: 'absolute' }, ]; - const speakingDates: DateAttributes = { - 'due:': { + const speakingDates: Pick = { + 'due': { date: null, string: null, type: null, notify: false, }, - 't:': { + 't': { date: null, string: null, type: null, @@ -86,4 +86,54 @@ function extractSpeakingDates(body: string): DateAttributes { return speakingDates; } -export { extractSpeakingDates, replaceSpeakingDatesWithAbsoluteDates }; +function friendlyDateGroup(date: Dayjs): FriendlyDateGroup | null { + const today = dayjs(); + + if (!date || !date.isValid()) { + return null; + } + + if (date.isBefore(today.subtract(1, 'week'))) { + return 'before-last-week'; + } + + if (date.isBefore(today.subtract(1, 'day'))) { + return 'last-week'; + } + + if (date.isBefore(today)) { + return 'yesterday'; + } + + if (date.isSame(today)) { + return 'today'; + } + + if (date.isSame(today.add(1, 'day'))) { + return 'tomorrow'; + } + + if (date.isBefore(today.add(1, 'week').add(1, 'day'))) { + return 'this-week'; + } + + if (date.isBefore(today.add(2, 'week').add(1, 'day'))) { + return 'next-week'; + } + + if (date.isBefore(today.add(1, 'month').add(1, 'day'))) { + return 'this-month'; + } + + if (date.isBefore(today.add(2, 'month').add(1, 'day'))) { + return 'next-month'; + } + + return 'after-next-month'; +} + +export { + extractSpeakingDates, + friendlyDateGroup, + replaceSpeakingDatesWithAbsoluteDates +}; diff --git a/src/renderer/Grid/Group.tsx b/src/renderer/Grid/Group.tsx index 2d5df24d..9e9efdb9 100644 --- a/src/renderer/Grid/Group.tsx +++ b/src/renderer/Grid/Group.tsx @@ -5,8 +5,9 @@ import Divider from '@mui/material/Divider'; import dayjs from 'dayjs'; import updateLocale from 'dayjs/plugin/updateLocale'; import { friendlyDate } from 'renderer/Shared'; -import { i18n } from 'renderer/Settings/LanguageSelector'; -import { WithTranslation, withTranslation } from 'react-i18next'; +import type { i18n } from 'renderer/Settings/LanguageSelector'; +import { type WithTranslation, withTranslation } from 'react-i18next'; +import { getDateAttributes } from 'main/modules/Attributes'; dayjs.extend(updateLocale); interface FormatGroupElementProps { @@ -18,12 +19,13 @@ interface FormatGroupElementProps { function formatGroupElement({ groupElement, settings, t, todotxtAttribute }: FormatGroupElementProps) { // If group element is a date, then format according to user preferences + const dateAttributeKeys = Object.keys(getDateAttributes()); if ( - ['due', 't'].includes(todotxtAttribute) + dateAttributeKeys.includes(todotxtAttribute) && dayjs(groupElement).isValid() && settings.useHumanFriendlyDates ) { - return friendlyDate(groupElement, todotxtAttribute, settings, t).pop(); + return friendlyDate(groupElement, todotxtAttribute, t).pop(); } // No transformation required: display as-is diff --git a/src/renderer/Shared.tsx b/src/renderer/Shared.tsx index ccf2c327..c72b2e6b 100644 --- a/src/renderer/Shared.tsx +++ b/src/renderer/Shared.tsx @@ -5,6 +5,7 @@ import calendar from 'dayjs/plugin/calendar'; import weekday from 'dayjs/plugin/weekday'; import updateLocale from 'dayjs/plugin/updateLocale'; import { i18n } from './Settings/LanguageSelector'; +import { friendlyDateGroup } from 'main/modules/Date'; dayjs.extend(relativeTime); dayjs.extend(duration); dayjs.extend(calendar); @@ -68,48 +69,47 @@ export const friendlyDate = (value: string, attributeKey: string, settings: Sett weekStart: settings.weekStart, }); - const today = dayjs(); const date = dayjs(value); - const results = []; - - if (date.isBefore(today, 'day')) { - results.push((attributeKey === 'due') ? t('drawer.attributes.overdue') : t('drawer.attributes.elapsed')); - } - - if (date.isAfter(today.subtract(1, 'week').startOf('week').subtract(1, 'day')) && date.isBefore(today.subtract(1, 'week').endOf('week'))) { - results.push(t('drawer.attributes.lastWeek')); - } - - if (date.isBefore(today.endOf('month')) && date.isAfter(today.subtract(1, 'day'), 'day')) { - results.push(t('drawer.attributes.thisMonth')); - } - - if (date.isSame(today, 'week')) { - results.push(t('drawer.attributes.thisWeek')); - } - - if (date.isSame(today.subtract(1, 'day'), 'day')) { - results.push(t('drawer.attributes.yesterday')); - } + const group = friendlyDateGroup(date); - if (date.isSame(today, 'day')) { - results.push(t('drawer.attributes.today')); - } - - if (date.isSame(today.add(1, 'day'), 'day')) { - results.push(t('drawer.attributes.tomorrow')); - } - - if (date.isSame(today.add(1, 'week'), 'week')) { - results.push(t('drawer.attributes.nextWeek')); - } - - if (date.month() === today.add(1, 'month').month()) { - results.push(t('drawer.attributes.nextMonth')); - } + const results = []; - if (date.isAfter(today.add(1, 'month').endOf('month'))) { - results.push(dayjs(date).format('YYYY-MM-DD')); + switch (group) { + case null: + // This should never happen, as `date` will always be a valid date + break; + case 'before-last-week': + results.push((attributeKey === 'due') ? t('drawer.attributes.overdue') : t('drawer.attributes.elapsed')); + break; + case 'last-week': + results.push((attributeKey === 'due') ? t('drawer.attributes.overdue') : t('drawer.attributes.elapsed')); + results.push(t('drawer.attributes.lastWeek')); + break; + case 'yesterday': + results.push((attributeKey === 'due') ? t('drawer.attributes.overdue') : t('drawer.attributes.elapsed')); + results.push(t('drawer.attributes.yesterday')); + break; + case 'today': + results.push(t('drawer.attributes.today')); + break; + case 'tomorrow': + results.push(t('drawer.attributes.tomorrow')); + break; + case 'this-week': + results.push(t('drawer.attributes.thisWeek')); + break; + case 'next-week': + results.push(t('drawer.attributes.nextWeek')); + break; + case 'this-month': + results.push(t('drawer.attributes.thisMonth')); + break; + case 'next-month': + results.push(t('drawer.attributes.nextMonth')); + break; + case 'after-next-month': + results.push(dayjs(date).format('YYYY-MM-DD')); + break; } return results; diff --git a/src/types.tsx b/src/types.tsx index 852da953..a1c62d1b 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -168,24 +168,36 @@ declare global { exclude: boolean; } + type AttributeKey = + 'priority' + | 'projects' + | 'contexts' + | 'due' + | 't' + | 'rec' + | 'pm' + | 'created' + | 'completed'; + + type DateAttributeKey = AttributeKey & ( + 'due' + | 't' + | 'created' + | 'completed' + ); + interface Attribute { [key: string]: number | boolean; } - interface Attributes { - [key: string]: { - [key: string]: Attribute; - } + type Attributes = { + [attributeKey in AttributeKey]: + attributeKey extends DateAttributeKey + ? DateAttribute + : { [key: string]: Attribute; }; } - type DateAttributes = { - [key: string]: { - date: string | null; - string: string | null; - type: string | null; - notify: boolean; - }; - }; + type DateAttributes = Pick; type DateAttribute = { date: string | null; @@ -229,6 +241,18 @@ declare global { }; type VisibleSettings = Record; + + type FriendlyDateGroup = + | 'before-last-week' + | 'last-week' + | 'yesterday' + | 'today' + | 'tomorrow' + | 'this-week' + | 'next-week' + | 'this-month' + | 'next-month' + | 'after-next-month'; } export {};