[WEB-3681]feat: added user timezone dates for cycle (#6820)

* feat: added user timezone dates for cycle

* *chore: added translations
*chore: refactored user timezone functions
This commit is contained in:
Vamsi Krishna 2025-03-26 20:23:19 +05:30 committed by GitHub
parent c125bc54ba
commit ae6e5a48fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 247 additions and 40 deletions

View file

@ -1774,6 +1774,12 @@
"remove_filters_to_see_all_cycles": "Odeberte filtry pro zobrazení všech cyklů",
"remove_search_criteria_to_see_all_cycles": "Odeberte kritéria pro zobrazení všech cyklů",
"only_completed_cycles_can_be_archived": "Lze archivovat pouze dokončené cykly",
"start_date": "Začátek data",
"end_date": "Konec data",
"in_your_timezone": "V časovém pásmu",
"transfer_work_items": "Převést {count} pracovních položek",
"date_range": "Období data",
"add_date": "Přidat datum",
"active_cycle": {
"label": "Aktivní cyklus",
"progress": "Pokrok",

View file

@ -1747,6 +1747,12 @@
"remove_filters_to_see_all_cycles": "Entfernen Sie Filter, um alle Zyklen anzuzeigen",
"remove_search_criteria_to_see_all_cycles": "Entfernen Sie Suchkriterien, um alle Zyklen anzuzeigen",
"only_completed_cycles_can_be_archived": "Nur abgeschlossene Zyklen können archiviert werden",
"start_date": "Startdatum",
"end_date": "Enddatum",
"in_your_timezone": "In Ihrer Zeitzone",
"transfer_work_items": "Übertragen von {count} Arbeitselementen",
"date_range": "Datumsbereich",
"add_date": "Datum hinzufügen",
"active_cycle": {
"label": "Aktiver Zyklus",
"progress": "Fortschritt",
@ -2321,4 +2327,4 @@
"label": "{count, plural, one {Modul} few {Module} other {Module}}",
"no_module": "Kein Modul"
}
}
}

View file

@ -1606,6 +1606,12 @@
"remove_filters_to_see_all_cycles": "Remove the filters to see all cycles",
"remove_search_criteria_to_see_all_cycles": "Remove the search criteria to see all cycles",
"only_completed_cycles_can_be_archived": "Only completed cycles can be archived",
"start_date": "Start date",
"end_date": "End date",
"in_your_timezone": "In your timezone",
"transfer_work_items": "Transfer {count} work items",
"date_range": "Date range",
"add_date": "Add date",
"active_cycle": {
"label": "Active cycle",
"progress": "Progress",

View file

@ -1776,6 +1776,12 @@
"remove_filters_to_see_all_cycles": "Elimina los filtros para ver todos los ciclos",
"remove_search_criteria_to_see_all_cycles": "Elimina los criterios de búsqueda para ver todos los ciclos",
"only_completed_cycles_can_be_archived": "Solo los ciclos completados pueden ser archivados",
"start_date": "Fecha de inicio",
"end_date": "Fecha de finalización",
"in_your_timezone": "En tu zona horaria",
"transfer_work_items": "Transferir {count} elementos de trabajo",
"date_range": "Rango de fechas",
"add_date": "Agregar fecha",
"active_cycle": {
"label": "Ciclo activo",
"progress": "Progreso",

View file

@ -1774,6 +1774,12 @@
"remove_filters_to_see_all_cycles": "Supprimez les filtres pour voir tous les cycles",
"remove_search_criteria_to_see_all_cycles": "Supprimez les critères de recherche pour voir tous les cycles",
"only_completed_cycles_can_be_archived": "Seuls les cycles terminés peuvent être archivés",
"start_date": "Date de début",
"end_date": "Date de fin",
"in_your_timezone": "Dans votre fuseau horaire",
"transfer_work_items": "Transférer {count} éléments de travail",
"date_range": "Plage de dates",
"add_date": "Ajouter une date",
"active_cycle": {
"label": "Cycle actif",
"progress": "Progression",

View file

@ -1772,6 +1772,12 @@
"remove_filters_to_see_all_cycles": "Rimuovi i filtri per vedere tutti i cicli",
"remove_search_criteria_to_see_all_cycles": "Rimuovi i criteri di ricerca per vedere tutti i cicli",
"only_completed_cycles_can_be_archived": "Solo i cicli completati possono essere archiviati",
"start_date": "Data di inizio",
"end_date": "Data di fine",
"in_your_timezone": "Nel tuo fuso orario",
"transfer_work_items": "Trasferisci {count} elementi di lavoro",
"date_range": "Intervallo di date",
"add_date": "Aggiungi data",
"active_cycle": {
"label": "Ciclo attivo",
"progress": "Avanzamento",

View file

@ -1774,6 +1774,12 @@
"remove_filters_to_see_all_cycles": "すべてのサイクルを表示するにはフィルターを解除してください",
"remove_search_criteria_to_see_all_cycles": "すべてのサイクルを表示するには検索条件を解除してください",
"only_completed_cycles_can_be_archived": "完了したサイクルのみアーカイブできます",
"start_date": "開始日",
"end_date": "終了日",
"in_your_timezone": "あなたのタイムゾーン",
"transfer_work_items": "作業項目を転送 {count}",
"date_range": "日付範囲",
"add_date": "日付を追加",
"active_cycle": {
"label": "アクティブなサイクル",
"progress": "進捗",

View file

@ -1776,6 +1776,12 @@
"remove_filters_to_see_all_cycles": "모든 주기를 보려면 필터를 제거하세요",
"remove_search_criteria_to_see_all_cycles": "모든 주기를 보려면 검색 기준을 제거하세요",
"only_completed_cycles_can_be_archived": "완료된 주기만 아카이브할 수 있습니다",
"start_date": "시작일",
"end_date": "종료일",
"in_your_timezone": "내 시간대",
"transfer_work_items": "{count}개의 작업 항목 이전",
"date_range": "날짜 범위",
"add_date": "날짜 추가",
"active_cycle": {
"label": "활성 주기",
"progress": "진행",

View file

@ -1747,6 +1747,12 @@
"remove_filters_to_see_all_cycles": "Usuń filtry, aby wyświetlić wszystkie cykle",
"remove_search_criteria_to_see_all_cycles": "Usuń kryteria wyszukiwania, aby wyświetlić wszystkie cykle",
"only_completed_cycles_can_be_archived": "Można archiwizować tylko ukończone cykle",
"start_date": "Data początku",
"end_date": "Data końca",
"in_your_timezone": "W Twojej strefie czasowej",
"transfer_work_items": "Przenieś {count} elementów pracy",
"date_range": "Zakres dat",
"add_date": "Dodaj datę",
"active_cycle": {
"label": "Aktywny cykl",
"progress": "Postęp",
@ -2321,4 +2327,4 @@
"label": "{count, plural, one {Moduł} few {Moduły} other {Modułów}}",
"no_module": "Brak modułu"
}
}
}

View file

@ -1774,6 +1774,12 @@
"remove_filters_to_see_all_cycles": "Снимите фильтры для просмотра всех циклов",
"remove_search_criteria_to_see_all_cycles": "Очистите поиск для просмотра всех циклов",
"only_completed_cycles_can_be_archived": "Только завершённые циклы можно архивировать",
"start_date": "Дата начала",
"end_date": "Дата окончания",
"in_your_timezone": "В вашем часовом поясе",
"transfer_work_items": "Перенести {count} рабочих элементов",
"date_range": "Диапазон дат",
"add_date": "Добавить дату",
"active_cycle": {
"label": "Активный цикл",
"progress": "Прогресс",

View file

@ -1773,6 +1773,12 @@
"remove_filters_to_see_all_cycles": "Odstráňte filtre pre zobrazenie všetkých cyklov",
"remove_search_criteria_to_see_all_cycles": "Odstráňte kritériá pre zobrazenie všetkých cyklov",
"only_completed_cycles_can_be_archived": "Archivovať je možné iba dokončené cykly",
"start_date": "Dátum začiatku",
"end_date": "Dátum konca",
"in_your_timezone": "Váš časový pásmo",
"transfer_work_items": "Presunúť {count} pracovných položiek",
"date_range": "Dátumový rozsah",
"add_date": "Pridať dátum",
"active_cycle": {
"label": "Aktívny cyklus",
"progress": "Pokrok",

View file

@ -1747,6 +1747,12 @@
"remove_filters_to_see_all_cycles": "Приберіть фільтри, щоб побачити всі цикли",
"remove_search_criteria_to_see_all_cycles": "Приберіть критерії пошуку, щоб побачити всі цикли",
"only_completed_cycles_can_be_archived": "Архівувати можна лише завершені цикли",
"start_date": "Дата початку",
"end_date": "Дата завершення",
"in_your_timezone": "У вашому часовому поясі",
"transfer_work_items": "Перенести {count} робочих одиниць",
"date_range": "Діапазон дат",
"add_date": "Додати дату",
"active_cycle": {
"label": "Активний цикл",
"progress": "Прогрес",

View file

@ -1774,6 +1774,12 @@
"remove_filters_to_see_all_cycles": "移除筛选器以查看所有周期",
"remove_search_criteria_to_see_all_cycles": "移除搜索条件以查看所有周期",
"only_completed_cycles_can_be_archived": "只能归档已完成的周期",
"start_date": "开始日期",
"end_date": "结束日期",
"in_your_timezone": "在您的时区",
"transfer_work_items": "转移 {count} 工作项",
"date_range": "日期范围",
"add_date": "添加日期",
"active_cycle": {
"label": "活动周期",
"progress": "进度",

View file

@ -1776,6 +1776,12 @@
"remove_filters_to_see_all_cycles": "移除篩選器以檢視所有週期",
"remove_search_criteria_to_see_all_cycles": "移除搜尋條件以檢視所有週期",
"only_completed_cycles_can_be_archived": "只有已完成的週期可以封存",
"start_date": "開始日期",
"end_date": "結束日期",
"in_your_timezone": "在您的時區",
"transfer_work_items": "轉移 {count} 工作事項",
"date_range": "日期範圍",
"add_date": "新增日期",
"active_cycle": {
"label": "使用中的週期",
"progress": "進度",

View file

@ -1,10 +1,11 @@
"use client";
import React, { FC, MouseEvent, useEffect, useMemo, useState } from "react";
import { format, parseISO } from "date-fns";
import { observer } from "mobx-react";
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import { Eye, Users } from "lucide-react";
import { Eye, Users, ArrowRight, CalendarDays } from "lucide-react";
// types
import {
CYCLE_FAVORITED,
@ -29,6 +30,7 @@ import { generateQueryParams } from "@/helpers/router.helper";
import { useCycle, useEventTracker, useMember, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { useTimeZoneConverter } from "@/hooks/use-timezone-converter";
// plane web components
import { CycleAdditionalActions } from "@/plane-web/components/cycles";
@ -55,6 +57,8 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
// hooks
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const { isProjectTimeZoneDifferent, getProjectUTCOffset, renderFormattedDateInUserTimezone } =
useTimeZoneConverter(projectId);
// router
const router = useAppRouter();
const searchParams = useSearchParams();
@ -88,6 +92,8 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
const showTransferIssues = routerProjectId && transferableIssuesCount > 0 && cycleStatus === "completed";
const projectUTCOffset = getProjectUTCOffset();
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
@ -189,14 +195,12 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
<Eye className="h-4 w-4 my-auto text-custom-primary-200" />
<span>{t("project_cycles.more_details")}</span>
</button>
{showIssueCount && (
<div className="flex items-center gap-1">
<LayersIcon className="h-4 w-4 text-custom-text-300" />
<span className="text-xs text-custom-text-300">{cycleDetails.total_issues}</span>
</div>
)}
<CycleAdditionalActions cycleId={cycleId} projectId={projectId} />
{showTransferIssues && (
<div
@ -206,37 +210,77 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
}}
>
<TransferIcon className="fill-custom-primary-200 w-4" />
<span>Transfer {transferableIssuesCount} work items</span>
<span>{t("project_cycles.transfer_work_items", { count: transferableIssuesCount })}</span>
</div>
)}
{!isActive && cycleDetails.start_date && (
<DateRangeDropdown
buttonVariant={"transparent-with-text"}
buttonContainerClassName={`h-6 w-full cursor-auto flex items-center gap-1.5 text-custom-text-300 rounded text-xs [&>div]:hover:bg-transparent`}
buttonClassName="p-0"
minDate={new Date()}
value={{
from: getDate(cycleDetails.start_date),
to: getDate(cycleDetails.end_date),
}}
placeholder={{
from: "Start date",
to: "End date",
}}
showTooltip
required={cycleDetails.status !== "draft"}
disabled
hideIcon={{
from: false,
to: false,
}}
/>
{isActive ? (
<>
<div className="flex gap-2">
{/* Duration */}
<Tooltip
tooltipContent={
<span className="flex gap-1">
{renderFormattedDateInUserTimezone(cycleDetails.start_date ?? "")}
<ArrowRight className="h-3 w-3 flex-shrink-0 my-auto" />
{renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
</span>
}
disabled={!isProjectTimeZoneDifferent()}
tooltipHeading={t("project_cycles.date_range")}
>
<div className="flex gap-1 text-xs text-custom-text-300 font-medium items-center">
<CalendarDays className="h-3 w-3 flex-shrink-0 my-auto" />
{cycleDetails.start_date && <span>{format(parseISO(cycleDetails.start_date), "MMM dd, yyyy")}</span>}
<ArrowRight className="h-3 w-3 flex-shrink-0 my-auto" />
{cycleDetails.end_date && <span>{format(parseISO(cycleDetails.end_date), "MMM dd, yyyy")}</span>}
</div>
</Tooltip>
{projectUTCOffset && (
<span className="rounded-md text-xs px-2 cursor-default py-1 bg-custom-background-80 text-custom-text-300">
{projectUTCOffset}
</span>
)}
{/* created by */}
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
</div>
</>
) : (
cycleDetails.start_date && (
<>
<DateRangeDropdown
buttonVariant={"transparent-with-text"}
buttonContainerClassName={`h-6 w-full cursor-auto flex items-center gap-1.5 text-custom-text-300 rounded text-xs [&>div]:hover:bg-transparent`}
buttonClassName="p-0"
minDate={new Date()}
value={{
from: getDate(cycleDetails.start_date),
to: getDate(cycleDetails.end_date),
}}
placeholder={{
from: t("project_cycles.start_date"),
to: t("project_cycles.end_date"),
}}
showTooltip={isProjectTimeZoneDifferent()}
customTooltipHeading={t("project_cycles.in_your_timezone")}
customTooltipContent={
<span className="flex gap-1">
{renderFormattedDateInUserTimezone(cycleDetails.start_date ?? "")}
<ArrowRight className="h-3 w-3 flex-shrink-0 my-auto" />
{renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
</span>
}
required={cycleDetails.status !== "draft"}
disabled
hideIcon={{
from: false,
to: false,
}}
/>
</>
)
)}
{/* created by */}
{createdByDetails && !isActive && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
{!isActive && (
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
<div className="flex w-10 cursor-default items-center justify-center">
@ -255,7 +299,6 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
</div>
</Tooltip>
)}
{isEditingAllowed && !cycleDetails.archived_at && (
<FavoriteStar
onClick={(e) => {

View file

@ -53,6 +53,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
// TODO: change this logic once backend fix the response
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
const isCompleted = cycleStatus === "completed";
const isActive = cycleStatus === "current";
const cycleTotalIssues =
cycleDetails.backlog_issues +
@ -113,6 +114,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
cycleId={cycleId}
cycleDetails={cycleDetails}
parentRef={parentRef}
isActive={isActive}
/>
}
quickActionElement={

View file

@ -6,6 +6,8 @@ import { DateRange, Matcher } from "react-day-picker";
import { usePopper } from "react-popper";
import { ArrowRight, CalendarCheck2, CalendarDays } from "lucide-react";
import { Combobox } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
// ui
import { ComboDropDown, Calendar } from "@plane/ui";
// helpers
@ -50,9 +52,12 @@ type Props = {
};
renderByDefault?: boolean;
renderPlaceholder?: boolean;
customTooltipContent?: React.ReactNode;
customTooltipHeading?: string;
};
export const DateRangeDropdown: React.FC<Props> = (props) => {
const { t } = useTranslation();
const {
buttonClassName,
buttonContainerClassName,
@ -69,8 +74,8 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
maxDate,
onSelect,
placeholder = {
from: "Add date",
to: "Add date",
from: t("project_cycles.add_date"),
to: t("project_cycles.add_date"),
},
placement,
showTooltip = false,
@ -78,6 +83,8 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
value,
renderByDefault = true,
renderPlaceholder = true,
customTooltipContent,
customTooltipHeading,
} = props;
// states
const [isOpen, setIsOpen] = useState(false);
@ -147,13 +154,15 @@ export const DateRangeDropdown: React.FC<Props> = (props) => {
<DropdownButton
className={buttonClassName}
isActive={isOpen}
tooltipHeading="Date range"
tooltipHeading={customTooltipHeading ?? t("project_cycles.date_range")}
tooltipContent={
<>
{dateRange.from ? renderFormattedDate(dateRange.from) : "N/A"}
{" - "}
{dateRange.to ? renderFormattedDate(dateRange.to) : "N/A"}
</>
customTooltipContent ?? (
<>
{dateRange.from ? renderFormattedDate(dateRange.from) : "N/A"}
{" - "}
{dateRange.to ? renderFormattedDate(dateRange.to) : "N/A"}
</>
)
}
showTooltip={showTooltip}
variant={buttonVariant}

View file

@ -0,0 +1,69 @@
import { useCallback } from "react";
import { format } from "date-fns";
import { useProject, useUser } from "@/hooks/store";
export const useTimeZoneConverter = (projectId: string) => {
const { data: user } = useUser();
const { getProjectById } = useProject();
const userTimezone = user?.user_timezone;
const projectTimezone = getProjectById(projectId)?.timezone;
/**
* Render a date in the user's timezone
* @param date - The date to render
* @param formatToken - The format token to use
* @returns The formatted date
*/
const renderFormattedDateInUserTimezone = useCallback(
(date: string, formatToken: string = "MMM dd, yyyy") => {
// return if undefined
if (!date || !userTimezone) return;
// convert the date to the user's timezone
const convertedDate = new Date(date).toLocaleString("en-US", { timeZone: userTimezone });
// return the formatted date
return format(convertedDate, formatToken);
},
[userTimezone]
);
/**
* Get the project's UTC offset
* @returns The project's UTC offset
*/
const getProjectUTCOffset = useCallback(() => {
if (!projectTimezone) return;
// Get date in user's timezone
const projectDate = new Date(new Date().toLocaleString("en-US", { timeZone: projectTimezone }));
const utcDate = new Date(new Date().toLocaleString("en-US", { timeZone: "UTC" }));
// Calculate offset in minutes
const offsetInMinutes = (projectDate.getTime() - utcDate.getTime()) / 60000;
// return if undefined
if (!offsetInMinutes) return;
// Convert to hours and minutes
const hours = Math.floor(Math.abs(offsetInMinutes) / 60);
const minutes = Math.abs(offsetInMinutes) % 60;
// Format as +/-HH:mm
const sign = offsetInMinutes >= 0 ? "+" : "-";
return `UTC ${sign}${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}`;
}, [projectTimezone]);
/**
* Check if the project's timezone is different from the user's timezone
* @returns True if the project's timezone is different from the user's timezone, false otherwise
*/
const isProjectTimeZoneDifferent = useCallback(() => {
if (!projectTimezone || !userTimezone) return false;
return projectTimezone !== userTimezone;
}, [projectTimezone, userTimezone]);
return {
renderFormattedDateInUserTimezone,
getProjectUTCOffset,
isProjectTimeZoneDifferent,
};
};