diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index fa63377..1e2a56f 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -402,5 +402,16 @@ }, "noNotifications": "Keine Benachrichtigungen" } + }, + "liveTimeAgo": { + "justNow": "Gerade eben", + "day": "Vor {{count}} Tag", + "day_plural": "Vor {{count}} Tage", + "hour": "Vor {{count}} Stunde", + "hour_plural": "Vor {{count}} Stunden", + "minute": "Vor {{count}} Minute", + "minute_plural": "Vor {{count}} Minuten", + "second": "Vor {{count}} Sekunde", + "second_plural": "Vor {{count}} Sekunden" } } diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 1502c93..34280b7 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -401,5 +401,16 @@ }, "noNotifications": "No notifications" } + }, + "liveTimeAgo": { + "justNow": "Just now", + "day": "{{count}} day ago", + "day_plural": "{{count}} days ago", + "hour": "{{count}} hour ago", + "hour_plural": "{{count}} hours ago", + "minute": "{{count}} minute ago", + "minute_plural": "{{count}} minutes ago", + "second": "{{count}} second ago", + "second_plural": "{{count}} seconds ago" } } diff --git a/src/Components/Header/index.js b/src/Components/Header/index.js index e6cfb6a..c965706 100644 --- a/src/Components/Header/index.js +++ b/src/Components/Header/index.js @@ -1,7 +1,6 @@ import { BellOutlined, CheckCircleOutlined, - CheckOutlined, CloseCircleOutlined, CloseOutlined, DeleteOutlined, @@ -16,10 +15,12 @@ import { Badge, Button, Drawer, List, Popconfirm, Typography } from "antd"; import { Header } from "antd/es/layout/layout"; import { useEffect, useState } from "react"; import { useHeaderContext } from "../../Contexts/HeaderContext"; -import { FormatDatetime, myFetch } from "../../utils"; +import { myFetch } from "../../utils"; import { useWebSocketContext } from "../../Contexts/WebSocketContext"; import { SentMessagesCommands } from "../../Handlers/WebSocketMessageHandler"; import { useTranslation } from "react-i18next"; +import LiveTimeAgo from "../LiveTimeAgo"; +import MyPagination from "../MyPagination"; export default function HeaderMenu({ isSideMenuCollapsed, @@ -31,17 +32,35 @@ export default function HeaderMenu({ const [isNotificationDrawerOpen, setIsNotificationDrawerOpen] = useState(false); + const fetchNotifications = (page = 1) => { + myFetch(`/notifications?page=${page}`, "GET").then((data) => { + console.log("data", data); + + headerContext.setNotificationResponse(data); + }); + }; + + const onPaginationChange = (page) => { + headerContext.setPaginationPage(page); + headerContext.paginationPageRef.current = page; + }; + useEffect(() => { // fetch will only be called if the drawer is open and there are no notifications // further notifications will be fetched by the websocket - if (!isNotificationDrawerOpen || headerContext.notifications !== null) + if (!isNotificationDrawerOpen || headerContext.notficationResponse !== null) return; - myFetch("/notifications", "GET").then((data) => - headerContext.setNotifications(data.Notifications) - ); + fetchNotifications(1); }, [isNotificationDrawerOpen]); + useEffect(() => { + if (!isNotificationDrawerOpen) return; + + console.log("paginationPage", headerContext.paginationPage); + fetchNotifications(headerContext.paginationPage); + }, [headerContext.paginationPage]); + return (
- {headerContext.totalNotifications === 0 || - headerContext.notifications === null ? ( -
- - - {t("header.notificationDrawer.noNotifications")} - -
- ) : ( + {isNotificationDrawerOpen && ( <> - { - return new Date(b.CreatedAt) - new Date(a.CreatedAt); - })} - renderItem={(item) => ( - - } - title={item.Title} - description={FormatDatetime(item.CreatedAt)} + {headerContext.totalNotifications === 0 || + headerContext.notficationResponse === null ? ( +
+ + + {t("header.notificationDrawer.noNotifications")} + +
+ ) : ( + { + return new Date(b.CreatedAt) - new Date(a.CreatedAt); + } + )} + footer={ + onPaginationChange(page)} + totalPages={headerContext.notficationResponse.TotalPages} + size="small" /> + } + renderItem={(item) => ( + + } + title={item.Title} + description={} + /> - - webSocketContext.SendSocketMessage( - SentMessagesCommands.DeleteOneNotification, - { - notificationId: item.Id, - } - ) - } - /> - - )} - /> + + webSocketContext.SendSocketMessage( + SentMessagesCommands.DeleteOneNotification, + { + notificationId: item.Id, + } + ) + } + /> +
+ )} + /> + )} )} diff --git a/src/Components/LiveTimeAgo/index.js b/src/Components/LiveTimeAgo/index.js new file mode 100644 index 0000000..fb92890 --- /dev/null +++ b/src/Components/LiveTimeAgo/index.js @@ -0,0 +1,61 @@ +import { Tooltip } from "antd"; +import { useEffect, useState } from "react"; +import { FormatDatetime } from "../../utils"; +import { useTranslation } from "react-i18next"; + +// Calculate elapsed time in seconds, minutes, hours, or days +function calculatedTimeAgo(startTime, t) { + const currentTime = new Date(); + const elapsedMilliseconds = currentTime - new Date(startTime); + + let timeAgoText; + + if (elapsedMilliseconds < 60000) { + const secondsAgo = Math.floor(elapsedMilliseconds / 1000); + timeAgoText = + secondsAgo === 0 + ? "just now" + : secondsAgo === 1 + ? t("liveTimeAgo.second", { count: secondsAgo }) + : t("liveTimeAgo.second_plural", { count: secondsAgo }); + } else if (elapsedMilliseconds < 3600000) { + const minutesAgo = Math.floor(elapsedMilliseconds / 60000); + timeAgoText = + minutesAgo === 1 + ? t("liveTimeAgo.minute", { count: minutesAgo }) + : t("liveTimeAgo.minute_plural", { count: minutesAgo }); + } else if (elapsedMilliseconds < 86400000) { + const hoursAgo = Math.floor(elapsedMilliseconds / 3600000); + timeAgoText = + hoursAgo === 1 + ? t("liveTimeAgo.hour", { count: hoursAgo }) + : t("liveTimeAgo.hour_plural", { count: hoursAgo }); + } else { + const daysAgo = Math.floor(elapsedMilliseconds / 86400000); + timeAgoText = + daysAgo === 1 + ? t("liveTimeAgo.day", { count: daysAgo }) + : t("liveTimeAgo.day_plural", { count: daysAgo }); + } + + return timeAgoText; +} + +export default function LiveTimeAgo({ startTime }) { + const { t } = useTranslation(); + const [timeAgo, setTimeAgo] = useState(calculatedTimeAgo(startTime, t)); + + useEffect(() => { + const interval = setInterval(() => { + setTimeAgo(calculatedTimeAgo(startTime, t)); + }, 1000); // Update every second + + return () => clearInterval(interval); // Clean up on component unmount + }, [startTime]); + + return ( + + {timeAgo} + + ); +} diff --git a/src/Components/MyPagination/index.js b/src/Components/MyPagination/index.js index deafd2d..3c9cb36 100644 --- a/src/Components/MyPagination/index.js +++ b/src/Components/MyPagination/index.js @@ -5,9 +5,11 @@ export default function MyPagination({ paginationPage, setPaginationPage, totalPages, + size, }) { return ( useContext(HeaderContext); export default function HeaderProvider({ children }) { const [totalNotifications, setTotalNotifications] = useState(0); - // initially null, then set to [...] on first fetch - const [notifications, setNotifications] = useState(null); + const [notficationResponse, setNotificationResponse] = useState(null); + const [paginationPage, setPaginationPage] = useState(1); + const paginationPageRef = useRef(paginationPage); return ( {children} diff --git a/src/Handlers/WebSocketMessageHandler.js b/src/Handlers/WebSocketMessageHandler.js index 712a540..c493156 100644 --- a/src/Handlers/WebSocketMessageHandler.js +++ b/src/Handlers/WebSocketMessageHandler.js @@ -109,7 +109,9 @@ export function handleWebSocketMessage( groupTasksContext.setGroupTasks((arr) => { const newArr = [...arr]; - if (newArr.length === 5) { + if ( + newArr.length === Constants.GLOBALS.GROUP_TASKS_PAGINATION_LIMIT + ) { newArr.pop(); } @@ -969,40 +971,66 @@ export function handleWebSocketMessage( }); break; case ReceivedMessagesCommands.NewNotification: + if (headerContext.paginationPageRef.current === 1) { + console.log("new body", body); + + headerContext.setNotificationResponse((obj) => { + if (obj === null) return obj; + + const newArr = [...obj.Notifications]; + + if ( + newArr.length === Constants.GLOBALS.NOTIFICATIONS_PAGINATION_LIMIT + ) { + newArr.pop(); + } + + newArr.unshift(body.Notification); + + obj.Notifications = newArr; + obj.TotalPages = body.TotalPages; + + return obj; + }); + } + headerContext.setTotalNotifications( (totalNotifications) => totalNotifications + 1 ); - - headerContext.setNotifications((arr) => { - // only add notifications to the list if the list is not null - // this has to do with the get fetch that is executed when the list is empty - if (arr === null) return arr; - - const newArr = [...arr]; - - newArr.push(body); - - return newArr; - }); break; case ReceivedMessagesCommands.AllNotificationsDeleted: + headerContext.setNotificationResponse(null); headerContext.setTotalNotifications(0); - - headerContext.setNotifications([]); + headerContext.setPaginationPage(1); break; case ReceivedMessagesCommands.OneNotificationDeleted: headerContext.setTotalNotifications( (totalNotifications) => totalNotifications - 1 ); - headerContext.setNotifications((arr) => { - if (arr === null) return arr; + headerContext.setNotificationResponse((obj) => { + if (obj === null) return obj; - let newArr = [...arr]; + const newArr = [...obj.Notifications]; - newArr = newArr.filter((notification) => notification.Id !== body); + const arrIndex = newArr.findIndex( + (notification) => notification.Id === body + ); - return newArr; + if (arrIndex !== -1) { + newArr.splice(arrIndex, 1); + } + + obj.Notifications = newArr; + + if (newArr.length === 0) { + const newPaginationPage = headerContext.paginationPageRef.current - 1; + + headerContext.setPaginationPage(newPaginationPage); + headerContext.paginationPageRef.current = newPaginationPage; + } + + return obj; }); break; default: diff --git a/src/utils.js b/src/utils.js index 29c324e..282c3b8 100644 --- a/src/utils.js +++ b/src/utils.js @@ -76,6 +76,7 @@ export const Constants = { MAX_EQUIPMENT_DOCUMENTATION_NOTE_LENGTH: 2000, EQUIPMENT_DOCUMENTATIONS_PAGINATION_LIMIT: 3, GROUP_TASKS_PAGINATION_LIMIT: 5, + NOTIFICATIONS_PAGINATION_LIMIT: 10, }, MAX_AVATAR_SIZE: 5 * 1024 * 1024, ACCEPTED_AVATAR_FILE_TYPES: [