From 650fb949782fd9d5256d6a8d994e5389d767b0ca Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Nov 2025 08:57:06 +0000 Subject: [PATCH] feat: add time display with configurable format (12h/24h) to non-relative date formats (#1807) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: kolaente --- .../cypress/e2e/task/date-display.spec.ts | 72 ++++++++++++++++--- frontend/src/composables/useTimeFormat.ts | 9 +++ frontend/src/constants/timeFormat.ts | 6 ++ frontend/src/helpers/time/formatDate.ts | 33 +++++---- frontend/src/i18n/lang/en.json | 5 ++ frontend/src/modelTypes/IUserSettings.ts | 2 + frontend/src/models/userSettings.ts | 2 + frontend/src/stores/auth.ts | 2 + frontend/src/views/user/settings/General.vue | 31 +++++++- 9 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 frontend/src/composables/useTimeFormat.ts create mode 100644 frontend/src/constants/timeFormat.ts diff --git a/frontend/cypress/e2e/task/date-display.spec.ts b/frontend/cypress/e2e/task/date-display.spec.ts index fa9f85427..c25dd4c92 100644 --- a/frontend/cypress/e2e/task/date-display.spec.ts +++ b/frontend/cypress/e2e/task/date-display.spec.ts @@ -3,6 +3,7 @@ import {ProjectFactory} from '../../factories/project' import {TaskFactory} from '../../factories/task' import {login} from '../../support/authenticateUser' import {DATE_DISPLAY} from '../../../src/constants/dateDisplay' +import {TIME_FORMAT} from '../../../src/constants/timeFormat' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' @@ -13,30 +14,85 @@ const now = new Date(Date.UTC(2022, 6, 30, 12)) const expectedFormats = { [DATE_DISPLAY.RELATIVE]: dayjs(createdDate).from(now), - [DATE_DISPLAY.MM_DD_YYYY]: dayjs(createdDate).format('MM-DD-YYYY'), - [DATE_DISPLAY.DD_MM_YYYY]: dayjs(createdDate).format('DD-MM-YYYY'), - [DATE_DISPLAY.YYYY_MM_DD]: dayjs(createdDate).format('YYYY-MM-DD'), - [DATE_DISPLAY.MM_SLASH_DD_YYYY]: dayjs(createdDate).format('MM/DD/YYYY'), - [DATE_DISPLAY.DD_SLASH_MM_YYYY]: dayjs(createdDate).format('DD/MM/YYYY'), - [DATE_DISPLAY.YYYY_SLASH_MM_DD]: dayjs(createdDate).format('YYYY/MM/DD'), + [DATE_DISPLAY.MM_DD_YYYY]: dayjs(createdDate).format('MM-DD-YYYY hh:mm A'), + [DATE_DISPLAY.DD_MM_YYYY]: dayjs(createdDate).format('DD-MM-YYYY hh:mm A'), + [DATE_DISPLAY.YYYY_MM_DD]: dayjs(createdDate).format('YYYY-MM-DD hh:mm A'), + [DATE_DISPLAY.MM_SLASH_DD_YYYY]: dayjs(createdDate).format('MM/DD/YYYY hh:mm A'), + [DATE_DISPLAY.DD_SLASH_MM_YYYY]: dayjs(createdDate).format('DD/MM/YYYY hh:mm A'), + [DATE_DISPLAY.YYYY_SLASH_MM_DD]: dayjs(createdDate).format('YYYY/MM/DD hh:mm A'), [DATE_DISPLAY.DAY_MONTH_YEAR]: new Intl.DateTimeFormat('en', { day: 'numeric', month: 'long', year: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true, }).format(createdDate), [DATE_DISPLAY.WEEKDAY_DAY_MONTH_YEAR]: new Intl.DateTimeFormat('en', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: true, + }).format(createdDate), +} + +const expectedFormats24h = { + [DATE_DISPLAY.RELATIVE]: dayjs(createdDate).from(now), + [DATE_DISPLAY.MM_DD_YYYY]: dayjs(createdDate).format('MM-DD-YYYY HH:mm'), + [DATE_DISPLAY.DD_MM_YYYY]: dayjs(createdDate).format('DD-MM-YYYY HH:mm'), + [DATE_DISPLAY.YYYY_MM_DD]: dayjs(createdDate).format('YYYY-MM-DD HH:mm'), + [DATE_DISPLAY.MM_SLASH_DD_YYYY]: dayjs(createdDate).format('MM/DD/YYYY HH:mm'), + [DATE_DISPLAY.DD_SLASH_MM_YYYY]: dayjs(createdDate).format('DD/MM/YYYY HH:mm'), + [DATE_DISPLAY.YYYY_SLASH_MM_DD]: dayjs(createdDate).format('YYYY/MM/DD HH:mm'), + [DATE_DISPLAY.DAY_MONTH_YEAR]: new Intl.DateTimeFormat('en', { + day: 'numeric', + month: 'long', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: false, + }).format(createdDate), + [DATE_DISPLAY.WEEKDAY_DAY_MONTH_YEAR]: new Intl.DateTimeFormat('en', { + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + hour: 'numeric', + minute: 'numeric', + hour12: false, }).format(createdDate), } describe('Date display setting', () => { Object.entries(expectedFormats).forEach(([format, expected]) => { - it(`shows ${format}`, () => { + it(`shows ${format} with 12h time format`, () => { const user = UserFactory.create(1, { - frontend_settings: JSON.stringify({dateDisplay: format}), + frontend_settings: JSON.stringify({dateDisplay: format, timeFormat: TIME_FORMAT.HOURS_12}), + })[0] + const project = ProjectFactory.create(1, {owner_id: user.id})[0] + TaskFactory.truncate() + const task = TaskFactory.create(1, { + id: 1, + project_id: project.id, + created_by_id: user.id, + created: createdDate.toISOString(), + updated: createdDate.toISOString(), + })[0] + + cy.clock(now, ['Date']) + login(user) + cy.visit(`/tasks/${task.id}`) + cy.get('.task-view .created time span').should('contain', expected) + }) + }) + + Object.entries(expectedFormats24h).forEach(([format, expected]) => { + it(`shows ${format} with 24h time format`, () => { + const user = UserFactory.create(1, { + frontend_settings: JSON.stringify({dateDisplay: format, timeFormat: TIME_FORMAT.HOURS_24}), })[0] const project = ProjectFactory.create(1, {owner_id: user.id})[0] TaskFactory.truncate() diff --git a/frontend/src/composables/useTimeFormat.ts b/frontend/src/composables/useTimeFormat.ts new file mode 100644 index 000000000..0b3321697 --- /dev/null +++ b/frontend/src/composables/useTimeFormat.ts @@ -0,0 +1,9 @@ +import {computed} from 'vue' +import {createSharedComposable} from '@vueuse/core' +import {useAuthStore} from '@/stores/auth' + +export const useTimeFormat = createSharedComposable(() => { + const authStore = useAuthStore() + const store = computed(() => authStore.settings.frontendSettings.timeFormat) + return {store} +}) diff --git a/frontend/src/constants/timeFormat.ts b/frontend/src/constants/timeFormat.ts new file mode 100644 index 000000000..f4d16de05 --- /dev/null +++ b/frontend/src/constants/timeFormat.ts @@ -0,0 +1,6 @@ +export const TIME_FORMAT = { + HOURS_12: '12h', + HOURS_24: '24h', +} as const + +export type TimeFormat = typeof TIME_FORMAT[keyof typeof TIME_FORMAT] diff --git a/frontend/src/helpers/time/formatDate.ts b/frontend/src/helpers/time/formatDate.ts index 9f1eddaff..4ff9a4da5 100644 --- a/frontend/src/helpers/time/formatDate.ts +++ b/frontend/src/helpers/time/formatDate.ts @@ -5,7 +5,9 @@ import {i18n} from '@/i18n' import {createSharedComposable} from '@vueuse/core' import {computed, toValue, type MaybeRefOrGetter} from 'vue' import {useDateDisplay} from '@/composables/useDateDisplay' +import {useTimeFormat} from '@/composables/useTimeFormat' import {DATE_DISPLAY, type DateDisplay} from '@/constants/dateDisplay' +import {TIME_FORMAT, type TimeFormat} from '@/constants/timeFormat' import {DAYJS_LOCALE_MAPPING} from '@/i18n/useDayjsLanguageSync.ts' export function dateIsValid(date: Date | null) { @@ -71,13 +73,13 @@ export function useWeekDayFromDate() { } export function formatDisplayDate(date: Date | string | null) { - const {store} = useDateDisplay() - const current = store.value + const {store: dateDisplay} = useDateDisplay() + const {store: timeFormat} = useTimeFormat() - return formatDisplayDateFormat(date, current) + return formatDisplayDateFormat(date, dateDisplay.value, timeFormat.value) } -export function formatDisplayDateFormat(date: Date | string | null, format: DateDisplay) { +export function formatDisplayDateFormat(date: Date | string | null, format: DateDisplay, timeFormat?: TimeFormat) { if (typeof date === 'string') { date = createDateFromString(date) } @@ -86,24 +88,31 @@ export function formatDisplayDateFormat(date: Date | string | null, format: Date return '' } + // Determine the time format string to use + // For 24-hour: HH:mm (24-hour format) + // For 12-hour: hh:mm A (explicit 12-hour format with AM/PM, ignoring locale default) + const timeFormatString = timeFormat === TIME_FORMAT.HOURS_24 ? 'HH:mm' : 'hh:mm A' + switch (format) { case DATE_DISPLAY.MM_DD_YYYY: - return formatDate(date, 'MM-DD-YYYY') + return formatDate(date, `MM-DD-YYYY ${timeFormatString}`) case DATE_DISPLAY.DD_MM_YYYY: - return formatDate(date, 'DD-MM-YYYY') + return formatDate(date, `DD-MM-YYYY ${timeFormatString}`) case DATE_DISPLAY.YYYY_MM_DD: - return formatDate(date, 'YYYY-MM-DD') + return formatDate(date, `YYYY-MM-DD ${timeFormatString}`) case DATE_DISPLAY.MM_SLASH_DD_YYYY: - return formatDate(date, 'MM/DD/YYYY') + return formatDate(date, `MM/DD/YYYY ${timeFormatString}`) case DATE_DISPLAY.DD_SLASH_MM_YYYY: - return formatDate(date, 'DD/MM/YYYY') + return formatDate(date, `DD/MM/YYYY ${timeFormatString}`) case DATE_DISPLAY.YYYY_SLASH_MM_DD: - return formatDate(date, 'YYYY/MM/DD') + return formatDate(date, `YYYY/MM/DD ${timeFormatString}`) case DATE_DISPLAY.DAY_MONTH_YEAR: { - return new Intl.DateTimeFormat(i18n.global.locale.value, {day: 'numeric', month: 'long', year: 'numeric'}).format(date) + const hour12 = timeFormat !== TIME_FORMAT.HOURS_24 + return new Intl.DateTimeFormat(i18n.global.locale.value, {day: 'numeric', month: 'long', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12}).format(date) } case DATE_DISPLAY.WEEKDAY_DAY_MONTH_YEAR: { - return new Intl.DateTimeFormat(i18n.global.locale.value, {weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'}).format(date) + const hour12 = timeFormat !== TIME_FORMAT.HOURS_24 + return new Intl.DateTimeFormat(i18n.global.locale.value, {weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12}).format(date) } case DATE_DISPLAY.RELATIVE: default: diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 64be8b4ee..3b1121b85 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -114,6 +114,11 @@ "dd\/mm\/yyyy": "DD\/MM\/YYYY", "yyyy\/mm\/dd": "YYYY\/MM\/DD" }, + "timeFormat": "Time format", + "timeFormatOptions": { + "12h": "12-hour (AM/PM)", + "24h": "24-hour (HH:mm)" + }, "externalUserNameChange": "Your name is managed by your login provider ({provider}). To change it, please update it there instead." }, "sections": { diff --git a/frontend/src/modelTypes/IUserSettings.ts b/frontend/src/modelTypes/IUserSettings.ts index 6ea48dbe5..84f7987a8 100644 --- a/frontend/src/modelTypes/IUserSettings.ts +++ b/frontend/src/modelTypes/IUserSettings.ts @@ -7,6 +7,7 @@ import type {SupportedLocale} from '@/i18n' import type {DefaultProjectViewKind} from '@/modelTypes/IProjectView' import type {Priority} from '@/constants/priorities' import type {DateDisplay} from '@/constants/dateDisplay' +import type {TimeFormat} from '@/constants/timeFormat' import type {IRelationKind} from '@/types/IRelationKind' export interface IFrontendSettings { @@ -18,6 +19,7 @@ export interface IFrontendSettings { defaultView?: DefaultProjectViewKind minimumPriority?: Priority dateDisplay: DateDisplay + timeFormat: TimeFormat defaultTaskRelationType: IRelationKind } diff --git a/frontend/src/models/userSettings.ts b/frontend/src/models/userSettings.ts index 73ded15da..37c6011e4 100644 --- a/frontend/src/models/userSettings.ts +++ b/frontend/src/models/userSettings.ts @@ -6,6 +6,7 @@ import {PrefixMode} from '@/modules/parseTaskText' import {DEFAULT_PROJECT_VIEW_SETTINGS} from '@/modelTypes/IProjectView' import {PRIORITIES} from '@/constants/priorities' import {DATE_DISPLAY} from '@/constants/dateDisplay' +import {TIME_FORMAT} from '@/constants/timeFormat' import {RELATION_KIND} from '@/types/IRelationKind' export default class UserSettingsModel extends AbstractModel implements IUserSettings { @@ -27,6 +28,7 @@ export default class UserSettingsModel extends AbstractModel impl defaultView: DEFAULT_PROJECT_VIEW_SETTINGS.FIRST, minimumPriority: PRIORITIES.MEDIUM, dateDisplay: DATE_DISPLAY.RELATIVE, + timeFormat: TIME_FORMAT.HOURS_24, defaultTaskRelationType: RELATION_KIND.RELATED, } extraSettingsLinks = {} diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index 2e90e9eca..54a7e89a9 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -23,6 +23,7 @@ import UserSettingsModel from '@/models/userSettings' import {MILLISECONDS_A_SECOND} from '@/constants/date' import {PrefixMode} from '@/modules/parseTaskText' import {DATE_DISPLAY} from '@/constants/dateDisplay' +import {TIME_FORMAT} from '@/constants/timeFormat' import {RELATION_KIND} from '@/types/IRelationKind' import type {IProvider} from '@/types/IProvider' @@ -134,6 +135,7 @@ export const useAuthStore = defineStore('auth', () => { colorSchema: 'auto', allowIconChanges: true, dateDisplay: DATE_DISPLAY.RELATIVE, + timeFormat: TIME_FORMAT.HOURS_24, defaultTaskRelationType: RELATION_KIND.RELATED, ...newSettings.frontendSettings, }, diff --git a/frontend/src/views/user/settings/General.vue b/frontend/src/views/user/settings/General.vue index 1b230ebd9..e405bc86a 100644 --- a/frontend/src/views/user/settings/General.vue +++ b/frontend/src/views/user/settings/General.vue @@ -214,6 +214,25 @@ +
+ +
@@ -366,6 +385,7 @@ import {isSavedFilter} from '@/services/savedFilter' import {DEFAULT_PROJECT_VIEW_SETTINGS} from '@/modelTypes/IProjectView' import {PRIORITIES} from '@/constants/priorities' import {DATE_DISPLAY} from '@/constants/dateDisplay' +import {TIME_FORMAT} from '@/constants/timeFormat' import {RELATION_KINDS} from '@/types/IRelationKind' defineOptions({name: 'UserSettingsGeneral'}) @@ -389,8 +409,13 @@ const dateDisplaySettings = computed(() => ({ [DATE_DISPLAY.MM_SLASH_DD_YYYY]: t('user.settings.general.dateDisplayOptions.mm/dd/yyyy'), [DATE_DISPLAY.DD_SLASH_MM_YYYY]: t('user.settings.general.dateDisplayOptions.dd/mm/yyyy'), [DATE_DISPLAY.YYYY_SLASH_MM_DD]: t('user.settings.general.dateDisplayOptions.yyyy/mm/dd'), - [DATE_DISPLAY.DAY_MONTH_YEAR]: formatDisplayDateFormat(new Date(), DATE_DISPLAY.DAY_MONTH_YEAR), - [DATE_DISPLAY.WEEKDAY_DAY_MONTH_YEAR]: formatDisplayDateFormat(new Date(), DATE_DISPLAY.WEEKDAY_DAY_MONTH_YEAR), + [DATE_DISPLAY.DAY_MONTH_YEAR]: formatDisplayDateFormat(new Date(), DATE_DISPLAY.DAY_MONTH_YEAR, settings.value?.frontendSettings?.timeFormat), + [DATE_DISPLAY.WEEKDAY_DAY_MONTH_YEAR]: formatDisplayDateFormat(new Date(), DATE_DISPLAY.WEEKDAY_DAY_MONTH_YEAR, settings.value?.frontendSettings?.timeFormat), +})) + +const timeFormatSettings = computed(() => ({ + [TIME_FORMAT.HOURS_12]: t('user.settings.general.timeFormatOptions.12h'), + [TIME_FORMAT.HOURS_24]: t('user.settings.general.timeFormatOptions.24h'), })) const authStore = useAuthStore() @@ -408,6 +433,8 @@ const settings = ref({ // Add fallback for old settings that don't have the logo change setting set allowIconChanges: authStore.settings.frontendSettings.allowIconChanges ?? true, dateDisplay: authStore.settings.frontendSettings.dateDisplay ?? DATE_DISPLAY.RELATIVE, + // Add fallback for old settings that don't have the time format set + timeFormat: authStore.settings.frontendSettings.timeFormat ?? TIME_FORMAT.HOURS_12, // Add fallback for old settings that don't have the default task relation type set defaultTaskRelationType: authStore.settings.frontendSettings.defaultTaskRelationType ?? 'related', },