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`,