diff options
Diffstat (limited to 'apps/files_reminders/src')
-rw-r--r-- | apps/files_reminders/src/actions/clearReminderAction.ts | 54 | ||||
-rw-r--r-- | apps/files_reminders/src/actions/reminderStatusAction.ts | 45 | ||||
-rw-r--r-- | apps/files_reminders/src/actions/setReminderCustomAction.ts | 43 | ||||
-rw-r--r-- | apps/files_reminders/src/actions/setReminderMenuAction.ts | 37 | ||||
-rw-r--r-- | apps/files_reminders/src/actions/setReminderSuggestionActions.scss | 23 | ||||
-rw-r--r-- | apps/files_reminders/src/actions/setReminderSuggestionActions.ts | 143 | ||||
-rw-r--r-- | apps/files_reminders/src/components/SetCustomReminderModal.vue | 195 | ||||
-rw-r--r-- | apps/files_reminders/src/init.ts | 19 | ||||
-rw-r--r-- | apps/files_reminders/src/services/customPicker.ts | 29 | ||||
-rw-r--r-- | apps/files_reminders/src/services/reminderService.ts | 38 | ||||
-rw-r--r-- | apps/files_reminders/src/shared/logger.ts | 11 | ||||
-rw-r--r-- | apps/files_reminders/src/shared/types.ts | 10 | ||||
-rw-r--r-- | apps/files_reminders/src/shared/utils.ts | 162 |
13 files changed, 809 insertions, 0 deletions
diff --git a/apps/files_reminders/src/actions/clearReminderAction.ts b/apps/files_reminders/src/actions/clearReminderAction.ts new file mode 100644 index 00000000000..148861999f4 --- /dev/null +++ b/apps/files_reminders/src/actions/clearReminderAction.ts @@ -0,0 +1,54 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Vue from 'vue' +import { FileAction, type Node } from '@nextcloud/files' +import { emit } from '@nextcloud/event-bus' +import { translate as t } from '@nextcloud/l10n' + +import AlarmOffSvg from '@mdi/svg/svg/alarm-off.svg?raw' + +import { clearReminder } from '../services/reminderService.ts' +import { getVerboseDateString } from '../shared/utils.ts' + +export const action = new FileAction({ + id: 'clear-reminder', + + displayName: () => t('files_reminders', 'Clear reminder'), + + title: (nodes: Node[]) => { + const node = nodes.at(0)! + const dueDate = new Date(node.attributes['reminder-due-date']) + return `${t('files_reminders', 'Clear reminder')} – ${getVerboseDateString(dueDate)}` + }, + + iconSvgInline: () => AlarmOffSvg, + + enabled: (nodes: Node[]) => { + // Only allow on a single node + if (nodes.length !== 1) { + return false + } + const node = nodes.at(0)! + const dueDate = node.attributes['reminder-due-date'] + return Boolean(dueDate) + }, + + async exec(node: Node) { + if (node.fileid) { + try { + await clearReminder(node.fileid) + Vue.set(node.attributes, 'reminder-due-date', '') + emit('files:node:updated', node) + return true + } catch (error) { + return false + } + } + return null + }, + + order: 19, +}) diff --git a/apps/files_reminders/src/actions/reminderStatusAction.ts b/apps/files_reminders/src/actions/reminderStatusAction.ts new file mode 100644 index 00000000000..6a2c9943d3b --- /dev/null +++ b/apps/files_reminders/src/actions/reminderStatusAction.ts @@ -0,0 +1,45 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { FileAction, type Node } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' + +import AlarmSvg from '@mdi/svg/svg/alarm.svg?raw' + +import { pickCustomDate } from '../services/customPicker.ts' +import { getVerboseDateString } from '../shared/utils.ts' + +export const action = new FileAction({ + id: 'reminder-status', + + inline: () => true, + + displayName: () => '', + + title: (nodes: Node[]) => { + const node = nodes.at(0)! + const dueDate = new Date(node.attributes['reminder-due-date']) + return `${t('files_reminders', 'Reminder set')} – ${getVerboseDateString(dueDate)}` + }, + + iconSvgInline: () => AlarmSvg, + + enabled: (nodes: Node[]) => { + // Only allow on a single node + if (nodes.length !== 1) { + return false + } + const node = nodes.at(0)! + const dueDate = node.attributes['reminder-due-date'] + return Boolean(dueDate) + }, + + async exec(node: Node) { + pickCustomDate(node) + return null + }, + + order: -15, +}) diff --git a/apps/files_reminders/src/actions/setReminderCustomAction.ts b/apps/files_reminders/src/actions/setReminderCustomAction.ts new file mode 100644 index 00000000000..ea21293ee52 --- /dev/null +++ b/apps/files_reminders/src/actions/setReminderCustomAction.ts @@ -0,0 +1,43 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node, View } from '@nextcloud/files' + +import { FileAction } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import CalendarClockSvg from '@mdi/svg/svg/calendar-clock.svg?raw' + +import { SET_REMINDER_MENU_ID } from './setReminderMenuAction' +import { pickCustomDate } from '../services/customPicker' + +export const action = new FileAction({ + id: 'set-reminder-custom', + displayName: () => t('files_reminders', 'Custom reminder'), + title: () => t('files_reminders', 'Reminder at custom date & time'), + iconSvgInline: () => CalendarClockSvg, + + enabled: (nodes: Node[], view: View) => { + if (view.id === 'trashbin') { + return false + } + // Only allow on a single node + if (nodes.length !== 1) { + return false + } + const node = nodes.at(0)! + const dueDate = node.attributes['reminder-due-date'] + return dueDate !== undefined + }, + + parent: SET_REMINDER_MENU_ID, + + async exec(file: Node) { + pickCustomDate(file) + return null + }, + + // After presets + order: 22, +}) diff --git a/apps/files_reminders/src/actions/setReminderMenuAction.ts b/apps/files_reminders/src/actions/setReminderMenuAction.ts new file mode 100644 index 00000000000..d6ddcd90677 --- /dev/null +++ b/apps/files_reminders/src/actions/setReminderMenuAction.ts @@ -0,0 +1,37 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node, View } from '@nextcloud/files' + +import { FileAction } from '@nextcloud/files' +import { translate as t } from '@nextcloud/l10n' +import AlarmSvg from '@mdi/svg/svg/alarm.svg?raw' + +export const SET_REMINDER_MENU_ID = 'set-reminder-menu' + +export const action = new FileAction({ + id: SET_REMINDER_MENU_ID, + displayName: () => t('files_reminders', 'Set reminder'), + iconSvgInline: () => AlarmSvg, + + enabled: (nodes: Node[], view: View) => { + if (view.id === 'trashbin') { + return false + } + // Only allow on a single node + if (nodes.length !== 1) { + return false + } + const node = nodes.at(0)! + const dueDate = node.attributes['reminder-due-date'] + return dueDate !== undefined + }, + + async exec() { + return null + }, + + order: 20, +}) diff --git a/apps/files_reminders/src/actions/setReminderSuggestionActions.scss b/apps/files_reminders/src/actions/setReminderSuggestionActions.scss new file mode 100644 index 00000000000..1327500c964 --- /dev/null +++ b/apps/files_reminders/src/actions/setReminderSuggestionActions.scss @@ -0,0 +1,23 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + // TODO: remove when/if the actions API supports a separator + // This the last preset action, so we need to add a separator +.files-list__row-action-set-reminder-custom { + margin-top: 13px; + position: relative; + + &::before { + content: ""; + margin-block: 3px; + margin-inline: 15px 10px; + border-bottom: 1px solid var(--color-border-dark); + cursor: default; + display: flex; + height: 0; + position: absolute; + inset-inline: 0; + top: -10px; + } +} diff --git a/apps/files_reminders/src/actions/setReminderSuggestionActions.ts b/apps/files_reminders/src/actions/setReminderSuggestionActions.ts new file mode 100644 index 00000000000..f92b2f89108 --- /dev/null +++ b/apps/files_reminders/src/actions/setReminderSuggestionActions.ts @@ -0,0 +1,143 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Vue from 'vue' +import type { Node, View } from '@nextcloud/files' + +import { FileAction } from '@nextcloud/files' +import { emit } from '@nextcloud/event-bus' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' + +import { DateTimePreset, getDateString, getDateTime, getVerboseDateString } from '../shared/utils' +import { logger } from '../shared/logger' +import { SET_REMINDER_MENU_ID } from './setReminderMenuAction' +import { setReminder } from '../services/reminderService' +import './setReminderSuggestionActions.scss' + +interface ReminderOption { + dateTimePreset: DateTimePreset + label: string + ariaLabel: string + dateString?: string + verboseDateString?: string + action?: () => Promise<void> +} + +const laterToday: ReminderOption = { + dateTimePreset: DateTimePreset.LaterToday, + label: t('files_reminders', 'Later today'), + ariaLabel: t('files_reminders', 'Set reminder for later today'), + dateString: '', + verboseDateString: '', +} + +const tomorrow: ReminderOption = { + dateTimePreset: DateTimePreset.Tomorrow, + label: t('files_reminders', 'Tomorrow'), + ariaLabel: t('files_reminders', 'Set reminder for tomorrow'), + dateString: '', + verboseDateString: '', +} + +const thisWeekend: ReminderOption = { + dateTimePreset: DateTimePreset.ThisWeekend, + label: t('files_reminders', 'This weekend'), + ariaLabel: t('files_reminders', 'Set reminder for this weekend'), + dateString: '', + verboseDateString: '', +} + +const nextWeek: ReminderOption = { + dateTimePreset: DateTimePreset.NextWeek, + label: t('files_reminders', 'Next week'), + ariaLabel: t('files_reminders', 'Set reminder for next week'), + dateString: '', + verboseDateString: '', +} + +/** + * Generate a file action for the given option + * + * @param option The option to generate the action for + * @return The file action or null if the option should not be shown + */ +const generateFileAction = (option: ReminderOption): FileAction|null => { + + return new FileAction({ + id: `set-reminder-${option.dateTimePreset}`, + displayName: () => `${option.label} – ${option.dateString}`, + title: () => `${option.ariaLabel} – ${option.verboseDateString}`, + + // Empty svg to hide the icon + iconSvgInline: () => '<svg></svg>', + + enabled: (nodes: Node[], view: View) => { + if (view.id === 'trashbin') { + return false + } + // Only allow on a single node + if (nodes.length !== 1) { + return false + } + const node = nodes.at(0)! + const dueDate = node.attributes['reminder-due-date'] + return dueDate !== undefined && Boolean(getDateTime(option.dateTimePreset)) + }, + + parent: SET_REMINDER_MENU_ID, + + async exec(node: Node) { + // Can't really happen, but just in case™ + if (!node.fileid) { + logger.error('Failed to set reminder, missing file id') + showError(t('files_reminders', 'Failed to set reminder')) + return null + } + + // Set the reminder + try { + const dateTime = getDateTime(option.dateTimePreset)! + await setReminder(node.fileid, dateTime) + Vue.set(node.attributes, 'reminder-due-date', dateTime.toISOString()) + emit('files:node:updated', node) + showSuccess(t('files_reminders', 'Reminder set for "{fileName}"', { fileName: node.basename })) + } catch (error) { + logger.error('Failed to set reminder', { error }) + showError(t('files_reminders', 'Failed to set reminder')) + } + // Silent success as we display our own notification + return null + }, + + order: 21, + }) +} + +[laterToday, tomorrow, thisWeekend, nextWeek].forEach((option) => { + // Generate the initial date string + const dateTime = getDateTime(option.dateTimePreset) + if (!dateTime) { + return + } + option.dateString = getDateString(dateTime) + option.verboseDateString = getVerboseDateString(dateTime) + + // Update the date string every 30 minutes + setInterval(() => { + const dateTime = getDateTime(option.dateTimePreset) + if (!dateTime) { + return + } + + // update the submenu remind options strings + option.dateString = getDateString(dateTime) + option.verboseDateString = getVerboseDateString(dateTime) + }, 1000 * 30 * 60) +}) + +// Generate the default preset actions +export const actions = [laterToday, tomorrow, thisWeekend, nextWeek] + .map(generateFileAction) as FileAction[] diff --git a/apps/files_reminders/src/components/SetCustomReminderModal.vue b/apps/files_reminders/src/components/SetCustomReminderModal.vue new file mode 100644 index 00000000000..59c0886a009 --- /dev/null +++ b/apps/files_reminders/src/components/SetCustomReminderModal.vue @@ -0,0 +1,195 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <NcDialog v-if="opened" + :name="name" + :out-transition="true" + size="small" + close-on-click-outside + @closing="onClose"> + <form id="set-custom-reminder-form" + class="custom-reminder-modal" + @submit.prevent="setCustom"> + <NcDateTimePickerNative id="set-custom-reminder" + v-model="customDueDate" + :label="label" + :min="nowDate" + :required="true" + type="datetime-local" + @input="onInput" /> + + <NcNoteCard v-if="isValid" type="info"> + {{ t('files_reminders', 'We will remind you of this file') }} + <NcDateTime :timestamp="customDueDate" /> + </NcNoteCard> + + <NcNoteCard v-else type="error"> + {{ t('files_reminders', 'Please choose a valid date & time') }} + </NcNoteCard> + </form> + <template #actions> + <!-- Cancel pick --> + <NcButton type="tertiary" @click="onClose"> + {{ t('files_reminders', 'Cancel') }} + </NcButton> + + <!-- Clear reminder --> + <NcButton v-if="hasDueDate" @click="clear"> + {{ t('files_reminders', 'Clear reminder') }} + </NcButton> + + <!-- Set reminder --> + <NcButton :disabled="!isValid" + type="primary" + form="set-custom-reminder-form" + native-type="submit"> + {{ t('files_reminders', 'Set reminder') }} + </NcButton> + </template> + </NcDialog> +</template> + +<script lang="ts"> +import Vue from 'vue' +import type { Node } from '@nextcloud/files' +import { emit } from '@nextcloud/event-bus' +import { showError, showSuccess } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' + +import NcButton from '@nextcloud/vue/components/NcButton' +import NcDateTime from '@nextcloud/vue/components/NcDateTime' +import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative' +import NcDialog from '@nextcloud/vue/components/NcDialog' +import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' + +import { getDateString, getInitialCustomDueDate } from '../shared/utils.ts' +import { logger } from '../shared/logger.ts' +import { clearReminder, setReminder } from '../services/reminderService.ts' + +export default Vue.extend({ + name: 'SetCustomReminderModal', + + components: { + NcButton, + NcDateTime, + NcDateTimePickerNative, + NcDialog, + NcNoteCard, + }, + + data() { + return { + node: undefined as Node | undefined, + hasDueDate: false, + opened: false, + isValid: true, + + customDueDate: null as null | Date, + nowDate: new Date(), + } + }, + + computed: { + fileId(): number|undefined { + return this.node?.fileid + }, + + fileName(): string|undefined { + return this.node?.basename + }, + + name() { + return this.fileName ? t('files_reminders', 'Set reminder for "{fileName}"', { fileName: this.fileName }) : '' + }, + + label(): string { + return t('files_reminders', 'Reminder at custom date & time') + }, + + clearAriaLabel(): string { + return t('files_reminders', 'Clear reminder') + }, + }, + + methods: { + t, + getDateString, + + /** + * Open the modal to set a custom reminder + * and reset the state. + * @param node The node to set a reminder for + */ + open(node: Node): void { + const dueDate = node.attributes['reminder-due-date'] ? new Date(node.attributes['reminder-due-date']) : null + + this.node = node + this.hasDueDate = Boolean(dueDate) + this.isValid = true + this.opened = true + this.customDueDate = dueDate ?? getInitialCustomDueDate() + this.nowDate = new Date() + + // Focus the input and show the picker after the animation + setTimeout(() => { + const input = document.getElementById('set-custom-reminder') as HTMLInputElement + input.focus() + if (!this.hasDueDate) { + input.showPicker() + } + }, 300) + }, + + async setCustom(): Promise<void> { + // Handle input cleared or invalid date + if (!(this.customDueDate instanceof Date) || isNaN(this.customDueDate)) { + showError(t('files_reminders', 'Please choose a valid date & time')) + return + } + + try { + await setReminder(this.fileId, this.customDueDate) + Vue.set(this.node.attributes, 'reminder-due-date', this.customDueDate.toISOString()) + emit('files:node:updated', this.node) + showSuccess(t('files_reminders', 'Reminder set for "{fileName}"', { fileName: this.fileName })) + this.onClose() + } catch (error) { + logger.error('Failed to set reminder', { error }) + showError(t('files_reminders', 'Failed to set reminder')) + } + }, + + async clear(): Promise<void> { + try { + await clearReminder(this.fileId) + Vue.set(this.node.attributes, 'reminder-due-date', '') + emit('files:node:updated', this.node) + showSuccess(t('files_reminders', 'Reminder cleared for "{fileName}"', { fileName: this.fileName })) + this.onClose() + } catch (error) { + logger.error('Failed to clear reminder', { error }) + showError(t('files_reminders', 'Failed to clear reminder')) + } + }, + + onClose(): void { + this.opened = false + this.$emit('close') + }, + + onInput(): void { + const input = document.getElementById('set-custom-reminder') as HTMLInputElement + this.isValid = input.checkValidity() + }, + }, +}) +</script> + +<style lang="scss" scoped> +.custom-reminder-modal { + margin: 0 12px; +} +</style> diff --git a/apps/files_reminders/src/init.ts b/apps/files_reminders/src/init.ts new file mode 100644 index 00000000000..17da254d0f2 --- /dev/null +++ b/apps/files_reminders/src/init.ts @@ -0,0 +1,19 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { registerDavProperty, registerFileAction } from '@nextcloud/files' +import { action as statusAction } from './actions/reminderStatusAction' +import { action as clearAction } from './actions/clearReminderAction' +import { action as menuAction } from './actions/setReminderMenuAction' +import { actions as suggestionActions } from './actions/setReminderSuggestionActions' +import { action as customAction } from './actions/setReminderCustomAction' + +registerDavProperty('nc:reminder-due-date', { nc: 'http://nextcloud.org/ns' }) + +registerFileAction(statusAction) +registerFileAction(clearAction) +registerFileAction(menuAction) +registerFileAction(customAction) +suggestionActions.forEach((action) => registerFileAction(action)) diff --git a/apps/files_reminders/src/services/customPicker.ts b/apps/files_reminders/src/services/customPicker.ts new file mode 100644 index 00000000000..5cefffe39a5 --- /dev/null +++ b/apps/files_reminders/src/services/customPicker.ts @@ -0,0 +1,29 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Node } from '@nextcloud/files' +import Vue from 'vue' + +import SetCustomReminderModal from '../components/SetCustomReminderModal.vue' + +const View = Vue.extend(SetCustomReminderModal) +const mount = document.createElement('div') +mount.id = 'set-custom-reminder-modal' +document.body.appendChild(mount) + +// Create a new Vue instance and mount it to our modal container +const CustomReminderModal = new View({ + name: 'SetCustomReminderModal', + el: mount, +}) + +export const pickCustomDate = (node: Node): Promise<void> => { + CustomReminderModal.open(node) + + // Wait for the modal to close + return new Promise((resolve) => { + CustomReminderModal.$once('close', resolve) + }) +} diff --git a/apps/files_reminders/src/services/reminderService.ts b/apps/files_reminders/src/services/reminderService.ts new file mode 100644 index 00000000000..9f58d1bdae3 --- /dev/null +++ b/apps/files_reminders/src/services/reminderService.ts @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' + +interface Reminder { + dueDate: null | Date +} + +export const getReminder = async (fileId: number): Promise<Reminder> => { + const url = generateOcsUrl('/apps/files_reminders/api/v1/{fileId}', { fileId }) + const response = await axios.get(url) + const dueDate = response.data.ocs.data.dueDate ? new Date(response.data.ocs.data.dueDate) : null + + return { + dueDate, + } +} + +export const setReminder = async (fileId: number, dueDate: Date): Promise<[]> => { + const url = generateOcsUrl('/apps/files_reminders/api/v1/{fileId}', { fileId }) + + const response = await axios.put(url, { + dueDate: dueDate.toISOString(), // timezone of string is always UTC + }) + + return response.data.ocs.data +} + +export const clearReminder = async (fileId: number): Promise<[]> => { + const url = generateOcsUrl('/apps/files_reminders/api/v1/{fileId}', { fileId }) + const response = await axios.delete(url) + + return response.data.ocs.data +} diff --git a/apps/files_reminders/src/shared/logger.ts b/apps/files_reminders/src/shared/logger.ts new file mode 100644 index 00000000000..79d663cca16 --- /dev/null +++ b/apps/files_reminders/src/shared/logger.ts @@ -0,0 +1,11 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getLoggerBuilder } from '@nextcloud/logger' + +export const logger = getLoggerBuilder() + .setApp('files_reminders') + .detectUser() + .build() diff --git a/apps/files_reminders/src/shared/types.ts b/apps/files_reminders/src/shared/types.ts new file mode 100644 index 00000000000..f8da6f6aed0 --- /dev/null +++ b/apps/files_reminders/src/shared/types.ts @@ -0,0 +1,10 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +export interface FileAttributes { + [key: string]: unknown + id: number + name: string +} diff --git a/apps/files_reminders/src/shared/utils.ts b/apps/files_reminders/src/shared/utils.ts new file mode 100644 index 00000000000..5d583ad3ddd --- /dev/null +++ b/apps/files_reminders/src/shared/utils.ts @@ -0,0 +1,162 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getCanonicalLocale } from '@nextcloud/l10n' + +export enum DateTimePreset { + LaterToday = 'later-today', + Tomorrow = 'tomorrow', + ThisWeekend = 'this-weekend', + NextWeek = 'next-week', +} + +const getFirstWorkdayOfWeek = () => { + const now = new Date() + now.setHours(0, 0, 0, 0) + now.setDate(now.getDate() - now.getDay() + 1) + return new Date(now) +} + +const getWeek = (date: Date) => { + const dateClone = new Date(date) + dateClone.setHours(0, 0, 0, 0) + const firstDayOfYear = new Date(date.getFullYear(), 0, 1, 0, 0, 0, 0) + const daysFromFirstDay = (date.getTime() - firstDayOfYear.getTime()) / 86400000 + return Math.ceil((daysFromFirstDay + firstDayOfYear.getDay() + 1) / 7) +} + +const isSameWeek = (a: Date, b: Date) => { + return getWeek(a) === getWeek(b) + && a.getFullYear() === b.getFullYear() +} + +const isSameDate = (a: Date, b: Date) => { + return a.getDate() === b.getDate() + && a.getMonth() === b.getMonth() + && a.getFullYear() === b.getFullYear() +} + +export const getDateTime = (dateTime: DateTimePreset): null | Date => { + const matchPreset: Record<DateTimePreset, () => null | Date> = { + [DateTimePreset.LaterToday]: () => { + const now = new Date() + const evening = new Date() + evening.setHours(18, 0, 0, 0) + const cutoff = new Date() + cutoff.setHours(17, 0, 0, 0) + if (now >= cutoff) { + return null + } + return evening + }, + + [DateTimePreset.Tomorrow]: () => { + const now = new Date() + const day = new Date() + day.setDate(now.getDate() + 1) + day.setHours(8, 0, 0, 0) + return day + }, + + [DateTimePreset.ThisWeekend]: () => { + const today = new Date() + if ( + [ + 5, // Friday + 6, // Saturday + 0, // Sunday + ].includes(today.getDay()) + ) { + return null + } + const saturday = new Date() + const firstWorkdayOfWeek = getFirstWorkdayOfWeek() + saturday.setDate(firstWorkdayOfWeek.getDate() + 5) + saturday.setHours(8, 0, 0, 0) + return saturday + }, + + [DateTimePreset.NextWeek]: () => { + const today = new Date() + if (today.getDay() === 0) { // Sunday + return null + } + const workday = new Date() + const firstWorkdayOfWeek = getFirstWorkdayOfWeek() + workday.setDate(firstWorkdayOfWeek.getDate() + 7) + workday.setHours(8, 0, 0, 0) + return workday + }, + } + + return matchPreset[dateTime]() +} + +export const getInitialCustomDueDate = (): Date => { + const now = new Date() + const dueDate = new Date() + dueDate.setHours(now.getHours() + 2, 0, 0, 0) + return dueDate +} + +export const getDateString = (dueDate: Date): string => { + let formatOptions: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', + } + + const today = new Date() + + if (!isSameDate(dueDate, today)) { + formatOptions = { + ...formatOptions, + weekday: 'short', + } + } + + if (!isSameWeek(dueDate, today)) { + formatOptions = { + ...formatOptions, + month: 'short', + day: 'numeric', + } + } + + if (dueDate.getFullYear() !== today.getFullYear()) { + formatOptions = { + ...formatOptions, + year: 'numeric', + } + } + + return dueDate.toLocaleString( + getCanonicalLocale(), + formatOptions, + ) +} + +export const getVerboseDateString = (dueDate: Date): string => { + let formatOptions: Intl.DateTimeFormatOptions = { + month: 'long', + day: 'numeric', + weekday: 'long', + hour: 'numeric', + minute: '2-digit', + } + + const today = new Date() + + if (dueDate.getFullYear() !== today.getFullYear()) { + formatOptions = { + ...formatOptions, + year: 'numeric', + } + } + + return dueDate.toLocaleString( + getCanonicalLocale(), + formatOptions, + ) +} |