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/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..57aa703
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,11 @@
+# What has been done
+
+-
+
+## To do
+
+- [ ]
+
+## Notes
+
+-
diff --git a/.github/templates/pull_request_template.md b/.github/templates/pull_request_template.md
deleted file mode 100644
index b535e13..0000000
--- a/.github/templates/pull_request_template.md
+++ /dev/null
@@ -1,13 +0,0 @@
-# This release contains:
-
-## Updates:
-
--
-
-## Bugfixes:
-
--
-
-## Others:
-
--
diff --git a/bun.lockb b/bun.lockb
index 6c0ddc5..96106ed 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 31e7b78..9a717f2 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.50.1",
+ "react-intersection-observer": "^9.13.1",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^10.0.0",
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/Homescreen/Homescreen.tsx b/src/components/Homescreen/Homescreen.tsx
index afc822f..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();
@@ -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) {
@@ -30,8 +33,8 @@ const Homescreen = () => {
isExpanded={!!isSidebarOpen}
setIsExpanded={() => toggleSidebarSetting("isOpen")}
/>
- {
)}
/>
)}
-
+
>
);
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/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/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}
+ />
);
};
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) => (
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/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/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/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..4bf231e
--- /dev/null
+++ b/src/pages/history/History.tsx
@@ -0,0 +1,113 @@
+import { useQuery } from "@tanstack/react-query";
+import { format, parseISO } from "date-fns";
+import { useMemo, useState } from "react";
+import { InView } from "react-intersection-observer";
+
+import Spinner from "@/src/components/ui/Spinner";
+import { useDebounce } from "@/src/hooks/useDebounce";
+import {
+ getHistory,
+ 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 History = () => {
+ const [searchQuery, setSearchQuery] = useState("");
+ const { debouncedValue: debouncedSearchQuery } = useDebounce(
+ searchQuery,
+ 200
+ );
+ const [activeDay, setActiveDay] = useState();
+ const [activeHour, setActiveHour] = useState();
+
+ const { data, isError, isPending } = useQuery({
+ queryFn: () => getHistory({ query: debouncedSearchQuery }),
+ queryKey: ["changelog", debouncedSearchQuery],
+ placeholderData: (previousData) => previousData,
+ select: (data) => organiseHistory(data),
+ });
+
+ const availableHours = useMemo(
+ () => getHoursWithHistory(data, activeDay),
+ [data, activeDay]
+ );
+
+ if (isError) {
+ return Unable to display history.
;
+ }
+
+ return (
+
+
+
+
+
+
+ {isPending ? (
+
+ ) : (
+
+ {Array.from(data.entries()).length === 0 &&
*crickets*
}
+ {Array.from(data.entries()).map(([date, hours]) => (
+
+
+ {format(parseISO(date), "EEEE, yyyy-MM-dd")}
+
+ {Array.from(hours.entries()).map(([hour, items]) => {
+ const setAsActive = () => {
+ setActiveDay(date);
+ setActiveHour(hour);
+ };
+
+ return (
+ {
+ if (inView) {
+ setAsActive();
+ }
+ }}
+ >
+
+
+ {formatHourDisplay(date, hour)}
+
+
+
+ {items.map((item) => (
+
+ ))}
+
+ );
+ })}
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default History;
diff --git a/src/pages/history/components/HistoryItem.tsx b/src/pages/history/components/HistoryItem.tsx
new file mode 100644
index 0000000..e8ad4ee
--- /dev/null
+++ b/src/pages/history/components/HistoryItem.tsx
@@ -0,0 +1,35 @@
+import { format } from "date-fns";
+
+import ImageFadeIn from "@/src/components/ui/ImageFadeIn";
+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/pages/history/components/SearchHeader.tsx b/src/pages/history/components/SearchHeader.tsx
new file mode 100644
index 0000000..6b0118a
--- /dev/null
+++ b/src/pages/history/components/SearchHeader.tsx
@@ -0,0 +1,26 @@
+import { MagnifyingGlassIcon } from "@heroicons/react/24/solid";
+
+type SearchHeaderProps = {
+ searchQuery: string;
+ setSearchQuery: (value: string) => void;
+};
+
+const SearchHeader = ({ searchQuery, setSearchQuery }: SearchHeaderProps) => (
+
+
History
+
+
setSearchQuery(event.target.value)}
+ />
+
+
+
+
+
+);
+
+export default SearchHeader;
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..5a70c33
--- /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/TimelineView/partials/HourIndicator.tsx b/src/pages/history/components/TimelineView/partials/HourIndicator.tsx
new file mode 100644
index 0000000..aabb15c
--- /dev/null
+++ b/src/pages/history/components/TimelineView/partials/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/partials/PaginationButton.tsx b/src/pages/history/components/TimelineView/partials/PaginationButton.tsx
new file mode 100644
index 0000000..f4a8601
--- /dev/null
+++ b/src/pages/history/components/TimelineView/partials/PaginationButton.tsx
@@ -0,0 +1,33 @@
+import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/solid";
+
+import { cn } from "@/src/utils";
+
+type PaginationButtonProps = {
+ icon: "left" | "right";
+ onClick: () => void;
+ isDisabled?: boolean;
+};
+
+const PaginationButton = ({
+ icon,
+ onClick,
+ isDisabled,
+}: PaginationButtonProps) => (
+
+);
+
+export default PaginationButton;
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..6386cb9
--- /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/history/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/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/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));
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/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
new file mode 100644
index 0000000..5ff1df3
--- /dev/null
+++ b/src/services/history/getHistory.ts
@@ -0,0 +1,20 @@
+import { history } from "webextension-polyfill";
+
+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.
+ * 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/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/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
new file mode 100644
index 0000000..e9cb69b
--- /dev/null
+++ b/src/services/history/index.ts
@@ -0,0 +1,4 @@
+export * from "./getHistory";
+export * from "./types";
+export * from "./organiseHistory";
+export * from "./getHoursWithHistory";
diff --git a/src/services/history/organiseHistory.test.ts b/src/services/history/organiseHistory.test.ts
new file mode 100644
index 0000000..401689a
--- /dev/null
+++ b/src/services/history/organiseHistory.test.ts
@@ -0,0 +1,44 @@
+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("00")).toBe(true);
+ expect(hours?.has("01")).toBe(true);
+ expect(hours?.has("02")).toBe(true);
+ expect(hours?.get("02")?.length).toBe(2);
+ });
+});
diff --git a/src/services/history/organiseHistory.ts b/src/services/history/organiseHistory.ts
new file mode 100644
index 0000000..51b8d65
--- /dev/null
+++ b/src/services/history/organiseHistory.ts
@@ -0,0 +1,40 @@
+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.getUTCHours().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..abd3952
--- /dev/null
+++ b/src/services/history/types.ts
@@ -0,0 +1,16 @@
+export interface HistoryItem {
+ id: string;
+ url?: string;
+ title?: string | null;
+ lastVisitTime?: number; // Milliseconds since the epoch
+ visitCount?: number;
+ typedCount?: number;
+}
+
+type historyDate = string;
+type historyHour = string;
+
+export type OrganisedHistory = Map<
+ historyDate,
+ Map
+>;
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/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");
+};
diff --git a/src/utils/getFavicon.test.ts b/src/utils/getFavicon.test.ts
index 4a40f26..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"
);
});
@@ -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`;
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";
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`,