From 507d58b2347702d6024e30e45aca8a27329f0c94 Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Mon, 14 Oct 2024 19:16:50 +0200 Subject: [PATCH 01/18] Refactor: use barrel exports for services (To create a kind of internal API) --- src/components/Bookmarks/Bookmarks.tsx | 6 +++--- src/components/Modals/SettingsModal/partials/rows.ts | 5 +---- src/components/Modals/ShortlinksModal/ShortlinksModal.tsx | 2 +- src/hooks/useRandomBackground.tsx | 2 +- src/hooks/useSettings.ts | 8 ++++---- src/hooks/useUpdateNotification.ts | 7 +++++-- src/services/bookmarks/index.ts | 3 +++ src/services/settings/index.ts | 5 +++++ src/services/updateNotification/index.ts | 2 ++ src/utils/index.ts | 3 +++ 10 files changed, 28 insertions(+), 15 deletions(-) create mode 100644 src/services/bookmarks/index.ts create mode 100644 src/services/settings/index.ts create mode 100644 src/services/updateNotification/index.ts diff --git a/src/components/Bookmarks/Bookmarks.tsx b/src/components/Bookmarks/Bookmarks.tsx index 343ac9c..0d8747e 100644 --- a/src/components/Bookmarks/Bookmarks.tsx +++ b/src/components/Bookmarks/Bookmarks.tsx @@ -2,13 +2,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { Bookmarks, bookmarks, tabs } from "webextension-polyfill"; -import { filterBookmarks } from "@/src/services/bookmarks/filterBookmarks"; -import { mapBookmarks } from "@/src/services/bookmarks/mapBookmarks"; import { + filterBookmarks, + mapBookmarks, SortMode, SortModes, sortBookmarks, -} from "@/src/services/bookmarks/sortBookmarks"; +} from "@/src/services/bookmarks"; import { cn } from "@/src/utils"; import BookmarkItem from "./partials/BookmarkItem"; diff --git a/src/components/Modals/SettingsModal/partials/rows.ts b/src/components/Modals/SettingsModal/partials/rows.ts index cdafd98..305908d 100644 --- a/src/components/Modals/SettingsModal/partials/rows.ts +++ b/src/components/Modals/SettingsModal/partials/rows.ts @@ -1,7 +1,4 @@ -import { - HomescreenSettings, - SidebarSettings, -} from "@/src/services/settings/types"; +import { HomescreenSettings, SidebarSettings } from "@/src/services/settings"; type SettingsRows = { sidebar: { diff --git a/src/components/Modals/ShortlinksModal/ShortlinksModal.tsx b/src/components/Modals/ShortlinksModal/ShortlinksModal.tsx index 62480fd..9d41cd9 100644 --- a/src/components/Modals/ShortlinksModal/ShortlinksModal.tsx +++ b/src/components/Modals/ShortlinksModal/ShortlinksModal.tsx @@ -6,7 +6,7 @@ import { v4 as uuidv4 } from "uuid"; import { Modals, useModalStore } from "@/src/hooks/stores/useModalStore"; import { useShortlinkStore } from "@/src/hooks/stores/useShortlinkStore"; import { addShortlink, deleteShortlink } from "@/src/services/shortlinks"; -import { normalizeUrl } from "@/src/utils/normalizeUrl"; +import { normalizeUrl } from "@/src/utils"; import FormField from "./partials/FormField"; import Button, { buttonVariants } from "../../ui/Button"; diff --git a/src/hooks/useRandomBackground.tsx b/src/hooks/useRandomBackground.tsx index 0d8a7b9..b64f8cb 100644 --- a/src/hooks/useRandomBackground.tsx +++ b/src/hooks/useRandomBackground.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import { dailyRandomNumber } from "@/src/utils/dailyRandomNumber"; +import { dailyRandomNumber } from "@/src/utils"; // import useKeyPress from "./useKeyPress"; type Background = { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index d7a754e..c643986 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -1,13 +1,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { defaultSettings } from "../services/settings/defaultSettings"; -import { editSettings } from "../services/settings/editSettings"; -import { getSettings } from "../services/settings/getSettings"; import { + defaultSettings, + editSettings, + getSettings, HomescreenSettings, Settings, SidebarSettings, -} from "../services/settings/types"; +} from "@/src/services/settings"; export const useSettings = () => { const queryClient = useQueryClient(); diff --git a/src/hooks/useUpdateNotification.ts b/src/hooks/useUpdateNotification.ts index 1e0d493..4756a94 100644 --- a/src/hooks/useUpdateNotification.ts +++ b/src/hooks/useUpdateNotification.ts @@ -1,7 +1,9 @@ import { useEffect, useState } from "react"; -import { getUpdateNotification } from "@/src/services/updateNotification/getUpdateNotification"; -import { setUpdateNotification } from "@/src/services/updateNotification/setUpdateNotification"; +import { + getUpdateNotification, + setUpdateNotification, +} from "@/src/services/updateNotification"; export const useUpdateNotification = () => { const [isVisible, setIsVisible] = useState(false); @@ -16,6 +18,7 @@ export const useUpdateNotification = () => { setUpdateNotification(false); }; + // TODO: Refactor in TanStack Query useEffect(() => { const fetchData = async () => { const data = await getUpdateNotification(); diff --git a/src/services/bookmarks/index.ts b/src/services/bookmarks/index.ts new file mode 100644 index 0000000..13365d1 --- /dev/null +++ b/src/services/bookmarks/index.ts @@ -0,0 +1,3 @@ +export * from "./filterBookmarks"; +export * from "./mapBookmarks"; +export * from "./sortBookmarks"; diff --git a/src/services/settings/index.ts b/src/services/settings/index.ts new file mode 100644 index 0000000..357f7b9 --- /dev/null +++ b/src/services/settings/index.ts @@ -0,0 +1,5 @@ +export * from "./defaultSettings"; +export * from "./editSettings"; +export * from "./getSettings"; + +export * from "./types"; diff --git a/src/services/updateNotification/index.ts b/src/services/updateNotification/index.ts new file mode 100644 index 0000000..3d41713 --- /dev/null +++ b/src/services/updateNotification/index.ts @@ -0,0 +1,2 @@ +export * from "./getUpdateNotification"; +export * from "./setUpdateNotification"; diff --git a/src/utils/index.ts b/src/utils/index.ts index c229dc5..54174a2 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,6 @@ export * from "./cn"; export * from "./date/formatToday"; export * from "./date/formatWeekNumber"; +export * from "./getFavicon"; +export * from "./dailyRandomNumber"; +export * from "./normalizeUrl"; From 89d0f172db906c73d06cfc7aeda705a6724db621 Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Tue, 15 Oct 2024 13:42:58 +0200 Subject: [PATCH 02/18] Create basic 'history' page --- src/components/Homescreen/Homescreen.tsx | 3 ++ src/hooks/useKeyPress.ts | 1 + src/manifest.ts | 2 +- src/pages/history/History.tsx | 66 ++++++++++++++++++++++++ src/pages/history/index.css | 9 ++++ src/pages/history/index.html | 12 +++++ src/pages/history/index.tsx | 28 ++++++++++ src/services/history/getHistory.ts | 54 +++++++++++++++++++ src/services/history/index.ts | 1 + vite.config.ts | 1 + 10 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/pages/history/History.tsx create mode 100644 src/pages/history/index.css create mode 100644 src/pages/history/index.html create mode 100644 src/pages/history/index.tsx create mode 100644 src/services/history/getHistory.ts create mode 100644 src/services/history/index.ts diff --git a/src/components/Homescreen/Homescreen.tsx b/src/components/Homescreen/Homescreen.tsx index afc822f..2997e3a 100644 --- a/src/components/Homescreen/Homescreen.tsx +++ b/src/components/Homescreen/Homescreen.tsx @@ -16,6 +16,9 @@ const Homescreen = () => { const isSidebarOpen = settings?.sidebar?.isOpen; useKeyPress(hotkeys.backslash, () => toggleSidebarSetting("isOpen")); + useKeyPress(hotkeys.greaterThan, () => + window.open("/src/pages/history/index.html") + ); useEasterEggs(); if (isPending) { diff --git a/src/hooks/useKeyPress.ts b/src/hooks/useKeyPress.ts index 150ed33..fa8b47e 100644 --- a/src/hooks/useKeyPress.ts +++ b/src/hooks/useKeyPress.ts @@ -9,6 +9,7 @@ export const hotkeys = { questionMark: "?", left: "ArrowLeft", right: "ArrowRight", + greaterThan: ">", } as const; type Hotkeys = (typeof hotkeys)[keyof typeof hotkeys]; diff --git a/src/manifest.ts b/src/manifest.ts index 8c24ebe..8b25287 100755 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -40,7 +40,7 @@ const manifest: Manifest.WebExtensionManifest = { }, manifest_version: 3, name: "UpTab", - permissions: ["storage", "geolocation", "bookmarks"], + permissions: ["storage", "geolocation", "bookmarks", "history"], version: pkg.version, web_accessible_resources: [ { diff --git a/src/pages/history/History.tsx b/src/pages/history/History.tsx new file mode 100644 index 0000000..8f2ef1e --- /dev/null +++ b/src/pages/history/History.tsx @@ -0,0 +1,66 @@ +import { useQuery } from "@tanstack/react-query"; +import { format } from "date-fns"; + +import { getHistory, organiseHistory } from "@/src/services/history"; +import { getFavicon } from "@/src/utils"; + +const formatTime = (timestamp: number) => { + const date = new Date(timestamp); + return format(date, "HH:mm:ss"); +}; + +const History = () => { + const { data, isError, isPending } = useQuery({ + queryFn: getHistory, + queryKey: ["changelog"], + select: (data) => organiseHistory(data), + }); + + if (isError) { + return

Something has gone wrong.

; + } + + if (isPending) { + return

Loading...

; + } + + return ( +
+
+

History

+ {Object.entries(data).map(([date, hours]) => ( +
+

{date}

+ {Object.entries(hours).map(([hours, items]) => ( +
+

{hours}

+ {items.map(({ id, url, title, lastVisitTime }) => ( + + +
+

{title ?? url}

+

{url}

+
+

+ {formatTime(lastVisitTime || 0)} +

+
+ ))} +
+ ))} +
+ ))} +
+
+ ); +}; + +export default History; diff --git a/src/pages/history/index.css b/src/pages/history/index.css new file mode 100644 index 0000000..2f54d8f --- /dev/null +++ b/src/pages/history/index.css @@ -0,0 +1,9 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", + "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-size: 14px; +} diff --git a/src/pages/history/index.html b/src/pages/history/index.html new file mode 100644 index 0000000..442b176 --- /dev/null +++ b/src/pages/history/index.html @@ -0,0 +1,12 @@ + + + + + History — UpTab + + + +
+ + + diff --git a/src/pages/history/index.tsx b/src/pages/history/index.tsx new file mode 100644 index 0000000..7847a70 --- /dev/null +++ b/src/pages/history/index.tsx @@ -0,0 +1,28 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createRoot } from "react-dom/client"; + +import History from "@/pages/history/History"; +import "@/assets/styles/tailwind.css"; +import "@/pages/newtab/index.css"; +import { initSentry } from "@/src/services/initSentry"; + +initSentry(); + +const queryClient = new QueryClient(); + +const init = () => { + const rootContainer = document.querySelector("#__root"); + + if (!rootContainer) { + throw new Error("Can't find History root element"); + } + + const root = createRoot(rootContainer); + root.render( + + + + ); +}; + +init(); diff --git a/src/services/history/getHistory.ts b/src/services/history/getHistory.ts new file mode 100644 index 0000000..acdc713 --- /dev/null +++ b/src/services/history/getHistory.ts @@ -0,0 +1,54 @@ +import { history } from "webextension-polyfill"; + +const millisecondsPerWeek = 1000 * 60 * 60 * 24 * 7; + +const duration = { + week: new Date().getTime() - millisecondsPerWeek, + month: new Date().getTime() - millisecondsPerWeek * 4, +} as const; + +export const getHistory = () => + history.search({ text: "", startTime: duration.month, maxResults: 1000 }); + +interface HistoryItem { + id: string; + url?: string; + title?: string; + lastVisitTime?: number; + visitCount?: number; + typedCount?: number; +} + +interface OrganisedHistory { + [date: string]: { + [hour: string]: HistoryItem[]; + }; +} + +// TODO: This is buggy. Should be fixed. +export const organiseHistory = (history: HistoryItem[]) => { + const organized: OrganisedHistory = {}; + + history.forEach((item) => { + if (item.lastVisitTime) { + const date = new Date(item.lastVisitTime); + const dateString = date.toISOString().split("T")[0]; // YYYY-MM-DD + const hourString = date + .getUTCHours() + .toString() + .padStart(2, "0") + .padEnd(2, "0"); // HH + + if (!organized[dateString]) { + organized[dateString] = {}; + } + if (!organized[dateString][hourString]) { + organized[dateString][hourString] = []; + } + + organized[dateString][hourString].push(item); + } + }); + + return organized; +}; diff --git a/src/services/history/index.ts b/src/services/history/index.ts new file mode 100644 index 0000000..6cccf81 --- /dev/null +++ b/src/services/history/index.ts @@ -0,0 +1 @@ +export * from "./getHistory"; diff --git a/vite.config.ts b/vite.config.ts index bfdf663..66872aa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -31,6 +31,7 @@ export default defineConfig({ ), newtab: resolve(pagesDir, "newtab", "index.html"), popup: resolve(pagesDir, "popup", "index.html"), + history: resolve(pagesDir, "history", "index.html"), }, output: { entryFileNames: (chunk) => `src/pages/${chunk.name}/index.js`, From f1f442237e7ff7b05bcbf19eb514838289a50226 Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Wed, 16 Oct 2024 17:16:06 +0200 Subject: [PATCH 03/18] Update PR template --- .github/templates/pull_request_template.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/templates/pull_request_template.md b/.github/templates/pull_request_template.md index b535e13..57aa703 100644 --- a/.github/templates/pull_request_template.md +++ b/.github/templates/pull_request_template.md @@ -1,13 +1,11 @@ -# This release contains: - -## Updates: +# What has been done - -## Bugfixes: +## To do -- +- [ ] -## Others: +## Notes - From 8867326a5e525ec6bfde3ddf16d9d040ec11bac4 Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Wed, 16 Oct 2024 17:16:56 +0200 Subject: [PATCH 04/18] Move PR template to `.github` root --- .github/{templates => }/pull_request_template.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{templates => }/pull_request_template.md (100%) diff --git a/.github/templates/pull_request_template.md b/.github/pull_request_template.md similarity index 100% rename from .github/templates/pull_request_template.md rename to .github/pull_request_template.md From 2213835f0b5ab39b102675baff4eecbaf9aaa024 Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Wed, 16 Oct 2024 19:40:28 +0200 Subject: [PATCH 05/18] Add link to history in sidebar (for now) --- src/components/History/HistoryItem.tsx | 32 ++++++++++++++++++++++++++ src/components/Sidebar/Sidebar.tsx | 9 ++++++++ src/components/ui/Spinner.tsx | 2 +- 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/components/History/HistoryItem.tsx diff --git a/src/components/History/HistoryItem.tsx b/src/components/History/HistoryItem.tsx new file mode 100644 index 0000000..5e31d73 --- /dev/null +++ b/src/components/History/HistoryItem.tsx @@ -0,0 +1,32 @@ +import { format } from "date-fns"; + +import type { HistoryItem } from "@/src/services/history"; +import { getFavicon } from "@/src/utils"; + +const formatTime = (timestamp: number) => { + const date = new Date(timestamp); + return format(date, "HH:mm:ss"); +}; + +const HistoryItem = ({ id, url, title, lastVisitTime }: HistoryItem) => ( + + +
+

{title ?? url}

+

{url}

+
+

+ {formatTime(lastVisitTime || 0)} +

+
+); + +export default HistoryItem; diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index 6e948d4..14c1104 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -50,6 +50,15 @@ const Sidebar = ({ isExpanded, setIsExpanded }: Props) => { )} + <> +

·

+ + View history + + diff --git a/src/components/ui/Spinner.tsx b/src/components/ui/Spinner.tsx index 414f5ac..c3337df 100644 --- a/src/components/ui/Spinner.tsx +++ b/src/components/ui/Spinner.tsx @@ -1,7 +1,7 @@ import { cn } from "@/src/utils"; type Props = { - className: string; + className?: string; }; const Spinner = ({ className }: Props) => ( From 3cf51b45828cfe5c143be9e0a65e8624d17ceba3 Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Wed, 16 Oct 2024 19:40:49 +0200 Subject: [PATCH 06/18] Organise history, render in groups and add search queries --- src/hooks/useDebounce.ts | 39 ++++++++ src/pages/history/History.tsx | 115 +++++++++++++++--------- src/services/history/getHistory.ts | 64 +++---------- src/services/history/index.ts | 2 + src/services/history/organiseHistory.ts | 41 +++++++++ src/services/history/types.ts | 10 +++ 6 files changed, 178 insertions(+), 93 deletions(-) create mode 100644 src/hooks/useDebounce.ts create mode 100644 src/services/history/organiseHistory.ts create mode 100644 src/services/history/types.ts diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..32b6749 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,39 @@ +// Via: https://gist.github.com/KristofferEriksson/2d78b69401be23e05f8b1ac12a998da4 + +import { useEffect, useState } from "react"; + +/** + * useDebounce hook + * This hook allows you to debounce any fast changing value. The debounced value will only + * reflect the latest value when the useDebounce hook has not been called for the specified delay period. + * + * @param value - The value to be debounced. + * @param delay - The delay in milliseconds for the debounce. + * @returns The debounced value. + */ +export function useDebounce(value: T, delay: number): { debouncedValue: T } { + // State and setters for debounced value + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + if (typeof value === "string" && value.trim().length === 0) { + setDebouncedValue(value); + return; + } + + // Update debounced value after the specified delay + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + /* + * Cancel the timeout if value changes (also on delay change or unmount) + * This is how we prevent debounced value from updating if value is changed within the delay period + */ + return () => { + clearTimeout(handler); + }; + }, [value, delay]); // Only re-call effect if value or delay changes + + return { debouncedValue }; +} diff --git a/src/pages/history/History.tsx b/src/pages/history/History.tsx index 8f2ef1e..239292b 100644 --- a/src/pages/history/History.tsx +++ b/src/pages/history/History.tsx @@ -1,63 +1,94 @@ +import { MagnifyingGlassIcon } from "@heroicons/react/24/solid"; import { useQuery } from "@tanstack/react-query"; -import { format } from "date-fns"; +import { format, parseISO } from "date-fns"; +import { useState } from "react"; +import HistoryItem from "@/src/components/History/HistoryItem"; +import Spinner from "@/src/components/ui/Spinner"; +import { useDebounce } from "@/src/hooks/useDebounce"; import { getHistory, organiseHistory } from "@/src/services/history"; -import { getFavicon } from "@/src/utils"; -const formatTime = (timestamp: number) => { - const date = new Date(timestamp); - return format(date, "HH:mm:ss"); -}; +const SearchHeader = ({ + searchQuery, + setSearchQuery, +}: { + searchQuery: string; + setSearchQuery: (value: string) => void; +}) => ( + +); const History = () => { + const [searchQuery, setSearchQuery] = useState(""); + const { debouncedValue: debouncedSearchQuery } = useDebounce( + searchQuery, + 200 + ); + const { data, isError, isPending } = useQuery({ - queryFn: getHistory, - queryKey: ["changelog"], + queryFn: () => getHistory({ query: debouncedSearchQuery }), + queryKey: ["changelog", debouncedSearchQuery], + placeholderData: (previousData) => previousData, select: (data) => organiseHistory(data), }); - if (isError) { - return

Something has gone wrong.

; - } + console.log(data); - if (isPending) { - return

Loading...

; + if (isError) { + return

Unable to display history.

; } return (
-
-

History

- {Object.entries(data).map(([date, hours]) => ( -
-

{date}

- {Object.entries(hours).map(([hours, items]) => ( -
-

{hours}

- {items.map(({ id, url, title, lastVisitTime }) => ( - - -
-

{title ?? url}

-

{url}

-
-

- {formatTime(lastVisitTime || 0)} -

-
+
+ + {isPending ? ( + + ) : ( + <> + {Array.from(data.entries()).map(([date, hours]) => ( +
+ {/* TODO: Format dates to (Monday, 16-10-2024) or intl */} +

+ {format(parseISO(date), "EEEE yyyy-MM-dd")} +

+ {Array.from(hours.entries()).map(([hour, items]) => ( +
+

+ {hour}:00 +

+ {items.map((item) => ( + + ))} +
))}
))} -
- ))} + + // <> + // {/* {data.length === 0 &&

Nothing was found.

} */} + // {/* {data.map(({item}) => ( + // + // ))} */} + // + )}
); diff --git a/src/services/history/getHistory.ts b/src/services/history/getHistory.ts index acdc713..6251f70 100644 --- a/src/services/history/getHistory.ts +++ b/src/services/history/getHistory.ts @@ -1,54 +1,16 @@ import { history } from "webextension-polyfill"; -const millisecondsPerWeek = 1000 * 60 * 60 * 24 * 7; - -const duration = { - week: new Date().getTime() - millisecondsPerWeek, - month: new Date().getTime() - millisecondsPerWeek * 4, -} as const; - -export const getHistory = () => - history.search({ text: "", startTime: duration.month, maxResults: 1000 }); - -interface HistoryItem { - id: string; - url?: string; - title?: string; - lastVisitTime?: number; - visitCount?: number; - typedCount?: number; -} - -interface OrganisedHistory { - [date: string]: { - [hour: string]: HistoryItem[]; - }; -} - -// TODO: This is buggy. Should be fixed. -export const organiseHistory = (history: HistoryItem[]) => { - const organized: OrganisedHistory = {}; - - history.forEach((item) => { - if (item.lastVisitTime) { - const date = new Date(item.lastVisitTime); - const dateString = date.toISOString().split("T")[0]; // YYYY-MM-DD - const hourString = date - .getUTCHours() - .toString() - .padStart(2, "0") - .padEnd(2, "0"); // HH - - if (!organized[dateString]) { - organized[dateString] = {}; - } - if (!organized[dateString][hourString]) { - organized[dateString][hourString] = []; - } - - organized[dateString][hourString].push(item); - } - }); - - return organized; +type GetHistoryParams = { + query: string; + endTime?: number; // milliseconds since the epoch + maxResults?: number; + startTime?: number; // Limit results to those visited after this date, represented in milliseconds since the epoch. }; + +/** + * Uses webextension's History API: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/history + * @TODO Implement pagination or sane defaults for maxResults. Now using 1000. + * @TODO Other supported params: https://developer.chrome.com/docs/extensions/reference/api/history#parameters_5 + */ +export const getHistory = ({ query }: GetHistoryParams) => + history.search({ text: query, maxResults: 1000, startTime: 0 }); diff --git a/src/services/history/index.ts b/src/services/history/index.ts index 6cccf81..c09c2a7 100644 --- a/src/services/history/index.ts +++ b/src/services/history/index.ts @@ -1 +1,3 @@ export * from "./getHistory"; +export * from "./types"; +export * from "./organiseHistory"; diff --git a/src/services/history/organiseHistory.ts b/src/services/history/organiseHistory.ts new file mode 100644 index 0000000..1434a8b --- /dev/null +++ b/src/services/history/organiseHistory.ts @@ -0,0 +1,41 @@ +/* eslint-disable camelcase */ +import { HistoryItem, OrganisedHistory } from "./types"; + +/** + * 1. Group history by day, then by hour. + * 2. Sorts history in descending order (latest first) + */ +export const organiseHistory = (history: HistoryItem[]): OrganisedHistory => { + const grouped = history.reduce((acc, item) => { + const date = new Date(item.lastVisitTime || 0); + const [dateString] = date.toISOString().split("T"); + const hourString = date.getHours().toString().padStart(2, "0"); + + if (!acc.has(dateString)) { + acc.set(dateString, new Map()); + } + const dayMap = acc.get(dateString)!; + + if (!dayMap.has(hourString)) { + dayMap.set(hourString, []); + } + dayMap.get(hourString)!.push(item); + + return acc; + }, new Map()); + + const sorted = new Map( + [...grouped.entries()] + .sort(([dateA], [dateB]) => dateB.localeCompare(dateA)) + .map(([date, hours]) => [ + date, + new Map( + [...hours.entries()].sort(([hourA], [hourB]) => + hourB.localeCompare(hourA) + ) + ), + ]) + ); + + return sorted; +}; diff --git a/src/services/history/types.ts b/src/services/history/types.ts new file mode 100644 index 0000000..bb03aec --- /dev/null +++ b/src/services/history/types.ts @@ -0,0 +1,10 @@ +export interface HistoryItem { + id: string; + url?: string; + title?: string | null; + lastVisitTime?: number; // Milliseconds since the epoch + visitCount?: number; + typedCount?: number; +} + +export type OrganisedHistory = Map>; From 75c1660b6d4d28ea0f064cbeafcf227e6fa09b80 Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Fri, 18 Oct 2024 10:01:05 +0200 Subject: [PATCH 07/18] Fade in History favicons --- src/components/History/HistoryItem.tsx | 7 ++-- src/components/Homescreen/Homescreen.tsx | 8 ++--- src/components/ui/ImageBackgroundFadeIn.tsx | 38 +++++++++++++++++++++ src/components/ui/ImageFadeIn.tsx | 34 ++++-------------- 4 files changed, 54 insertions(+), 33 deletions(-) create mode 100644 src/components/ui/ImageBackgroundFadeIn.tsx diff --git a/src/components/History/HistoryItem.tsx b/src/components/History/HistoryItem.tsx index 5e31d73..7ba12f8 100644 --- a/src/components/History/HistoryItem.tsx +++ b/src/components/History/HistoryItem.tsx @@ -3,6 +3,8 @@ import { format } from "date-fns"; import type { HistoryItem } from "@/src/services/history"; import { getFavicon } from "@/src/utils"; +import ImageFadeIn from "../ui/ImageFadeIn"; + const formatTime = (timestamp: number) => { const date = new Date(timestamp); return format(date, "HH:mm:ss"); @@ -12,12 +14,13 @@ const HistoryItem = ({ id, url, title, lastVisitTime }: HistoryItem) => ( - {`Favicon

{title ?? url}

diff --git a/src/components/Homescreen/Homescreen.tsx b/src/components/Homescreen/Homescreen.tsx index 2997e3a..8712247 100644 --- a/src/components/Homescreen/Homescreen.tsx +++ b/src/components/Homescreen/Homescreen.tsx @@ -7,7 +7,7 @@ import { cn } from "@/src/utils"; import InfoWidget from "../InfoWidget/InfoWidget"; import Modals from "../Modals/Modals"; import Sidebar from "../Sidebar/Sidebar"; -import ImageFadeIn from "../ui/ImageFadeIn"; +import ImageBackgroundFadeIn from "../ui/ImageBackgroundFadeIn"; const Homescreen = () => { const { data: settings, isPending, toggleSidebarSetting } = useSettings(); @@ -33,8 +33,8 @@ const Homescreen = () => { isExpanded={!!isSidebarOpen} setIsExpanded={() => toggleSidebarSetting("isOpen")} /> - { )} /> )} - +
); diff --git a/src/components/ui/ImageBackgroundFadeIn.tsx b/src/components/ui/ImageBackgroundFadeIn.tsx new file mode 100644 index 0000000..5f24c33 --- /dev/null +++ b/src/components/ui/ImageBackgroundFadeIn.tsx @@ -0,0 +1,38 @@ +import { ComponentProps, ReactNode, useEffect, useState } from "react"; + +import { cn } from "@/src/utils"; + +interface ImageBackgroundFadeInProps extends ComponentProps<"img"> { + src: string; + alt: string; + className?: string; + children: ReactNode; +} + +const ImageBackgroundFadeIn = ({ + className, + children, + ...props +}: ImageBackgroundFadeInProps) => { + const [isLoaded, setIsLoaded] = useState(false); + + useEffect(() => { + const image = new Image(); + image.src = props.src; + image.onload = () => { + setIsLoaded(true); + }; + }, [props.src]); + + return ( +
+ {children} +
+ ); +}; + +export default ImageBackgroundFadeIn; diff --git a/src/components/ui/ImageFadeIn.tsx b/src/components/ui/ImageFadeIn.tsx index 4485eba..3347cee 100644 --- a/src/components/ui/ImageFadeIn.tsx +++ b/src/components/ui/ImageFadeIn.tsx @@ -6,45 +6,25 @@ interface ImageProps extends ComponentProps<"img"> { src: string; alt: string; className?: string; - asBackground?: boolean; } -const ImageFadeIn = ({ - className, - children, - asBackground = false, - ...props -}: ImageProps) => { +const ImageFadeIn = ({ className, ...props }: ImageProps) => { const [isLoaded, setIsLoaded] = useState(false); useEffect(() => { - if (!asBackground) { - return; - } - const image = new Image(); image.src = props.src; image.onload = () => { setIsLoaded(true); }; - }, [asBackground, props.src]); - - const classes = cn("duration-300", className, !isLoaded && "opacity-0 "); - - if (asBackground) { - return ( -
- {children} -
- ); - } + }, [props.src]); return ( - setIsLoaded(true)} {...props} /> + setIsLoaded(true)} + {...props} + /> ); }; From a8df09753995ef9790c23290a69426e57243ff6b Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Fri, 18 Oct 2024 13:46:47 +0200 Subject: [PATCH 08/18] Allow higher resolution favicons --- src/utils/getFavicon.test.ts | 22 ++++++++++++++++++---- src/utils/getFavicon.ts | 4 ++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/utils/getFavicon.test.ts b/src/utils/getFavicon.test.ts index 4a40f26..fe53000 100644 --- a/src/utils/getFavicon.test.ts +++ b/src/utils/getFavicon.test.ts @@ -16,9 +16,9 @@ describe("getFavicon", () => { "https://site3.net", ]; const expectedFaviconUrls = [ - "https://www.google.com/s2/favicons?domain=https://site1.com", - "https://www.google.com/s2/favicons?domain=https://site2.org", - "https://www.google.com/s2/favicons?domain=https://site3.net", + "https://www.google.com/s2/favicons?domain=https://site1.com&sz=64", + "https://www.google.com/s2/favicons?domain=https://site2.org&sz=64", + "https://www.google.com/s2/favicons?domain=https://site3.net&sz=64", ]; inputUrls.forEach((url, index) => { @@ -30,10 +30,24 @@ describe("getFavicon", () => { it("should handle special characters in the input URL", () => { const inputUrl = "https://site-with_special_characters.com"; const expectedFaviconUrl = - "https://www.google.com/s2/favicons?domain=https://site-with_special_characters.com"; + "https://www.google.com/s2/favicons?domain=https://site-with_special_characters.com&sz=64"; const result = getFavicon(inputUrl); expect(result).toBe(expectedFaviconUrl); }); + + it("should handle size params for a higher resolution", () => { + const inputUrl = "https://site.com"; + + expect(getFavicon(inputUrl)).toEqual( + "https://www.google.com/s2/favicons?domain=https://site.com&sz=64" + ); + expect(getFavicon(inputUrl, 16)).toEqual( + "https://www.google.com/s2/favicons?domain=https://site.com&sz=16" + ); + expect(getFavicon(inputUrl, 256)).toEqual( + "https://www.google.com/s2/favicons?domain=https://site.com&sz=256" + ); + }); }); diff --git a/src/utils/getFavicon.ts b/src/utils/getFavicon.ts index 06ac5f1..952cb3e 100644 --- a/src/utils/getFavicon.ts +++ b/src/utils/getFavicon.ts @@ -1,5 +1,5 @@ -export const getFavicon = (url: string) => - `https://www.google.com/s2/favicons?domain=${url}`; +export const getFavicon = (url: string, size = 64) => + `https://www.google.com/s2/favicons?domain=${url}&sz=${size}`; export const getDdgFavicon = (url: string) => `https://icons.duckduckgo.com/ip3/${url}.ico`; From f4b7566f60d181a37b92b0c3271925998f39c0d3 Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Fri, 18 Oct 2024 14:38:36 +0200 Subject: [PATCH 09/18] Add interactionObserver that keeps track of active days and active hours --- .eslintrc | 1 - bun.lockb | Bin 402826 -> 403343 bytes package.json | 1 + src/pages/history/History.tsx | 103 +++++++++--------- .../history/components}/HistoryItem.tsx | 5 +- src/pages/history/components/SearchHeader.tsx | 26 +++++ src/pages/history/components/TimelineView.tsx | 58 ++++++++++ src/services/history/generateHourArray.ts | 3 + src/services/history/getHistory.ts | 8 +- src/services/history/getHoursWithHistory.ts | 14 +++ src/services/history/index.ts | 1 + src/services/history/organiseHistory.ts | 1 - src/services/history/types.ts | 8 +- 13 files changed, 172 insertions(+), 57 deletions(-) rename src/{components/History => pages/history/components}/HistoryItem.tsx (90%) create mode 100644 src/pages/history/components/SearchHeader.tsx create mode 100644 src/pages/history/components/TimelineView.tsx create mode 100644 src/services/history/generateHourArray.ts create mode 100644 src/services/history/getHoursWithHistory.ts diff --git a/.eslintrc b/.eslintrc index 80ee502..f761dbd 100644 --- a/.eslintrc +++ b/.eslintrc @@ -50,7 +50,6 @@ "dot-notation": "error", "eqeqeq": "error", "max-lines": ["error"], - "multiline-comment-style": "error", "no-console": "warn", "no-duplicate-imports": "error", "no-else-return": "warn", diff --git a/bun.lockb b/bun.lockb index 6c0ddc50e6b0e18041c5a90af2ee7b6882461de5..16212ffc4ea4f80c893335699e3f458a84f8cc7a 100755 GIT binary patch delta 79184 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

sWo3qQK>GUXS_@jKLO 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�W&rNzA#k+>jjcu1?(~eWv0~z|uDXNt*#RX4qyx&SpR*Ao5$tG<+*O#3a52DS8VM4x4_PAge`My-lP9 z6MLIT1#bhY1)`?qJAfAN0A{=cXke-Y)(a%R3-CmGQ&OqO;V>CQqQ?LqN5_@uuZRfEFJCW_$!V!Bh#X7fAjX(7}{^44D2gAjtM4)8P|9 z`%eJnpAaJ1Y!%od(EU@usiy2x!0b-}y9GL$uAc!?KLafK3~+|oC9qQ<`*T2Nv+#4k zg3kdp0x2fz3qa-U9vv0flZBg>{SeRn*Kb8H&z z{~k{x+id+iX>1Yb{xyl7XUe_?%>Ej%TVR0c`VAoU8^EG(0E5gfft>=`-vTZ$3%>;{ z_!dwjFw|sy$FI!q0L#Av3^RKL_6X#D4;XGLz6UJ*9+318K&~0~4?xa80F?q48}A1| z;tznL9{_n~jlgPwRzCtpnb?njf*%3Z0{N!pPk0mMwn9>DZH zfb9a8nhtvb?e_x8_X3K{R)H-7-G2s5Fl9djX8#P>EilP+-3LhB2UxTZP;7Py>=elU z1u)et`~|S!7eI}`G?P^W$gBY@uK|>py#jj#a(@L}VJdzFEd3Rb^c!G?8TK0>=Qlv5 zK?mM2ATp0N>}l4p)cTM` z^&x)P{4BClB)b8T=9`5Lh_s*qphm!$tVBR2RWZvG0SnDufjt7b9^ejBL0!#K50KOl zu*eK+2*_y&s1&%{c#QywjQ~ZB0E^8Ufz<-74g%a~Vg~^V4gypQEHN#U043|Me5phjSo$vOm(c?e+nA%HbzufQIG+(Q9Rn~FmL zOAiGk9R{d0!wv)F90sTqc+Pl-0}>Ah6dev&Yt{&?7HHKR@S=$|2NX01R13UpTDAbR zXaShf0P<{lU+H4irBGCOv!0V>$NWknP z0lNh@nXX3xQjY>GItsAa>=M{1klhOKwprK;u%H#7M&MnO)f$l58nC=IV5`|Huty-b z4Pcw8XaiW<29VSiu-y!63&?2;s1(>?yrTh$M+1tE27F}J2&@)pbqwGW6FUY_a15YY z;4{OnX<;Medo2>#{1iH5a zd~M3w0cN)Y>=yXebUgu(dIDh434rg-E`gl_+3f*8n1$^D3)%x}1b#AE9RQgf0Lwc7 z_L{u{djxV%1ne^vCjyq92uL~!P-BLj1jsoFPzi|q7B&q}jLYz>HG>4NR55dV%Cq0iG#26)^o&z;=N~ro(A~_NM{L zPXi>GtpZyFx_1OLF=ZVAvpWKI3p6ubPY0x)4p?+L;1IJ*V5dO#8Gysg!ZQF1&H&U1 zG&fnD0GXWt%R2#Dn!N&h1adnAjx-gW0ZTgrlDYs|nPFW3Ib8si0&R?!0!T~&6r}); zHfsb{3$#iF9BX2!fPz#&wZQSFWmiCpu7DX`0VkL$f%O8(X97Byk~0C*&jf52ILUNK z1GG;Al&1lb%~pXe0^Pd-PUSsSfZ5#uy9GL$uH6Bt-2scb1I{qJ1a=B!rvo~hh3S9= z>3|x66qCij&@`DD0RFK{u=^0$BanL*Ak9>q1z36(AgKqSyBXF4kkbQDDUe~jo`A%j zfTEs&9%hZeYJpb00KH7C7oeaQpjx1hY1tdlqBmehZ@}56N?^S}avwmJDd_{4-UqN< z;2hJTFQ9#2KzUz4w%IDMMWFlHfb&e**?`$+19l4xFkLeNshNO9nSeoNm%vVe>@2_q zW?>d!K^CA!V5rIJ2gvLPSl$mX%?QXTAYRhUX#cMUu~l z#3JShk?H3{wu@XEG2=djv>yoABgs6|^&;dJGf(21U5MFv5rMNW zCh&Z-@M6G%ivcwP#$;Ur$h-uw{1U)IvlkG)-3-V>?l2XSJ56{bvd9dR++|is?l#^i zg6*lp$tz0Y%+iKv7qit}#Gr46rB$c--s~*eQ@b4zSWJ90yo14p1Yo z%4A&%$h;J={8GRgvsYk`KyD%6X;V=MSXu~3DgsoRVMTzPB0#0UbH*DFNE{C+8V^`& z)(ET?Xf*-wqKQoa6ifhA3%qPvP6V`=2$(Suu+CHotQSb01gJ7$&9Lc{0NVvNnhuiz z?I#1uCj+X@R)H-7-HQRQo3diS>|(%ffla3C6hP_}z@jOD&1RRtPJ!&HfVa)UselDj z0W|{enykwJnU?{UUk2D}_6qC~$ejk*W-6uumQDjCO$Tf@!=?jrrUNPkb{MY&kXQmJ zDgk_C)(ET?XmvT@6BD}}P;fb*THrI&@(MtUD*!XD0DNJp1l9{AUkTV{O0EP)vuy99O$WX}Zr zU>42*8W5~ed);cYMMnv4NR55dV%EY z0iG$j9x(lSz;=N~ro(JN``Li<*?=UoRbY!i_cA~eQ&t9;T?W`K(9Cq514x|%STqN4 zh}k8uQy}{Wz+qkB~ zLO^#jY#|_LA)rzq!+5s?5^o0--45ts)(ET?Xmtmmmxkf%O8(ivU@sWD#KcBEWWmb4-W30PXJrl-~u&Hd_U@2z0+2aGoi<8!-EBz;1y7 zrt3X`)O!Gn?g0!ky99O$WG@C>U=}V0ELaSv5g2N+?geDt3s`aT!NCiYiA!CwK@0{N!p z1ArC}0A@S@7;CBo)(a#r1;k9rQo!`3fb9a8nhq6!_7#Bg3P6$BDzHVM`-6Z9rtCq$ z><0n61tyuU%K)j%0E?CZip?&8odVep0j8RT4*?cD1gH_1X0nz8GM59EF9(#Ey#jj# zavug5l`p3*2BjJOOC`1fcv0K)Kl}utlKz zO2AxGwh}OVC1AI}Jk#|_K}*1x$Yyuw7u8>F^w&{d0iw=K#yi zR)HY zHPdk;^13OLykS0)Y%*P6C2H!cL|ybMQ8$}ifbd(UM>X=cS%{bg)p*xbsh0jLz%VZ2R%#7%&rO@NQg8iCaU zt=kxuS|!x0qx%g zl)nwwZMF(*5$OI7;A>O%4q)~>fZYP$ny&8xQs0dvty=VMV-q$LKk4!O;AJ5*E78WQ$m@Y{kaulS_Yuk~x^#4G@l;# z<31Js@GEWM?}s*1;V%uZ+7d~)By3yZNEIe;)P%|7d1}4DFsE(?5PO5ARG!w=X{Z^*4K{ zDhKgZJ$*w*FEY>(x7BufnSzca!pPRQ`t&*h9ZgKfT?vPj9t>m71ZRuDBNS@xlGNYF z>zz(IS~#XZRy;Bw_`#H{{;vHo_xI{Y!uSb_qd$Sy(b~n;t8eahtc_)*!Qa%+a z3F@sAGn5L)F)p&+9&npu$D;bSDswyZqe{tf0!(q47NMUU>)_%xRREXAiH@mlR_cut z*rAj7B~{}*<07BpnBFS07AAkafKHhl%K3r|cLq!ya2V%{j_HMSV)`Pf-b|yyc82j2 z(#xj472rrMg(yKa{r3*)?Q>#k`kx)si|3T!5uE#A!rfgsb;mD`Ww>xh!Gb+1rxg}v<9*^V6zd&RL#$Bu!;Uz3#O*s-vs zE?hsyj)Tpt=l&G_90!jFUhM+*cdQ+3mSgeP=A8h`RdaBh>%z6ieUW45!Bo)>oWWal z(ZMdTWlcPi|1$1ZXi zp9W*52gk*Zb=0@H4|nhq2TzAR$!OKI%5&@t+^ZZL3F9Yt6~uLJRObU!=+2yFFuikO ztc%+P_oXgefnzDKMBJsCYB2{>aTmG~JkBw_HloO}OJOP$SCG(n$BJFJG+6w#DN|tb z?#8K?yJ)Ii?!tA){itKSJ+L(JmE$MIMB^7fg%G@i5h7FlQgf+PSzFz%m_6b>W7<`onZ|b!;g4hq4_! z)4?19oa4?7?ZxrrF9SdHM zr~V(!IoHAXyYlj3Wsd2!c&gnP&MRSB!7g!ZEbfsoEns<$72uw!2pl6}N=Cb$wk|zY zQr_b@OOJ6d-@!|P`ZH-AV;n2Q9e+psSQtGKD&kz>GAVT7#>3(-I4FWCnF*Y838xiN ze_A0nkyHOQfTJ`t$wi)o)!HRE*|Ev6N>(nde#MRzA1|X%Wzl2w5UxB80|j|W2=MHUBKzE4_)LE$4X!yId-{Ym%}zNShf0H;n)?pH@X3Q zB}`?#lJiv;?kX2f1F$;&#wopmP64mt-0ULjN~i|W7?k_If)v#U^ zP|L1fb*Q-4aC)xL*Eu!|*3hw1y$ewR|H9eG!P$;o3v1<=-j%3u*KwW>>yFNG?0VeF zSj%|)Rf@A+xD5107p@HV)vj{omX(I)0Ivb|LT_>bZ@{g$$@NC(I(8%O+a0^vv2vI) z?t{*A>?YhQU|;kW$L8Yh3Dc78J9abfUV8PN;H?hM!<|S#t>g0@y9Kur6uZqaAGb2r z+OEY#EpscU!f9<^;MjcJ%2-SLLdR~yt+-;htKSvba4NFa`#T(4fLoEZ-s=sN3b&9` zk;N7{b~|px)ivNQ$L_$bxVi@1?bw~T6<6#Y$4VDDs0+hl2k*kI1a)1w*Ri`@I9(U+ zbL<|+bX~aLvBkI*SJ#Clj@^q}F9Xyy;IA+Z^!qq@k6&rxcEhR!mZROW2Iw{l7C2h`;q9A zzy_$UYUwVZUg)Wod5rUP*eLW_n7kk7Z0o|Ub>W_X`P67M`hsICaqF-2^U)U_dlI)^ zSFikoy}mM8#d*CP951_ot8up{pe`Vh#;r`sQcO82frnd%a&E5i2?Ox&h2&TS& z&#`s5KY;0Y-?8<$^|H{J=m#*BwTiO~;jU5p?{EP(;64L53)Ki!CL1|VgZ%~l#IaX# z>(!XoqMyN(U^VA#m$62?*lV1s@eQb6pDOmcV_N8T!h+YUzk#8};$C!@3%JPz6#LS# zHyyhV{mQY;jx9m|=Ga@fKgO-8w%f6{aqH#RI{prm^Bs*ptxB3g-vU+RcRBU;Z%vo) z9ovFiH;#{@Kez<9;?`}pX2y?>y@$JtV?Q~z4R$FBYsBw?;T?LPv%4}=|L=1Fw_~;d zYIN&%S^+=c)ISQ*=>E-x+kra*)9BV#Na=jY`5W!4(H()QsXyY>zfaNNPH^mF-23p< z;MRsV_!mH*V8s7@OVkDY6sG^HqoclKpW#-2zKAw(>~q}ex|dO{l8W*Lr^1Qp+Ap?~ zQ{i4gb?q11#aXI=g1Yt#e#xo#Xlf)MMxE{ z98>?j>e#7{g|YR2nRJ{6BkAB}ZR#d>%o-qKXB{sk`zvF zc+&`03w3dff36zZN-L;^Qe4~yF#SguwTAwT7BiH{uhuZNhTgl3IXvX?`$hS3q`83n z{o9I;25Rk(wDwKspx)QYs=KC5_S+9ZEX2)?|Xg-Ghimn0vRjkz+BJ= zMq@y>${wJJcyiWZ;zfZx9TIAutq% z!TT^AM#2X$3S=0OA!IC!gYhuIEPspzaus~Jq|w$^Da%UXuD>}r|SEEoyjK_SqmVt0aeGwB zH%UO>^h*E`<~hjRt*)eiLkl zZLl4_g*~tr_Q3)85q^S0a2!s+Npm3q^=ieXtVz3JFC2h_a0HHmj6mAtX;Y^?oa{BS zv3!hs4rS2+L5GP~DerDvox|3_dRRsMWUtvu9lGMqgpXi0%z?QUX8cbHj4~4vTB!>U z?7sD5@#OzLJ*0R0%)M9}Zzje@}t1B2il(6O~6=x5mUnbpBC42DB9O4JblMvxowz{{Z1sy^3As zCzI*lZ*k+w=~<4R-OPr$pl30jd?P)1egzLbJJGWeod$K}T@L#x@gCR-<6tp-jK6HP z{ctZ*Ks}YvQwTkEm=CMreb57f7NCa$wV)0pg=8?FR$K%&IH@3-3E}IeKY98 z_>*7{nds~OiEu|zxUKLV`~W*Zc66D|e}-eAy}N#pLFRCoy7$0-kmXu?be(Khtte~?~PSDv%N1^6XZUn%})CHQ}o%u{XhyteU5 z_3zF5REeI~Hvg?@8>xNN`7%P!1^s}`m#`Az>gE5u*X;B2e8!}VZnIz**Aa}ha||0< zfZv1CAmeQWOdw1K+7DnDw1=S#!}sAOu5&>!+~EEu$X0p>WD3>qG0Utu4JN`Qtzr>e zybKzzcZk@9I`x2bD0x!2&GmIq^U2hC9@G{xXP$#I5W&3;?lqwj$TS%Ng&-%;HlZkun$;tJuJ+*N%iA_@meVHv~U6eU=j}FkgVJFNa zP_3{ADnk{h3e}+o)P!149%MNz3XxC#t>)cVCv%y$uv$giW~z^< z`7BtE=hq+snWc(B53b*WUQiZfh6)8gq=gs2f&fs>9>O26$E}E$x#xNnbMEPXFTiu9 zOhrI_te*~=3I?WuGB{14&cG>96RHJeJt_g^C`5Uv0Ixu0r~*|XGh~8vpu!#@-l6LJ zZzF65HILVn5>QQ!k?3(yi)4ZC@cRb5!d6R#7V+=B!m<`+kJdR~%d5^-I%CBUhyMa5 zzFl;S zTZ%j$M!@?p42HrG2wHO`wbdt321xtiE{z(XYq`VS>#?}w%!G7ovb}A(w(7bb1EavZ zmuTGk_^xLlHyu6%15;o!OazKu5`S2-x0vaBYQJx>eJeUoM zU@pu7R3X}q;0bEF`3&L;tD(JwJvn8f)^Th7RBNcP&-3^ER3OEb+*I=aTTQ%7yq0kU zot>h>;Hz%-=(Icoq@G%V25VQS0x}A12BozTzJ@iRCox|^7T5smVIAmP63D@20JGi&uJ_Hg+CjS6c0iyW`hQ&jY)1p#_m}Z@Bxg3QA%JOjD@0*g+wOcjt6hdPUc!;b`q|(p&GKf zHXud%5O=zZ?&onm8WR;TxO`O zpv5;0u6BXi{Urj~Bojgc2!aR_(q2k?sz15D18ZR&+=g3l6|TS~xCq@y_X6%YIHUfT zqMe3Qpq4lR$KYo;0*B!c?1!Dui3EPY{T{x9bnqsAiE+Py?XVHHfm&uO?gr41TaUX1 zHp3>^Xs%?o5|;gn%dcH@{VnW(-LMPxz+Tt~2jC~rbaN2*N1xf4#>yCdl${J4^LmCe1ZVeg6Q#so}DeI^Po(7u6feNEcWHFV3OYe0k`U+PC))VLr30>7rX%i~?zOf*UAXQJ-CRGlj!t|!`DrBe!|e<2 zK_5^ubyaNMZ7pwz?eUG~-uq1OW7vI6R@C_^h>DE@*0xMu6tcBIE9HM|ff3woR z{CF9cN>9mnQKxb}2?jwQ${vGjizj(ZhPLg$Pk{M0hgCM&V1kO`HwAaHNtx4X7Hzm( z1z&0oUOg{0X<=CftHMa3Ai0;yuKbz7sFn_L#?wOwpQB65=L+Ah6&CP^4@`Qn>tN z)b}RSBwTBrO6lG!Q^=*eRi z(BldfL}o3Sw?d#A$lRspYE>W)$m%FlSS65sMNg8-gPyy|Dp?Bj{7q&snZsljla;bC zLJsn4Ws-fcRS~{V=mY)iu0+fKbVcvCcGyxKpha#wdy&B0+&8Hfd z122-~DuCDQ6}gt~D4sHxdZu}6C88U-afPI}%1m9Yme71v9x6ahGbTT8xxB-r7tK?1 zFNwJKx*iDqLDN`2h+EIN*8>PsTg3HKJ4lJVyxe+3+Y_!gEoq*OJIAWL(NGptC?%qL z#DIG8*$kxCgFutFI%FiMr$)eVCB|PvK*TdyfBv z`%5&Cju+i%l-P5;vwu8|zs@!~lj!WC^NG$v|KS)P{Xag(FQ71=!LJl#zU#(S4XIPy zd(~Onx+U@_j`pUycVyTfY4(ubDB^O6(`t63t-^05#5P)Z+(!G7pcSC=_p@d54*%HcxL*Chg4R|2=#M-@vz^aK+gH(R{tIuoV`)lWU#j58-Ny z`!nuQI0HvOnkxU}Tx-j#eR(U~W88bPb|-PaAOF+1CqX+z85_Fbp5Xozc=8pI)8Umg zqU{suIRZ=L`3tU>xiUV?y)sv3lJN?pgrrq*3mJp_1^j&=TM`o&hL z*3&hT@D)&vbnV@5SZU1cs#Z|?TqK$Z zsoxiTkmAsx1U&f4xyJMUhq^lA`Mxn0%!XZ*EQ~R%i2P zDJx&1FJHtQintRP_(vj>ImW6Lct4SuT-plf8+8-jrKYD#TNwlIB{mOBTOoWgEZI~$ zoq}bkaRxK640mDXR7ITXX1VT`o1ITQ(_2LXt|XsY)(Q!IKqV8C*^p)J_vS58cb)1< zLlolxWhxvdpIT*U(Q;<)2Ciec4*WIP%p|5;AiwDQxO1Keb2 z&ub}7=5iGAT~n-_^=9CW)MgI1MN`lq!9=Khw#+wQ&HQD3Y`$6Ckf?EA?#hxpWA@mP zg62v&YEsT5EKlRsHKpXdW_sX+zDX<_CB zRG^7M%!H4)n`s8!veH>dEihv$pcgCBI&HtR@zmW1ejMB~jk>C+^kSpgf&iaVIwrZ_ zk;Cj3lPTlu3yU^n&20G!hed=HE>tMMXHwAWMMKlEr$pfB#StdQ#09%!1;Poz_#89q zi<4dNw`%lhY)B2$oT#Dg5K!8Ul3p0r{;0yUo(va0O_Iil%TST09_!(}SbhOMFa!4g=qUobitILn?k8gd)Ek_YksHT-Baa&d{KcVYiGG(ix zhH>YSo>fupI39r)UpDirTH%qyn5MA0#SD7!e8kQ__KXVj6%PxyXNQjoQQutZ|Hj9+ z=N?HF8}e0Nlf0T0;y>udZScZhX;-g4**8|=dS272Is@mgykT}cLB3Y z{uc_E^EIp%p}sJbkLHopM}5*iq}2UR!y9%8=2M%4YFp`(#;yRH!pzzRR=Al}%Sz>h zMz~HQFO>k(tP!(Ly;@d&Hz=_ew5O0+U(0Hd)4P98hHg>ZDwq#zK1t=-eB!BW({NM1 zjujHw7nPO@eYUD?`EAMfd>+VH*FwN9gAn$+F+XPuHTWngT`vT!q2aknDDaE>|r zd6r+kF0$ypBf%&OI6{b~fr+`MuKuZdg(k6a-A4BBAqy=V{RbY~`(~Q&7RE}r&F{ZP z8d}A6X1tcOM_S{JB9ea)ZYtHaT9p0w_IZQ(LKD^Y8HK*NJNU(1_V9@aD}uo)!kn&a zh5JV!5sXB!uM@7>;II3gJyw}%*mg&luzFTrzJ=Gift4Y#w^pBtFazpYl>+}Df%;ZR z4$nXS?LbxYS3Rqxo6eIpKdXtZ&v1X)Os{WMem2k^w;2MRLKZO98(8UG5uE-FtZYw| z*U_~9qAQ+y|MwViwfKp0dZWfojyccPdDhe?*IHp2G^Cgu7eOj@@5xG&)>_Z~(K}mCww0y};rq|G? zOs3dt*50DyN@0g3-p(sQO)KQy6dCA?3MX?KU=|_TfKB`Ci~bGX&3MvQcoJnJY)ZY( zhOTsJr|EW7E9o2AXh*V$eD%D7>J6*Dj5P@UPI-YbTnc2|_G zp1J-yWeGM(npw3Y*Oqn4vb58ppLV6*n9g0p$niTuQj)^C6ZOCAbR|;@JH&2)BW2Cl zX0*^bBvjfmZ|-Y1V0h=8lF$TIIACr$bNUVHzqp*~`6lkRaweGj$VBCxZhPG~^H`NX zXS3mM!4^lQI^w4FS6AP%p4lsDOHl-+V5qxsLr+w2SKEV^bN1^I{2CJ4Hz1MKOn8H; zQ>?|xlnCRO;l@*lz)lrSSaX_aZAGUr`up3kJS0%Hug;mQx9|_H)2gf&Ww0TtT?RK^8N-eKs{4GhVZ)H;t*Pp$LBROhz zzlezCKlO-}Tw2AgEvu=7v6tv-S#3< z3RkUam_J%EbPWO;n8|VsPn`75jkoPaB@Mf=%C=_zeW8XkslQpWY@Hu_KdfdK)9&ee zgedV88LosCoz|py+zO0pP41a%Ix0CUO|o9ehrhpCxwG#JD;`kLY(l_a(G_TwYIv?C z6Z;KCK;>sxzgpA8bU`Va*EGr7poyK4)cE)&W6#64%kskvPmW>l5hAT_edx@A1Ov|0 zj|~}G)4V}kfBbQ$)-vOj`s`Xx8fm&G4Ud?6x4jDLX!E_=PQDu+E=pOh$R}^wsoQJ1 zsbfwOH_$8TiaI7$TRQ$ZF^V{P|7{)9sx2|xa)pkp>!?V#mov9nIJpgzgLdUKeZY(6 zi?%dRy9SQH*+lu*bnBY$W^C1ZG%#n|S{))sq3cPgeDNAPu7=OZ_#P4(hD=S>8}jlQ zAqSfLboSJZ@snfYHg0I5+R-e_U0tql@K``dtL|SQ;p*S=hUWAo3i?$;vrCdckd|`U z*r3z4z()tiyJ^u)KQ^4&K8|4V#?Ey2XR|{!s%?Bbtz8^@c+6~U>a<7cmo{G{v1kkk!=h;7l}K5ws^7lH^0|v+<7H)YR$P*S?KxaIwa{%cLyjWVFvM=hXLd zyVo0?D7{(AR)P@ftGOyR2^G>#*_+DZnZo?*nmGv$eY|ScgOIe{Nl?R(thP5Z8<}za z&pnme3*62(%#Al`x#L)|WZx+D!Mx+)+3sy24XF!h^fTo;QlICvR74mHF}ueWPWE@Y zPaT{l@%$WaO=ml&ZY<`^S^BJh~YXD_OIx(*T9WH-78jZ#{M9 zciXOUTl1(h+l&Kk&BITqk2ji+wKdm9s-L2H7YFD&U;EpCMnQ`hX(T&A{0+$`}q`+x|$mHM4Ol{mU7rjAQHEq7z zBs|lY0d;n`8KD!;sfD|!Br%&tSQ$-|?pOzEnWNp=;DmQ|_Eg2Tj9NYQl{0%|%Uj7* z=z&JmG0o)Icji4Gk9VZaquDz|5z8WUm+YI+V-!Y}%rpB?tBj08UW)|2; z+`waf%#F7h8{_(#*WW=`XZ1A;-mzB7zS!bj(tcqL_C@0hyw~64ea|Y%pQ^Wi&x$CT zY=G0k{l4$CbwRNywE}%wR%ovP?=#S)I^TPL`tcYOd&bRbS`THzkdLTR%0Vx!$o0r~ z+AjfHB?5{WUtj7`*@WWw8zL8s+{BMp6+Za+txS#_$`#OVfT`D)xgh?%{=39YP2As> z^qtV;>|1{Z`rL^oVB`QZOL1o+AP$I0b-Q&v$(NVn2)ZhloZq1EW z)ry8ipkCh(FqivUc@tOSeI4ynCJ!`O`_Z&32Ab33ad!?hJul!M8)&-YANnhDG6Erk>}H z9eXqOVU$^RnZkb_WtJf69S;J(j53e#rzj5wl4%`Ncp{TrwJ0)e5p9-DK(u4DiHgGY z?mZX(T~TB{v+>ku^6oao8Dx#?@2t~0c%>`{?g8<=A*M()JMV5o9f^XUmEC-PSet>d z!|>5iGc($XiEIAu8YA_z&EJ9J-#6=Zo7mJJtuCOoO5VD&+re zYho_GPm7P5V!98f8$O(3=HdD$PIa0sqTJ)>KPLGD(4BEh+evW@{iu> zudPACoxh&5FgnNQ0BcN(tNrc++%KXcVoxd%9`$ft8kD_OUVr9-eMeeL&UD5-ke_pW1Y1$%Wl( zYgKd)+>UrHeop9%511ny=cz^RMnJi0~A0o$KOn)*O*K{3Yb@sWF)IUu^%$!NbQTKQzA$NWZ zai>0awtF&7x|3XJj|NWLrWy6~*+#p*@%o=CIumH1H`lonwmT1(U>>d)>Gn0FsdpwpbW+MRww z$G`9Be3vqlN4$0LxTas{5hg!n5Y3!kG-R$b`|ch0*{vVb|bDf z?2w>Rm+1()YrufHrXCWJ6Oc%U#L(Gia;MnTuPG8*yJ_do2$5}i`N!E_T~j2(ak~`u z+&!+8nKenP+&pt&lGP!!S!qWBHXJzLWel!;s#UD45?Ce zZI~m)k8WJD|Ar7%Zeh|xj~>7BQxUshc0;-|Q{WE`&HO1=-ZHlqIEzo;%1$}1Z6DZk z=HXq~WgYWMp@4f#U@1uJwM|!pW}dwA5+Q1Hy32X~8(M0i(zDFYNn-{2GUKDp|DV|gLlc#E6!-52O6QOtpHb1>E(Nk$7n;VTGINos zF%2!5zSu{ZB_;Y7mIUz*sGcJr;P<8GDse;aAdv)#EX9Y^`KkL*mIqJ5XR^&eTa%koa?+bd zGk8fL&oXDfThff2!6xk$^R)tNo5M4B$nKPQu`iFgGLuv3$z>+^BP+&z=pEQ`xmo-X z^?GNyIsFl(9>3g~cvd~Wd?Rtj9l0#F&V_Arz+ytQ_SFCI>ecdd_CJaZ*}2?=&7#y7 zT!}ApSh+V7e>rQY17>%nB{E{@{hSug6MkCqy%0gR=Yb`7@>bEH>ol4dyH2hF)_e z7FFrgyjQtAGh-zZm_O!HbpKb*B(;BTaHj1qU8ox?5VOtXocCm`hWP(J61~ynjExX~ zqU}ys*I041RM4%}AG%`|C18APp2zriC3#w`@fUb!sj2ue#@SxqxJQ=G)fx>Nk^GPy z(OxtM5~BI}g_ap^{+MEMnb;(qDJ48K{$km$Y@|08Ke7C7hUUFbtc?ES-#M#6g0dsq zPYmKW-O)FDM)&MOC0Hk)v$@s0PhFm~xplX_o@p%3<`!$$b2qn42UziISBk$?>5vAo?-=iL-jYreJ5dr`}ML(6%E%jdkP z6&lYVr5x@HSk7ViAKv70U-gRrO)lr%E#7tD4KnYAF1^;&a{-zZ=QXE|3)trV(|a>^ z@7wRq#Pi}x+}FbLneGeuS=HXo+VA~gB>ZnJC=1ME2)8Fz~l zXnt9QRV>*aXN=Bxv+0W&tBf1!YA}t?VL5y;OJ@$#elZ3@-#$mxSKVB8ZSsb6dOL`n zv9u)E>|V_NFe?JVXvv~vqXrK@)1;s+V6S+EjPDDYyPPTW7eo40H=&oOFMGLQabGD{ z4%Kb5&(!+@6Nq!Z@UJ`IY>(b8H?d#Yd-d2{=;VxCz`+CN@)y`E96`sa9r(jR(|rjO z(t(5Sb}D88|roTio~EuSf~lbJ$YMcYWe{=HP7Y(**Ih8As^nJaFw0dhf8ac?uZ3al!XhdO5E)+C_Q6 zq+Cu>QkZ-=p_z_2>&091PXxT1CqqYyOna)xg-uHYyeGJ3;&NW_i(mSKqvq^#1`PYy zbSqFcw&v|tU}t#lmfRjGe}5`&TS4ecXMGJUkt7X|@$M`cOnszg;Y;b8q zKFXgiaCcd^i8;dUAtZzllcQYzpSzdIK!|%TI)BW}{E}|^>zG;nCC#4JWI0RCunJi# zt?;77k2`BX!(;tk%I7cMA<*{{KEWgv|46B9x~wFLSC5-Hzgua8$i6+7E6JqSakGA< zl{av}adUp9mDN9zw6rUkx8-c#+y}?3m++<&63EoeODQ328<89bY z`>h~mqt>Tnp(@6VI`M>Ac7Vqfw9LX2j@jp0%+7jwbKlc)C0%6l|K^0*xQgtLoiMGA zSpEg0b|JDhUbTx{a=%lkg|2$+%NV+}OWpGnE^m=AfZZ+Cb$Xwrw zQ^NSp;8Zhvf3`A)5=q*9rQNv;cSiKb_G>pV$#yzvu6Prl4$S#~`a^K7}>w zY|C(ejGI9?FUX%X!D~>IFHV|e-w@}UlV;y$+&w2v)(uucJw-Tr(kxzs*ZGqs<{+(0 zdbdxS(|B=o4*ih=B{iwnvfuTa3g>unlj?_?)@$)EW$N#-(r1ncsD8>(|D)+mSgM!~ z=VJ4J^^{q*mc(11GUd0i9qWpujw_M1`sE9o{YMy*TIpEG`kpeI4&a#e%Zo1}}bR`IfOz|&D2&52uDw;papHwQEJYj#l8e{~j)4ewp7 zf27J^<=nPK;~6nA-_iy_=ET?J<~9veb;^z0_Bn6zZ$t@V&O1twXr)||a z^Uj*6O=M}eWIFjcVW!~bC(UO+HMG6Garsne7D^sOIn}oTC2qYtuj;7&ZaGPg64)x^ z4$e5^D&A)F^sdO?^zP|I7_HAw{YikHrP;HOGn(W0+cV^oS;>{M8zGI!vds!|HOH>H zJH9m`Qk4+SJlH5vS3NaJxvFEX z?RCWMB;0)6!qztB_BpSbKQ#*c3$8lLaIZ&;?jJfkI+xuQyoko1&bafWQ;)QaYo3>> zDxRi#Gue{@dNq#Qe0IqlCDD8CZoPJWi?uewoH$2nUMwdlPOaPt(``VrW0O-k8r3e$ z-{jqPLO$DG8r*K$w5Hx(%ld~}I%`}=;KCE8J1dF*8x}gPA3dfI{r!{7o6oqaN#*0T zgS{--k8EE5Z%b2%Kc01o!SM9DWUJ8MCm8=xGSX_1ufe+6^>X*)39~&#Q!a0@Vs`Zw ztE48+4sVsp;LHzCmZ8l;ONK;^y*nZWi6=whzS#-p`)+saYUxaRg#q zRlmHn%-l4q8olHy68ilrDyBVn<-Qd^+_0sxvliN>LVIR%wy<$3Xio1(G2_`upvBSm zO!*UROlbYN=S|pwIFqcqfVqpM*>RM~$D1qSpJ=?Rm+JMXYzKZ#UM@bc3OHifuO4m%==!! zDOYU4HR%S$nQRF3O60CH?n)N!?UmdG%}LkUUb%V}=xwvzeJ*BWtxZ{Isfg>&fizjV zyRTmxecC%7u*v+7e=p|!Jx!0poMoDucMfyteWswXD{b86`APAgAME;WK4z92;Zz^; z*jY6@cdUB1&*@U@Z58Fj%_lQZ(dX>yX)n%c4~eVd#2sL9mZ^UTdST<^0Q2~imEO_B ze@Me=a7{$c(d60c+xb1&1luc_tReaXJ5PHltg|SEcst@}4oe|Vx^P8+nRATUI{u@I zyJv9qi(oBIp3zZBoy-%=~RyUu? zaW>~1HzQ9OHNYFy2zheG2~KX#kJW)K2Anw+7Ybd}NU`OPX4 zT7YjthLT_V(Wy&K-1%i28bA*k*d10gnSPfsRe!>q&UF0U^8c%vh4`D1oc1oIb1x_y zTygwdyW?yNG7H0xzq2vxl+?UJ$%U3c%4 zCjSj4%;89+Mf=($O<(o(RE2(ym6(~*v_c~COC)4z%yMqnl-(Dyz7{L7lMp{4k5(UB zc4+0*b+IA8r8G;38_FNs+JD--qQEN)H}w86U#vv1Idg+|f?nnelbRSKCbsRj<)b<5 z0R8xAM^zM8Mx&~mTbTZ7^Yvl?>6s6MxV$NNlTjZ3BfG_>?M+{7=Ow1}Y>?r`Y_&u&d&6P>nSJU~&du}wklzV; z-XHQiA?-}2+cae@tk#*xp~8Vv+h#VK-#0dg=Y2lFiE4jlAhn6QZ6yfxVF$@b{BCt3 zlMe1(o2Ttw32T_yWbmA?6)J`VQ_Ezlpie zUto{8V|9Gqqe1r_r&mmpKbe}JNyQ)k+i|X>8T}`#Cfl|@X?puvPC9e_PpiKFd%jtz zURiQ{!{uzf^Jld6$bJOzOde+ZU5ekze$hFES6u9JxNq_my`9Aw1PSZ(@6+?;s}=1( z4CT#8zAMA@tmiIgMjU&4d7T|E+UfIZY@vW;rocU_@=QaRe)p_8Zu0*4lXaWxNkgzY z)Vlw-=ByQXB%9fD->Q}7?-VRMnlN>#dNzPahABRNaNN^BuSFU5SG>BRRxt*CrQA$ID4KW8^P9$4M~wMV1l z&wen8XCd*x+w}4o_v%+{26&~c7Dt^rh<{2zOD5i^!94Ymk|fLYgHQbH`Pec){AHD6|Du0yx9-%nPp9tfsx~{2 z`)a=Bc>+v^M-~cYO5kMEFJ5Y*JJzWGCruV*&5)2c9yBEmO5Z5?<1|H1rZ#;Mi%yc- z5u36))3S8Km*q<5-uGR%`lb52t{ut7z3g#yDmK~EYio&dh1~GsiAv0yx2SShDK~uC zi9V?-R0{o}2KU;abj?0KUDTE?^O|y>ihDDB^QYf0FTAHS_p*#GJaBc^7cKK<-yDO7 zR*GF0PHpY~=A<1{xYxjH*rDMYiHEM-I?I%JY?bpF)Aup8N;lm}{`l61uT^?@>*Ji< zCr0|0w+D9$Zdd(Sm|5pY-$Rvh#mi void; -}) => ( -

-); +import HistoryItem from "./components/HistoryItem"; +import SearchHeader from "./components/SearchHeader"; +import TimelineView from "./components/TimelineView"; const History = () => { const [searchQuery, setSearchQuery] = useState(""); @@ -38,6 +21,8 @@ const History = () => { searchQuery, 200 ); + const [activeDay, setActiveDay] = useState(); + const [activeHour, setActiveHour] = useState(); const { data, isError, isPending } = useQuery({ queryFn: () => getHistory({ query: debouncedSearchQuery }), @@ -46,7 +31,10 @@ const History = () => { select: (data) => organiseHistory(data), }); - console.log(data); + const availableHours = useMemo( + () => getHoursWithHistory(data, activeDay), + [data, activeDay] + ); if (isError) { return

Unable to display history.

; @@ -54,40 +42,57 @@ const History = () => { return (
-
- +
+
+ + +
{isPending ? ( ) : ( - <> +
+ {Array.from(data.entries()).length === 0 &&

*crickets*

} {Array.from(data.entries()).map(([date, hours]) => ( -
- {/* TODO: Format dates to (Monday, 16-10-2024) or intl */} -

- {format(parseISO(date), "EEEE yyyy-MM-dd")} + { + if (inView) { + setActiveDay(date); + } + }} + > +

+ {format(parseISO(date), "EEEE, yyyy-MM-dd")}

{Array.from(hours.entries()).map(([hour, items]) => ( -
-

+ { + if (inView) { + setActiveHour(hour); + } + }} + > +

{hour}:00

{items.map((item) => ( ))} -

+
))} -

+ ))} - - // <> - // {/* {data.length === 0 &&

Nothing was found.

} */} - // {/* {data.map(({item}) => ( - // - // ))} */} - // +
)}
diff --git a/src/components/History/HistoryItem.tsx b/src/pages/history/components/HistoryItem.tsx similarity index 90% rename from src/components/History/HistoryItem.tsx rename to src/pages/history/components/HistoryItem.tsx index 7ba12f8..e2e13b1 100644 --- a/src/components/History/HistoryItem.tsx +++ b/src/pages/history/components/HistoryItem.tsx @@ -1,10 +1,9 @@ import { format } from "date-fns"; +import ImageFadeIn from "@/src/components/ui/ImageFadeIn"; import type { HistoryItem } from "@/src/services/history"; import { getFavicon } from "@/src/utils"; -import ImageFadeIn from "../ui/ImageFadeIn"; - const formatTime = (timestamp: number) => { const date = new Date(timestamp); return format(date, "HH:mm:ss"); @@ -17,7 +16,7 @@ const HistoryItem = ({ id, url, title, lastVisitTime }: HistoryItem) => ( className="flex cursor-pointer items-start gap-4 rounded-md px-2 py-1.5 outline-none ring-sky-500 duration-150 hover:bg-gray-100 focus:z-10 focus:ring-2 active:scale-95 active:opacity-70" > void; +}; + +const SearchHeader = ({ searchQuery, setSearchQuery }: SearchHeaderProps) => ( +
+

History

+
+ setSearchQuery(event.target.value)} + /> +
+ +
+
+
+); + +export default SearchHeader; diff --git a/src/pages/history/components/TimelineView.tsx b/src/pages/history/components/TimelineView.tsx new file mode 100644 index 0000000..ebe36dc --- /dev/null +++ b/src/pages/history/components/TimelineView.tsx @@ -0,0 +1,58 @@ +import { MoonIcon, SunIcon } from "@heroicons/react/24/solid"; + +import { generateHourArray } from "@/src/services/history/generateHourArray"; +import { cn } from "@/src/utils"; + +type TimelineViewProps = { + /** + * @TODO activeDay is currently not used, but may be in the future. + */ + activeDay?: string; + activeHour?: string; + availableHours: string[]; +}; + +const allHours = generateHourArray(); + +const HourIndicator = ({ + hour, + isAvailable, + isActive, +}: { + hour: string; + isAvailable: boolean; + isActive: boolean; +}) => ( + +); + +const TimelineView = ({ activeHour, availableHours }: TimelineViewProps) => ( +
+ + {allHours.map((hour) => ( + + ))} + +
+); + +export default TimelineView; diff --git a/src/services/history/generateHourArray.ts b/src/services/history/generateHourArray.ts new file mode 100644 index 0000000..6c788e5 --- /dev/null +++ b/src/services/history/generateHourArray.ts @@ -0,0 +1,3 @@ +/** Generates an array that contains ["23", "22" ..., "01", "00"] */ +export const generateHourArray = () => + Array.from({ length: 24 }, (_, i) => i.toString().padStart(2, "0")).reverse(); diff --git a/src/services/history/getHistory.ts b/src/services/history/getHistory.ts index 6251f70..5ff1df3 100644 --- a/src/services/history/getHistory.ts +++ b/src/services/history/getHistory.ts @@ -10,7 +10,11 @@ type GetHistoryParams = { /** * Uses webextension's History API: https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/history * @TODO Implement pagination or sane defaults for maxResults. Now using 1000. - * @TODO Other supported params: https://developer.chrome.com/docs/extensions/reference/api/history#parameters_5 + * Other supported params: https://developer.chrome.com/docs/extensions/reference/api/history#parameters_5 */ export const getHistory = ({ query }: GetHistoryParams) => - history.search({ text: query, maxResults: 1000, startTime: 0 }); + history.search({ + text: query, + maxResults: 1000, + startTime: 0, + }); diff --git a/src/services/history/getHoursWithHistory.ts b/src/services/history/getHoursWithHistory.ts new file mode 100644 index 0000000..61c9494 --- /dev/null +++ b/src/services/history/getHoursWithHistory.ts @@ -0,0 +1,14 @@ +import { OrganisedHistory } from "./types"; + +export const getHoursWithHistory = ( + history?: OrganisedHistory, + activeDay?: string +) => { + if (!history || !activeDay) { + return []; + } + + const hoursMap = history.get(activeDay); + + return hoursMap ? Array.from(hoursMap.keys()) : []; +}; diff --git a/src/services/history/index.ts b/src/services/history/index.ts index c09c2a7..e9cb69b 100644 --- a/src/services/history/index.ts +++ b/src/services/history/index.ts @@ -1,3 +1,4 @@ export * from "./getHistory"; export * from "./types"; export * from "./organiseHistory"; +export * from "./getHoursWithHistory"; diff --git a/src/services/history/organiseHistory.ts b/src/services/history/organiseHistory.ts index 1434a8b..b7694b2 100644 --- a/src/services/history/organiseHistory.ts +++ b/src/services/history/organiseHistory.ts @@ -1,4 +1,3 @@ -/* eslint-disable camelcase */ import { HistoryItem, OrganisedHistory } from "./types"; /** diff --git a/src/services/history/types.ts b/src/services/history/types.ts index bb03aec..abd3952 100644 --- a/src/services/history/types.ts +++ b/src/services/history/types.ts @@ -7,4 +7,10 @@ export interface HistoryItem { typedCount?: number; } -export type OrganisedHistory = Map>; +type historyDate = string; +type historyHour = string; + +export type OrganisedHistory = Map< + historyDate, + Map +>; From c4193cd1817fe95b2c3529d86eae13c82ba2c804 Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Fri, 18 Oct 2024 14:39:43 +0200 Subject: [PATCH 10/18] Bugfix: set active day from hour instead of section of day --- src/pages/history/History.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/pages/history/History.tsx b/src/pages/history/History.tsx index 2b81dc2..76fb87c 100644 --- a/src/pages/history/History.tsx +++ b/src/pages/history/History.tsx @@ -60,15 +60,7 @@ const History = () => {
{Array.from(data.entries()).length === 0 &&

*crickets*

} {Array.from(data.entries()).map(([date, hours]) => ( - { - if (inView) { - setActiveDay(date); - } - }} - > +

{format(parseISO(date), "EEEE, yyyy-MM-dd")}

@@ -79,6 +71,7 @@ const History = () => { onChange={(inView) => { if (inView) { setActiveHour(hour); + setActiveDay(date); } }} > @@ -90,7 +83,7 @@ const History = () => { ))} ))} - +
))}
)} From 659cecf5090dffe622b85954598d7496526ecb9b Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Fri, 18 Oct 2024 14:39:53 +0200 Subject: [PATCH 11/18] Add unit tests for history-related services --- .../history/generateHourArray.test.ts | 36 ++++++++++ .../history/getHoursWithHistory.test.ts | 65 +++++++++++++++++++ src/services/history/organiseHistory.test.ts | 48 ++++++++++++++ src/utils/getFavicon.test.ts | 2 +- 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/services/history/generateHourArray.test.ts create mode 100644 src/services/history/getHoursWithHistory.test.ts create mode 100644 src/services/history/organiseHistory.test.ts diff --git a/src/services/history/generateHourArray.test.ts b/src/services/history/generateHourArray.test.ts new file mode 100644 index 0000000..5b9768a --- /dev/null +++ b/src/services/history/generateHourArray.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; + +import { generateHourArray } from "./generateHourArray"; + +describe("generateHourArray", () => { + it('Generates an array that contains ["23", "22" ..., "01", "00"]', () => { + const result = generateHourArray(); + + expect(result).toEqual([ + "23", + "22", + "21", + "20", + "19", + "18", + "17", + "16", + "15", + "14", + "13", + "12", + "11", + "10", + "09", + "08", + "07", + "06", + "05", + "04", + "03", + "02", + "01", + "00", + ]); + }); +}); diff --git a/src/services/history/getHoursWithHistory.test.ts b/src/services/history/getHoursWithHistory.test.ts new file mode 100644 index 0000000..33eaa73 --- /dev/null +++ b/src/services/history/getHoursWithHistory.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; + +import { getHoursWithHistory } from "./getHoursWithHistory"; +import { OrganisedHistory } from "./types"; + +describe("getHoursWithHistory", () => { + it("should return an empty array if no history is provided", () => { + const result = getHoursWithHistory(undefined, "2024-10-18"); + expect(result).toEqual([]); + }); + + it("should return an empty array if no active day is provided", () => { + const organisedHistory: OrganisedHistory = new Map(); + const result = getHoursWithHistory(organisedHistory, undefined); + expect(result).toEqual([]); + }); + + it("should return an empty array if the active day does not exist in history", () => { + const organisedHistory: OrganisedHistory = new Map([ + [ + "2024-10-18", + new Map([ + ["10", [{ id: "1" }]], + ["11", [{ id: "2" }]], + ]), + ], + ["2024-10-19", new Map([["12", [{ id: "3" }]]])], + ]); + + const result = getHoursWithHistory(organisedHistory, "2024-10-20"); + expect(result).toEqual([]); + }); + + it("should return the correct hours for a valid active day", () => { + const organisedHistory: OrganisedHistory = new Map([ + [ + "2024-10-18", + new Map([ + ["10", [{ id: "1" }]], + ["11", [{ id: "2" }]], + ]), + ], + ["2024-10-19", new Map([["12", [{ id: "3" }]]])], + ]); + + const result = getHoursWithHistory(organisedHistory, "2024-10-18"); + expect(result).toEqual(["10", "11"]); + }); + + it("should return the correct hours for another valid active day", () => { + const organisedHistory: OrganisedHistory = new Map([ + [ + "2024-10-18", + new Map([ + ["10", [{ id: "1" }]], + ["11", [{ id: "2" }]], + ]), + ], + ["2024-10-19", new Map([["12", [{ id: "3" }]]])], + ]); + + const result = getHoursWithHistory(organisedHistory, "2024-10-19"); + expect(result).toEqual(["12"]); + }); +}); diff --git a/src/services/history/organiseHistory.test.ts b/src/services/history/organiseHistory.test.ts new file mode 100644 index 0000000..90aa777 --- /dev/null +++ b/src/services/history/organiseHistory.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from "vitest"; + +import { organiseHistory } from "./organiseHistory"; +import { HistoryItem } from "./types"; + +describe("organiseHistory", () => { + it("should return an empty map when there is no history", () => { + const history: HistoryItem[] = []; + const result = organiseHistory(history); + expect(result.size).toBe(0); + }); + + it("should group history entries across multiple days correctly", () => { + const history: HistoryItem[] = [ + { id: "1", lastVisitTime: new Date("2024-10-17T00:00:00Z").getTime() }, + { id: "2", lastVisitTime: new Date("2024-10-18T00:00:00Z").getTime() }, + { id: "3", lastVisitTime: new Date("2024-10-19T00:00:00Z").getTime() }, + ]; + + const result = organiseHistory(history); + + expect(result.size).toBe(3); + expect(result.has("2024-10-19")).toBe(true); + expect(result.has("2024-10-18")).toBe(true); + expect(result.has("2024-10-17")).toBe(true); + }); + + it("should group multiple entries for a single day correctly", () => { + const history: HistoryItem[] = [ + { id: "1", lastVisitTime: new Date("2024-10-17T00:00:00Z").getTime() }, + { id: "2", lastVisitTime: new Date("2024-10-17T01:00:00Z").getTime() }, + { id: "3", lastVisitTime: new Date("2024-10-17T02:00:00Z").getTime() }, + { id: "4", lastVisitTime: new Date("2024-10-17T02:34:00Z").getTime() }, + ]; + + const result = organiseHistory(history); + + expect(result.size).toBe(1); + expect(result.get("2024-10-17")?.size).toBe(3); + + const hours = result.get("2024-10-17"); + + expect(hours?.has("02")).toBe(true); + expect(hours?.has("03")).toBe(true); + expect(hours?.has("04")).toBe(true); + expect(hours?.get("04")?.length).toBe(2); + }); +}); diff --git a/src/utils/getFavicon.test.ts b/src/utils/getFavicon.test.ts index fe53000..652d980 100644 --- a/src/utils/getFavicon.test.ts +++ b/src/utils/getFavicon.test.ts @@ -5,7 +5,7 @@ import { getFavicon } from "./getFavicon"; describe("getFavicon", () => { it("should generate the correct favicon URL for a given input URL", () => { expect(getFavicon("https://example.com")).toBe( - "https://www.google.com/s2/favicons?domain=https://example.com" + "https://www.google.com/s2/favicons?domain=https://example.com&sz=64" ); }); From 8c2dbe84c30c9999a59b903990166a4bf3654b62 Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Fri, 18 Oct 2024 14:46:08 +0200 Subject: [PATCH 12/18] Fix z-index glitch on image fade in --- src/pages/history/History.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/history/History.tsx b/src/pages/history/History.tsx index 76fb87c..e4ea866 100644 --- a/src/pages/history/History.tsx +++ b/src/pages/history/History.tsx @@ -43,7 +43,7 @@ const History = () => { return (
-
+
Date: Fri, 18 Oct 2024 15:07:56 +0200 Subject: [PATCH 13/18] Extract Hour Indicator component --- .../history/components/HourIndicator.tsx | 27 +++++++++++++++++ src/pages/history/components/TimelineView.tsx | 29 ++----------------- 2 files changed, 29 insertions(+), 27 deletions(-) create mode 100644 src/pages/history/components/HourIndicator.tsx diff --git a/src/pages/history/components/HourIndicator.tsx b/src/pages/history/components/HourIndicator.tsx new file mode 100644 index 0000000..a371b7c --- /dev/null +++ b/src/pages/history/components/HourIndicator.tsx @@ -0,0 +1,27 @@ +import { cn } from "@/src/utils"; + +type HourIndicatorProps = { + hour: string; + isAvailable: boolean; + isActive: boolean; +}; + +const HourIndicator = ({ hour, isAvailable, isActive }: HourIndicatorProps) => ( + +); + +export default HourIndicator; diff --git a/src/pages/history/components/TimelineView.tsx b/src/pages/history/components/TimelineView.tsx index ebe36dc..01a548f 100644 --- a/src/pages/history/components/TimelineView.tsx +++ b/src/pages/history/components/TimelineView.tsx @@ -1,7 +1,8 @@ import { MoonIcon, SunIcon } from "@heroicons/react/24/solid"; import { generateHourArray } from "@/src/services/history/generateHourArray"; -import { cn } from "@/src/utils"; + +import HourIndicator from "./HourIndicator"; type TimelineViewProps = { /** @@ -14,32 +15,6 @@ type TimelineViewProps = { const allHours = generateHourArray(); -const HourIndicator = ({ - hour, - isAvailable, - isActive, -}: { - hour: string; - isAvailable: boolean; - isActive: boolean; -}) => ( - -); - const TimelineView = ({ activeHour, availableHours }: TimelineViewProps) => (
From 77f1a3fbf5da4eab7cc1087afe66ce0e3459d0a0 Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Fri, 18 Oct 2024 16:08:43 +0200 Subject: [PATCH 14/18] Whoops --- src/pages/history/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/history/index.tsx b/src/pages/history/index.tsx index 7847a70..6386cb9 100644 --- a/src/pages/history/index.tsx +++ b/src/pages/history/index.tsx @@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client"; import History from "@/pages/history/History"; import "@/assets/styles/tailwind.css"; -import "@/pages/newtab/index.css"; +import "@/pages/history/index.css"; import { initSentry } from "@/src/services/initSentry"; initSentry(); From 9389a0d54537e394a5cd9dda53d3ff2199bce90f Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Sun, 20 Oct 2024 09:57:32 +0200 Subject: [PATCH 15/18] Show (active) day and days in history header --- src/pages/history/components/TimelineView.tsx | 33 -------- .../components/TimelineView/TimelineView.tsx | 75 +++++++++++++++++++ .../history/components/TimelineView/index.ts | 1 + .../TimelineView/partials/DayButton.tsx | 38 ++++++++++ .../partials}/HourIndicator.tsx | 2 +- .../partials/PaginationButton.tsx | 33 ++++++++ src/services/history/generateDayArray.test.ts | 53 +++++++++++++ src/services/history/generateDayArray.ts | 5 ++ 8 files changed, 206 insertions(+), 34 deletions(-) delete mode 100644 src/pages/history/components/TimelineView.tsx create mode 100644 src/pages/history/components/TimelineView/TimelineView.tsx create mode 100644 src/pages/history/components/TimelineView/index.ts create mode 100644 src/pages/history/components/TimelineView/partials/DayButton.tsx rename src/pages/history/components/{ => TimelineView/partials}/HourIndicator.tsx (93%) create mode 100644 src/pages/history/components/TimelineView/partials/PaginationButton.tsx create mode 100644 src/services/history/generateDayArray.test.ts create mode 100644 src/services/history/generateDayArray.ts diff --git a/src/pages/history/components/TimelineView.tsx b/src/pages/history/components/TimelineView.tsx deleted file mode 100644 index 01a548f..0000000 --- a/src/pages/history/components/TimelineView.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { MoonIcon, SunIcon } from "@heroicons/react/24/solid"; - -import { generateHourArray } from "@/src/services/history/generateHourArray"; - -import HourIndicator from "./HourIndicator"; - -type TimelineViewProps = { - /** - * @TODO activeDay is currently not used, but may be in the future. - */ - activeDay?: string; - activeHour?: string; - availableHours: string[]; -}; - -const allHours = generateHourArray(); - -const TimelineView = ({ activeHour, availableHours }: TimelineViewProps) => ( -
- - {allHours.map((hour) => ( - - ))} - -
-); - -export default TimelineView; diff --git a/src/pages/history/components/TimelineView/TimelineView.tsx b/src/pages/history/components/TimelineView/TimelineView.tsx new file mode 100644 index 0000000..d873cca --- /dev/null +++ b/src/pages/history/components/TimelineView/TimelineView.tsx @@ -0,0 +1,75 @@ +import { MoonIcon, SunIcon } from "@heroicons/react/24/solid"; +import { isSameDay, isToday, subDays } from "date-fns"; +import { useMemo, useState } from "react"; + +import { generateDayArray } from "@/src/services/history/generateDayArray"; +import { generateHourArray } from "@/src/services/history/generateHourArray"; + +import DayButton from "./partials/DayButton"; +import HourIndicator from "./partials/HourIndicator"; +import PaginationButton from "./partials/PaginationButton"; + +type TimelineViewProps = { + activeDay?: string; + activeHour?: string; + availableHours: string[]; +}; + +const allHours = generateHourArray(); +const today = new Date(); + +const TimelineView = ({ + activeHour, + activeDay, + availableHours, +}: TimelineViewProps) => { + const [rangeStart, setRangeStart] = useState(today); + const range = useMemo(() => generateDayArray(rangeStart), [rangeStart]); + + return ( + <> +
+ { + const newDate = subDays(rangeStart, -1); + setRangeStart(newDate); + }} + isDisabled={isToday(rangeStart)} + /> +
+ {range.map((day) => ( + alert(`TODO: Navigate to ${String(day)}`)} + isActive={isSameDay(day, activeDay || "")} + /> + ))} +
+ { + const newDate = subDays(rangeStart, 1); + + setRangeStart(newDate); + }} + /> +
+
+ + {allHours.map((hour) => ( + + ))} + +
+ + ); +}; + +export default TimelineView; diff --git a/src/pages/history/components/TimelineView/index.ts b/src/pages/history/components/TimelineView/index.ts new file mode 100644 index 0000000..d276c60 --- /dev/null +++ b/src/pages/history/components/TimelineView/index.ts @@ -0,0 +1 @@ +export { default } from "./TimelineView"; diff --git a/src/pages/history/components/TimelineView/partials/DayButton.tsx b/src/pages/history/components/TimelineView/partials/DayButton.tsx new file mode 100644 index 0000000..5188139 --- /dev/null +++ b/src/pages/history/components/TimelineView/partials/DayButton.tsx @@ -0,0 +1,38 @@ +import { format, isToday, isYesterday } from "date-fns"; + +import { cn } from "@/src/utils"; + +type DayButtonProps = { + date: Date; + isActive: boolean; + onClick: () => void; +}; + +const DayButton = ({ date, isActive, onClick }: DayButtonProps) => { + const title = (() => { + if (isToday(date)) { + return "today"; + } + + if (isYesterday(date)) { + return "yesterday"; + } + + return format(date, "EEEE"); + })(); + + return ( + + ); +}; + +export default DayButton; diff --git a/src/pages/history/components/HourIndicator.tsx b/src/pages/history/components/TimelineView/partials/HourIndicator.tsx similarity index 93% rename from src/pages/history/components/HourIndicator.tsx rename to src/pages/history/components/TimelineView/partials/HourIndicator.tsx index a371b7c..aabb15c 100644 --- a/src/pages/history/components/HourIndicator.tsx +++ b/src/pages/history/components/TimelineView/partials/HourIndicator.tsx @@ -9,7 +9,7 @@ type HourIndicatorProps = { const HourIndicator = ({ hour, isAvailable, isActive }: HourIndicatorProps) => ( +); + +export default PaginationButton; diff --git a/src/services/history/generateDayArray.test.ts b/src/services/history/generateDayArray.test.ts new file mode 100644 index 0000000..34efc99 --- /dev/null +++ b/src/services/history/generateDayArray.test.ts @@ -0,0 +1,53 @@ +import { isSameDay, subDays } from "date-fns"; +import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; + +import { generateDayArray } from "./generateDayArray"; + +describe("generateDayArray", () => { + // Mock the current date to ensure consistent tests + const mockToday = new Date("2024-10-19"); + + beforeAll(() => { + // Mock the Date constructor to always return our fixed date + vi.useFakeTimers(); + vi.setSystemTime(mockToday); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it("should generate an array of 7 dates", () => { + const result = generateDayArray(mockToday); + expect(result).toHaveLength(7); + expect(result.every((date) => date instanceof Date)).toBe(true); + }); + + it("should generate dates from today backwards to 6 days ago", () => { + const result = generateDayArray(mockToday); + + result.forEach((date, index) => { + const expectedDate = subDays(mockToday, index); + expect(isSameDay(date, expectedDate)).toBe(true); + }); + }); + + it("should have today as the first element", () => { + const result = generateDayArray(mockToday); + expect(isSameDay(result[0], mockToday)).toBe(true); + }); + + it("should have 6 days ago as the last element", () => { + const result = generateDayArray(mockToday); + const sixDaysAgo = subDays(mockToday, 6); + expect(isSameDay(result[6], sixDaysAgo)).toBe(true); + }); + + it("should return dates in descending order", () => { + const result = generateDayArray(mockToday); + + for (let i = 1; i < result.length; i++) { + expect(result[i].getTime()).toBeLessThan(result[i - 1].getTime()); + } + }); +}); diff --git a/src/services/history/generateDayArray.ts b/src/services/history/generateDayArray.ts new file mode 100644 index 0000000..be73d7c --- /dev/null +++ b/src/services/history/generateDayArray.ts @@ -0,0 +1,5 @@ +import { subDays } from "date-fns"; + +/** Generates an array that dates of today to 6 days from now */ +export const generateDayArray = (startingPoint: Date) => + Array.from({ length: 7 }, (_, i) => subDays(startingPoint, i)); From 1909532081b1574e50dd0ee05aabf58220a3f683 Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Sun, 20 Oct 2024 10:57:39 +0200 Subject: [PATCH 16/18] Add placeholder for deleting one hour of history --- src/pages/history/History.tsx | 18 ++++++++++++++---- src/pages/history/components/SearchHeader.tsx | 2 +- .../TimelineView/partials/DayButton.tsx | 4 ++-- .../TimelineView/partials/PaginationButton.tsx | 2 +- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/pages/history/History.tsx b/src/pages/history/History.tsx index e4ea866..3d73671 100644 --- a/src/pages/history/History.tsx +++ b/src/pages/history/History.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { format, parseISO } from "date-fns"; +import { addHours, format, parseISO } from "date-fns"; import { useMemo, useState } from "react"; import { InView } from "react-intersection-observer"; @@ -75,9 +75,19 @@ const History = () => { } }} > -

- {hour}:00 -

+
+

+ {hour}:00 +

+ +
{items.map((item) => ( ))} diff --git a/src/pages/history/components/SearchHeader.tsx b/src/pages/history/components/SearchHeader.tsx index e02be8c..6b0118a 100644 --- a/src/pages/history/components/SearchHeader.tsx +++ b/src/pages/history/components/SearchHeader.tsx @@ -8,7 +8,7 @@ type SearchHeaderProps = { const SearchHeader = ({ searchQuery, setSearchQuery }: SearchHeaderProps) => (

History

-
+
{ -
- {items.map((item) => ( - - ))} - - ))} + {Array.from(hours.entries()).map(([hour, items]) => { + const setAsActive = () => { + setActiveDay(date); + setActiveHour(hour); + }; + + return ( + { + if (inView) { + setAsActive(); + } + }} + > +
+

+ {formatHourDisplay(date, hour)} +

+ +
+ {items.map((item) => ( + + ))} +
+ ); + })} ))}
diff --git a/src/pages/history/components/HistoryItem.tsx b/src/pages/history/components/HistoryItem.tsx index e2e13b1..e8ad4ee 100644 --- a/src/pages/history/components/HistoryItem.tsx +++ b/src/pages/history/components/HistoryItem.tsx @@ -6,6 +6,7 @@ import { getFavicon } from "@/src/utils"; const formatTime = (timestamp: number) => { const date = new Date(timestamp); + return format(date, "HH:mm:ss"); }; diff --git a/src/services/history/organiseHistory.test.ts b/src/services/history/organiseHistory.test.ts index 90aa777..401689a 100644 --- a/src/services/history/organiseHistory.test.ts +++ b/src/services/history/organiseHistory.test.ts @@ -16,9 +16,7 @@ describe("organiseHistory", () => { { id: "2", lastVisitTime: new Date("2024-10-18T00:00:00Z").getTime() }, { id: "3", lastVisitTime: new Date("2024-10-19T00:00:00Z").getTime() }, ]; - const result = organiseHistory(history); - expect(result.size).toBe(3); expect(result.has("2024-10-19")).toBe(true); expect(result.has("2024-10-18")).toBe(true); @@ -32,17 +30,15 @@ describe("organiseHistory", () => { { id: "3", lastVisitTime: new Date("2024-10-17T02:00:00Z").getTime() }, { id: "4", lastVisitTime: new Date("2024-10-17T02:34:00Z").getTime() }, ]; - const result = organiseHistory(history); - expect(result.size).toBe(1); expect(result.get("2024-10-17")?.size).toBe(3); const hours = result.get("2024-10-17"); + expect(hours?.has("00")).toBe(true); + expect(hours?.has("01")).toBe(true); expect(hours?.has("02")).toBe(true); - expect(hours?.has("03")).toBe(true); - expect(hours?.has("04")).toBe(true); - expect(hours?.get("04")?.length).toBe(2); + expect(hours?.get("02")?.length).toBe(2); }); }); diff --git a/src/services/history/organiseHistory.ts b/src/services/history/organiseHistory.ts index b7694b2..51b8d65 100644 --- a/src/services/history/organiseHistory.ts +++ b/src/services/history/organiseHistory.ts @@ -8,7 +8,7 @@ export const organiseHistory = (history: HistoryItem[]): OrganisedHistory => { const grouped = history.reduce((acc, item) => { const date = new Date(item.lastVisitTime || 0); const [dateString] = date.toISOString().split("T"); - const hourString = date.getHours().toString().padStart(2, "0"); + const hourString = date.getUTCHours().toString().padStart(2, "0"); if (!acc.has(dateString)) { acc.set(dateString, new Map()); From b9e48f9a6d5f21c76e06aaf92e8480f157b370e8 Mon Sep 17 00:00:00 2001 From: Arthur Geel Date: Tue, 22 Oct 2024 20:31:11 +0200 Subject: [PATCH 18/18] Moar unit tests! --- src/pages/history/History.tsx | 8 +--- src/utils/date/formatHourDisplay.test.ts | 57 ++++++++++++++++++++++ src/utils/date/formatHourDisplay.ts | 8 ++++ src/utils/date/formatTime.test.ts | 60 ++++++++++++++++++++++++ src/utils/date/formatTime.ts | 7 +++ 5 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 src/utils/date/formatHourDisplay.test.ts create mode 100644 src/utils/date/formatHourDisplay.ts create mode 100644 src/utils/date/formatTime.test.ts create mode 100644 src/utils/date/formatTime.ts diff --git a/src/pages/history/History.tsx b/src/pages/history/History.tsx index d017dba..4bf231e 100644 --- a/src/pages/history/History.tsx +++ b/src/pages/history/History.tsx @@ -10,18 +10,12 @@ import { getHoursWithHistory, organiseHistory, } from "@/src/services/history"; +import { formatHourDisplay } from "@/src/utils/date/formatHourDisplay"; import HistoryItem from "./components/HistoryItem"; import SearchHeader from "./components/SearchHeader"; import TimelineView from "./components/TimelineView"; -const formatHourDisplay = (dateStr: string, hourStr: string) => { - const fullDateTime = `${dateStr}T${hourStr}:00:00Z`; - const date = new Date(fullDateTime); - // Format just the hour in local time - return format(date, "HH:00"); -}; - const History = () => { const [searchQuery, setSearchQuery] = useState(""); const { debouncedValue: debouncedSearchQuery } = useDebounce( diff --git a/src/utils/date/formatHourDisplay.test.ts b/src/utils/date/formatHourDisplay.test.ts new file mode 100644 index 0000000..05223a6 --- /dev/null +++ b/src/utils/date/formatHourDisplay.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; + +import { formatHourDisplay } from "./formatHourDisplay"; + +const originalTimezone = process.env.TZ; + +// Test cases in different timezones +const timezones = ["UTC", "America/New_York", "Asia/Tokyo", "Europe/London"]; + +describe("Time formatting functions", () => { + describe("formatHourDisplay", () => { + for (const timezone of timezones) { + describe(`in ${timezone} timezone`, () => { + beforeAll(() => { + process.env.TZ = timezone; + }); + + afterAll(() => { + process.env.TZ = originalTimezone; + }); + + it("should format midnight UTC correctly", () => { + const result = formatHourDisplay("2024-01-01", "00"); + expect(result).toMatch(/^\d{2}:00$/); + }); + + it("should format noon UTC correctly", () => { + const result = formatHourDisplay("2024-01-01", "12"); + expect(result).toMatch(/^\d{2}:00$/); + }); + + it("should format evening UTC correctly", () => { + const result = formatHourDisplay("2024-01-01", "20"); + expect(result).toMatch(/^\d{2}:00$/); + }); + + it("should handle single-digit hours correctly", () => { + const result = formatHourDisplay("2024-01-01", "05"); + expect(result).toMatch(/^\d{2}:00$/); + }); + + it("should handle date transitions correctly", () => { + const result = formatHourDisplay("2024-01-01", "23"); + expect(result).toMatch(/^\d{2}:00$/); + }); + }); + } + + it("should handle invalid date strings gracefully", () => { + expect(() => formatHourDisplay("invalid-date", "12")).toThrow(); + }); + + it("should handle invalid hour strings gracefully", () => { + expect(() => formatHourDisplay("2024-01-01", "25")).toThrow(); + }); + }); +}); diff --git a/src/utils/date/formatHourDisplay.ts b/src/utils/date/formatHourDisplay.ts new file mode 100644 index 0000000..45164ec --- /dev/null +++ b/src/utils/date/formatHourDisplay.ts @@ -0,0 +1,8 @@ +import { format } from "date-fns"; + +export const formatHourDisplay = (dateStr: string, hourStr: string) => { + const fullDateTime = `${dateStr}T${hourStr}:00:00Z`; + const date = new Date(fullDateTime); + + return format(date, "HH:00"); +}; diff --git a/src/utils/date/formatTime.test.ts b/src/utils/date/formatTime.test.ts new file mode 100644 index 0000000..6cf015c --- /dev/null +++ b/src/utils/date/formatTime.test.ts @@ -0,0 +1,60 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { formatTime } from "./formatTime"; + +const originalTimezone = process.env.TZ; +const timezones = ["UTC", "America/New_York", "Asia/Tokyo", "Europe/London"]; + +describe("formatTime", () => { + for (const timezone of timezones) { + describe(`in ${timezone} timezone`, () => { + beforeAll(() => { + process.env.TZ = timezone; + }); + + afterAll(() => { + process.env.TZ = originalTimezone; + }); + + it("should format midnight correctly", () => { + const midnight = new Date("2024-01-01T00:00:00Z").getTime(); + const result = formatTime(midnight); + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); + + it("should format noon correctly", () => { + const noon = new Date("2024-01-01T12:00:00Z").getTime(); + const result = formatTime(noon); + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); + + it("should format time with seconds correctly", () => { + const timeWithSeconds = new Date("2024-01-01T12:34:56Z").getTime(); + const result = formatTime(timeWithSeconds); + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); + + it("should handle millisecond precision", () => { + const timeWithMs = new Date("2024-01-01T12:34:56.789Z").getTime(); + const result = formatTime(timeWithMs); + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); + }); + } + + it("should handle invalid timestamps gracefully", () => { + expect(() => formatTime(NaN)).toThrow(); + }); + + it("should handle very large timestamps", () => { + const farFuture = new Date("2100-01-01T00:00:00Z").getTime(); + const result = formatTime(farFuture); + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); + + it("should handle very small timestamps", () => { + const farPast = new Date("1900-01-01T00:00:00Z").getTime(); + const result = formatTime(farPast); + expect(result).toMatch(/^\d{2}:\d{2}:\d{2}$/); + }); +}); diff --git a/src/utils/date/formatTime.ts b/src/utils/date/formatTime.ts new file mode 100644 index 0000000..3399ea8 --- /dev/null +++ b/src/utils/date/formatTime.ts @@ -0,0 +1,7 @@ +import { format } from "date-fns"; + +export const formatTime = (timestamp: number) => { + const date = new Date(timestamp); + + return format(date, "HH:mm:ss"); +};