diff --git a/src/components/questions/QuestionsTable.tsx b/src/components/questions/QuestionsTable.tsx index 11dc6ddc..ee020d07 100644 --- a/src/components/questions/QuestionsTable.tsx +++ b/src/components/questions/QuestionsTable.tsx @@ -1,24 +1,23 @@ "use client"; -import { useState, useMemo, useCallback, useEffect, useRef, useSyncExternalStore, type ReactNode } from "react"; +import { useState, useMemo, useCallback, useEffect, useRef } from "react"; import { useSearchParams } from "next/navigation"; import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, - createColumnHelper, flexRender, type SortingState, type ColumnFiltersState, } from "@tanstack/react-table"; import { Question } from "@/types/question"; -import { ExternalLink, Star, Check } from "lucide-react"; import { trackEvent } from "@/lib/analytics"; -import FormattedNote from "@/components/questions/FormattedNote"; import { loadCompleted, saveCompleted, loadStarred, saveStarred, loadNotes, saveNotes, loadSolvedDates, saveSolvedDates, loadShuffleOrder, saveShuffleOrder, migrateLegacyProgress, loadReminders, saveReminders } from "@/lib/storage"; import { useAuth } from "@/components/layout/AuthContext"; import { type Reminder, isDue, setCustomDate as setCustomReviewDate } from "@/lib/reminders"; +import { useIsMobile } from "@/lib/useIsMobile"; +import { parseInitialQuestionTableFilters } from "@/lib/parseQuestionTableSearchParams"; import ProgressBar, { type ProgressStats } from "./ProgressBar"; import FilterToolbar from "./FilterToolbar"; import NoteModal, { type EditingNote } from "./NoteModal"; @@ -26,398 +25,7 @@ import ConfirmModal from "./ConfirmModal"; import GroupHeaderRow from "./GroupHeaderRow"; import QuestionRow from "./QuestionRow"; import ReviewDateModal, { type ReviewDateTarget } from "./ReviewDateModal"; - -const columnHelper = createColumnHelper(); - -const difficultyColor: Record = { - Easy: "text-green-700 dark:text-green-400", - Medium: "text-yellow-700 dark:text-yellow-400", - Hard: "text-red-700 dark:text-red-400", -}; - -const difficultyPill: Record = { - Easy: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400", - Medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400", - Hard: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400", -}; - -const makeColumns = ( - completed: Set, - toggleCompleted: (id: number) => void, - starred: Set, - toggleStarred: (id: number) => void, - notes: Record, - openNoteModal: (id: number, title: string) => void, - hidePatterns: boolean, - companyFilter: string[], - updatedDate: string, - solvedDates: Record, - reminders: Record, - openReviewModal: (id: number, title: string) => void, - completeReminder: (id: number) => void -) => [ - columnHelper.display({ - id: "completed", - header: () => , - size: 40, - cell: (info) => ( - toggleCompleted(info.row.original.id)} - className="h-4 w-4 pointer-events-none accent-blue-600" - aria-label={`Mark ${info.row.original.title} as ${completed.has(info.row.original.id) ? "incomplete" : "complete"}`} - /> - ), - meta: { clickable: true }, - }), - columnHelper.display({ - id: "starred", - header: "โ˜…", - size: 40, - cell: (info) => ( - - - - ), - meta: { clickable: true, toggleFn: "starred" }, - enableSorting: false, - }), - columnHelper.accessor("title", { - header: "Title", - cell: (info) => ( - - {info.getValue()} - {info.row.original.premium && ( - ๐Ÿ”’ - )} - - ), - }), - columnHelper.display({ - id: "solutions", - header: "Solutions", - size: 75, - cell: (info) => ( - - - - ), - enableSorting: false, - meta: { hideOnMobile: true }, - }), - columnHelper.accessor("difficulty", { - header: "Difficulty", - cell: (info) => ( - - {info.getValue()} - - ), - filterFn: (row, _columnId, filterValue: string[]) => { - if (!filterValue || filterValue.length === 0) return true; - return filterValue.includes(row.original.difficulty); - }, - meta: { hideOnMobile: true }, - }), - columnHelper.accessor("pattern", { - header: "Pattern(s)", - cell: (info) => ( -
- {info.getValue().map((p) => ( - - {hidePatterns ? "โ€ข".repeat(p.length) : p} - - ))} -
- ), - filterFn: (row, _columnId, filterValue: string[]) => { - if (!filterValue || filterValue.length === 0) return true; - return row.original.pattern.some((p) => - filterValue.some((f) => p.toLowerCase() === f.toLowerCase()) - ); - }, - }), - columnHelper.accessor("companies", { - header: () => ( -
- Companies -
- 0โ€“6 months, via{" "} - - LC Premium - - {", "} - {new Date(updatedDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} -
-
- ), - meta: { hideOnMobile: true }, - cell: (info) => ( -
- {info.getValue().map((c) => ( - - {/* eslint-disable-next-line @next/next/no-img-element */} - {c.name} { - const img = e.target as HTMLImageElement; - const fallback = `https://www.google.com/s2/favicons?sz=64&domain_url=https://${c.slug}.com`; - if (!img.dataset.triedFallback) { - img.dataset.triedFallback = "1"; - img.src = fallback; - } else { - img.style.display = "none"; - img.nextElementSibling?.classList.remove("hidden"); - } - }} - /> - - {c.name} - - - {c.name} - asked {c.frequency} {c.frequency === 1 ? "time" : "times"} in the last 6 months - - - ))} -
- ), - enableSorting: companyFilter.length === 1, - sortingFn: (rowA, rowB) => { - const slug = companyFilter[0]; - const freqA = rowA.original.companies.find((c) => c.slug === slug)?.frequency ?? 0; - const freqB = rowB.original.companies.find((c) => c.slug === slug)?.frequency ?? 0; - return freqA - freqB; - }, - filterFn: (row, _columnId, filterValue: string[]) => { - if (!filterValue || filterValue.length === 0) return true; - return row.original.companies.some( - (c) => filterValue.includes(c.slug) - ); - }, - }), - columnHelper.display({ - id: "notes", - header: "Notes", - size: 100, - meta: { hideOnMobile: true, noStrikethrough: true }, - cell: (info) => { - const note = notes[info.row.original.id]; - return ( - - - - ); - }, - enableSorting: false, - }), - columnHelper.display({ - id: "review", - header: "Review", - size: 160, - meta: { hideOnMobile: true, noStrikethrough: true }, - cell: (info) => { - const solvedDate = solvedDates[info.row.original.id]; - const reminder = reminders[info.row.original.id]; - if (!solvedDate && !reminder) return โ€”; - const dateFmt = { month: "short" as const, day: "numeric" as const, year: "numeric" as const }; - return ( -
- {solvedDate && ( - - Solved {relativeDate(solvedDate, "past")} - - )} - {reminder ? ( - - - - - - ) : solvedDate && ( - - )} -
- ); - }, - enableSorting: false, - }), -]; - -function daysDiff(isoDate: string): number { - const todayStr = new Date().toISOString().slice(0, 10); - const target = isoDate.slice(0, 10); - const todayMs = new Date(todayStr + "T00:00:00Z").getTime(); - const targetMs = new Date(target + "T00:00:00Z").getTime(); - return Math.round((targetMs - todayMs) / 86_400_000); -} - -function reviewPillStyle(isoDate: string): string { - const diff = daysDiff(isoDate); - if (diff < 0) return "bg-red-100 text-red-700 ring-red-200 hover:bg-red-200 dark:bg-red-900/40 dark:text-red-400 dark:ring-red-800 dark:hover:bg-red-900/60"; - if (diff === 0) return "bg-orange-100 text-orange-700 ring-orange-200 hover:bg-orange-200 dark:bg-orange-900/40 dark:text-orange-400 dark:ring-orange-800 dark:hover:bg-orange-900/60"; - if (diff === 1) return "bg-amber-100 text-amber-700 ring-amber-200 hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-400 dark:ring-amber-800 dark:hover:bg-amber-900/60"; - if (diff <= 3) return "bg-yellow-100 text-yellow-700 ring-yellow-200 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:text-yellow-400 dark:ring-yellow-800 dark:hover:bg-yellow-900/60"; - if (diff <= 7) return "bg-lime-100 text-lime-700 ring-lime-200 hover:bg-lime-200 dark:bg-lime-900/40 dark:text-lime-400 dark:ring-lime-800 dark:hover:bg-lime-900/60"; - if (diff <= 14) return "bg-emerald-100 text-emerald-700 ring-emerald-200 hover:bg-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-400 dark:ring-emerald-800 dark:hover:bg-emerald-900/60"; - if (diff <= 30) return "bg-cyan-100 text-cyan-700 ring-cyan-200 hover:bg-cyan-200 dark:bg-cyan-900/40 dark:text-cyan-400 dark:ring-cyan-800 dark:hover:bg-cyan-900/60"; - return "bg-zinc-100 text-zinc-600 ring-zinc-200 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:ring-zinc-700 dark:hover:bg-zinc-700"; -} - -function relativeDate(isoDate: string, mode: "past" | "future"): string { - const diffDays = daysDiff(isoDate); - if (mode === "past") { - const ago = -diffDays; - if (ago <= 0) return "today"; - if (ago === 1) return "yesterday"; - return `${ago}d ago`; - } - if (diffDays < 0) return `Overdue ${-diffDays}d`; - if (diffDays === 0) return "Due today"; - if (diffDays === 1) return "Review tomorrow"; - return `Review in ${diffDays}d`; -} - -const mobileQuery = "(max-width: 639px)"; - -function NoteCell({ note, children }: { note: string | undefined; children: ReactNode }) { - const [rect, setRect] = useState(null); - const ref = useRef(null); - const hideTimeout = useRef>(undefined); - - const show = useCallback(() => { - clearTimeout(hideTimeout.current); - if (!note || !ref.current) return; - setRect(ref.current.getBoundingClientRect()); - }, [note]); - - const hide = useCallback(() => { - hideTimeout.current = setTimeout(() => setRect(null), 100); - }, []); - - useEffect(() => () => clearTimeout(hideTimeout.current), []); - - const style = useMemo(() => { - if (!rect) return undefined; - const gap = 8; - const spaceAbove = rect.top - gap; - const spaceBelow = window.innerHeight - rect.bottom - gap; - const above = spaceAbove >= Math.min(200, spaceBelow); - const maxH = Math.min(300, above ? spaceAbove : spaceBelow); - return { - left: rect.left, - maxHeight: maxH, - ...(above - ? { bottom: window.innerHeight - rect.top + gap } - : { top: rect.bottom + gap }), - }; - }, [rect]); - - return ( -
- {children} - {note && style && ( -
- -
- )} -
- ); -} - -function subscribeMobile(callback: () => void) { - const mq = window.matchMedia(mobileQuery); - mq.addEventListener("change", callback); - return () => mq.removeEventListener("change", callback); -} - -function getMobileSnapshot() { - return window.matchMedia(mobileQuery).matches; -} - -function getMobileServerSnapshot() { - return false; -} - -function useIsMobile() { - return useSyncExternalStore(subscribeMobile, getMobileSnapshot, getMobileServerSnapshot); -} - -function parseInitialFilters(searchParams: URLSearchParams) { - const filters: ColumnFiltersState = []; - const difficulty = searchParams.get("difficulty"); - if (difficulty) filters.push({ id: "difficulty", value: difficulty.split(",") }); - const pattern = searchParams.get("pattern"); - if (pattern) filters.push({ id: "pattern", value: pattern.split(",") }); - const companies = searchParams.get("companies"); - if (companies) filters.push({ id: "companies", value: companies.split(",") }); - return filters; -} +import { makeColumns, difficultyColor } from "./questionsTableColumns"; export default function QuestionsTable({ data, updatedDate }: { data: Question[]; updatedDate: string }) { const isMobile = useIsMobile(); @@ -426,7 +34,7 @@ export default function QuestionsTable({ data, updatedDate }: { data: Question[] const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState(() => - parseInitialFilters(searchParams) + parseInitialQuestionTableFilters(searchParams) ); const [globalFilter, setGlobalFilter] = useState( () => searchParams.get("q") ?? "" diff --git a/src/components/questions/questionsTableColumns.tsx b/src/components/questions/questionsTableColumns.tsx new file mode 100644 index 00000000..09597f01 --- /dev/null +++ b/src/components/questions/questionsTableColumns.tsx @@ -0,0 +1,371 @@ +"use client"; + +import { useState, useMemo, useCallback, useEffect, useRef, type ReactNode } from "react"; +import { createColumnHelper } from "@tanstack/react-table"; +import { ExternalLink, Star, Check } from "lucide-react"; +import FormattedNote from "@/components/questions/FormattedNote"; +import { type Reminder } from "@/lib/reminders"; +import { Question } from "@/types/question"; + +const columnHelper = createColumnHelper(); + +export const difficultyColor: Record = { + Easy: "text-green-700 dark:text-green-400", + Medium: "text-yellow-700 dark:text-yellow-400", + Hard: "text-red-700 dark:text-red-400", +}; + +const difficultyPill: Record = { + Easy: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400", + Medium: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400", + Hard: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400", +}; + +export function makeColumns( + completed: Set, + toggleCompleted: (id: number) => void, + starred: Set, + toggleStarred: (id: number) => void, + notes: Record, + openNoteModal: (id: number, title: string) => void, + hidePatterns: boolean, + companyFilter: string[], + updatedDate: string, + solvedDates: Record, + reminders: Record, + openReviewModal: (id: number, title: string) => void, + completeReminder: (id: number) => void +) { + return [ + columnHelper.display({ + id: "completed", + header: () => , + size: 40, + cell: (info) => ( + toggleCompleted(info.row.original.id)} + className="h-4 w-4 pointer-events-none accent-blue-600" + aria-label={`Mark ${info.row.original.title} as ${completed.has(info.row.original.id) ? "incomplete" : "complete"}`} + /> + ), + meta: { clickable: true }, + }), + columnHelper.display({ + id: "starred", + header: "โ˜…", + size: 40, + cell: (info) => ( + + + + ), + meta: { clickable: true, toggleFn: "starred" }, + enableSorting: false, + }), + columnHelper.accessor("title", { + header: "Title", + cell: (info) => ( + + {info.getValue()} + {info.row.original.premium && ( + ๐Ÿ”’ + )} + + ), + }), + columnHelper.display({ + id: "solutions", + header: "Solutions", + size: 75, + cell: (info) => ( + + + + ), + enableSorting: false, + meta: { hideOnMobile: true }, + }), + columnHelper.accessor("difficulty", { + header: "Difficulty", + cell: (info) => ( + + {info.getValue()} + + ), + filterFn: (row, _columnId, filterValue: string[]) => { + if (!filterValue || filterValue.length === 0) return true; + return filterValue.includes(row.original.difficulty); + }, + meta: { hideOnMobile: true }, + }), + columnHelper.accessor("pattern", { + header: "Pattern(s)", + cell: (info) => ( +
+ {info.getValue().map((p) => ( + + {hidePatterns ? "โ€ข".repeat(p.length) : p} + + ))} +
+ ), + filterFn: (row, _columnId, filterValue: string[]) => { + if (!filterValue || filterValue.length === 0) return true; + return row.original.pattern.some((p) => + filterValue.some((f) => p.toLowerCase() === f.toLowerCase()) + ); + }, + }), + columnHelper.accessor("companies", { + header: () => ( +
+ Companies +
+ 0โ€“6 months, via{" "} + + LC Premium + + {", "} + {new Date(updatedDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} +
+
+ ), + meta: { hideOnMobile: true }, + cell: (info) => ( +
+ {info.getValue().map((c) => ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {c.name} { + const img = e.target as HTMLImageElement; + const fallback = `https://www.google.com/s2/favicons?sz=64&domain_url=https://${c.slug}.com`; + if (!img.dataset.triedFallback) { + img.dataset.triedFallback = "1"; + img.src = fallback; + } else { + img.style.display = "none"; + img.nextElementSibling?.classList.remove("hidden"); + } + }} + /> + + {c.name} + + + {c.name} - asked {c.frequency} {c.frequency === 1 ? "time" : "times"} in the last 6 months + + + ))} +
+ ), + enableSorting: companyFilter.length === 1, + sortingFn: (rowA, rowB) => { + const slug = companyFilter[0]; + const freqA = rowA.original.companies.find((c) => c.slug === slug)?.frequency ?? 0; + const freqB = rowB.original.companies.find((c) => c.slug === slug)?.frequency ?? 0; + return freqA - freqB; + }, + filterFn: (row, _columnId, filterValue: string[]) => { + if (!filterValue || filterValue.length === 0) return true; + return row.original.companies.some( + (c) => filterValue.includes(c.slug) + ); + }, + }), + columnHelper.display({ + id: "notes", + header: "Notes", + size: 100, + meta: { hideOnMobile: true, noStrikethrough: true }, + cell: (info) => { + const note = notes[info.row.original.id]; + return ( + + + + ); + }, + enableSorting: false, + }), + columnHelper.display({ + id: "review", + header: "Review", + size: 160, + meta: { hideOnMobile: true, noStrikethrough: true }, + cell: (info) => { + const solvedDate = solvedDates[info.row.original.id]; + const reminder = reminders[info.row.original.id]; + if (!solvedDate && !reminder) return โ€”; + const dateFmt = { month: "short" as const, day: "numeric" as const, year: "numeric" as const }; + return ( +
+ {solvedDate && ( + + Solved {relativeDate(solvedDate, "past")} + + )} + {reminder ? ( + + + + + + ) : solvedDate && ( + + )} +
+ ); + }, + enableSorting: false, + }), + ]; +} + +function daysDiff(isoDate: string): number { + const todayStr = new Date().toISOString().slice(0, 10); + const target = isoDate.slice(0, 10); + const todayMs = new Date(todayStr + "T00:00:00Z").getTime(); + const targetMs = new Date(target + "T00:00:00Z").getTime(); + return Math.round((targetMs - todayMs) / 86_400_000); +} + +function reviewPillStyle(isoDate: string): string { + const diff = daysDiff(isoDate); + if (diff < 0) return "bg-red-100 text-red-700 ring-red-200 hover:bg-red-200 dark:bg-red-900/40 dark:text-red-400 dark:ring-red-800 dark:hover:bg-red-900/60"; + if (diff === 0) return "bg-orange-100 text-orange-700 ring-orange-200 hover:bg-orange-200 dark:bg-orange-900/40 dark:text-orange-400 dark:ring-orange-800 dark:hover:bg-orange-900/60"; + if (diff === 1) return "bg-amber-100 text-amber-700 ring-amber-200 hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-400 dark:ring-amber-800 dark:hover:bg-amber-900/60"; + if (diff <= 3) return "bg-yellow-100 text-yellow-700 ring-yellow-200 hover:bg-yellow-200 dark:bg-yellow-900/40 dark:text-yellow-400 dark:ring-yellow-800 dark:hover:bg-yellow-900/60"; + if (diff <= 7) return "bg-lime-100 text-lime-700 ring-lime-200 hover:bg-lime-200 dark:bg-lime-900/40 dark:text-lime-400 dark:ring-lime-800 dark:hover:bg-lime-900/60"; + if (diff <= 14) return "bg-emerald-100 text-emerald-700 ring-emerald-200 hover:bg-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-400 dark:ring-emerald-800 dark:hover:bg-emerald-900/60"; + if (diff <= 30) return "bg-cyan-100 text-cyan-700 ring-cyan-200 hover:bg-cyan-200 dark:bg-cyan-900/40 dark:text-cyan-400 dark:ring-cyan-800 dark:hover:bg-cyan-900/60"; + return "bg-zinc-100 text-zinc-600 ring-zinc-200 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:ring-zinc-700 dark:hover:bg-zinc-700"; +} + +function relativeDate(isoDate: string, mode: "past" | "future"): string { + const diffDays = daysDiff(isoDate); + if (mode === "past") { + const ago = -diffDays; + if (ago <= 0) return "today"; + if (ago === 1) return "yesterday"; + return `${ago}d ago`; + } + if (diffDays < 0) return `Overdue ${-diffDays}d`; + if (diffDays === 0) return "Due today"; + if (diffDays === 1) return "Review tomorrow"; + return `Review in ${diffDays}d`; +} + +function NoteCell({ note, children }: { note: string | undefined; children: ReactNode }) { + const [rect, setRect] = useState(null); + const ref = useRef(null); + const hideTimeout = useRef>(undefined); + + const show = useCallback(() => { + clearTimeout(hideTimeout.current); + if (!note || !ref.current) return; + setRect(ref.current.getBoundingClientRect()); + }, [note]); + + const hide = useCallback(() => { + hideTimeout.current = setTimeout(() => setRect(null), 100); + }, []); + + useEffect(() => () => clearTimeout(hideTimeout.current), []); + + const style = useMemo(() => { + if (!rect) return undefined; + const gap = 8; + const spaceAbove = rect.top - gap; + const spaceBelow = window.innerHeight - rect.bottom - gap; + const above = spaceAbove >= Math.min(200, spaceBelow); + const maxH = Math.min(300, above ? spaceAbove : spaceBelow); + return { + left: rect.left, + maxHeight: maxH, + ...(above + ? { bottom: window.innerHeight - rect.top + gap } + : { top: rect.bottom + gap }), + }; + }, [rect]); + + return ( +
+ {children} + {note && style && ( +
+ +
+ )} +
+ ); +} diff --git a/src/lib/parseQuestionTableSearchParams.ts b/src/lib/parseQuestionTableSearchParams.ts new file mode 100644 index 00000000..d42fdb67 --- /dev/null +++ b/src/lib/parseQuestionTableSearchParams.ts @@ -0,0 +1,12 @@ +import type { ColumnFiltersState } from "@tanstack/react-table"; + +export function parseInitialQuestionTableFilters(searchParams: URLSearchParams): ColumnFiltersState { + const filters: ColumnFiltersState = []; + const difficulty = searchParams.get("difficulty"); + if (difficulty) filters.push({ id: "difficulty", value: difficulty.split(",") }); + const pattern = searchParams.get("pattern"); + if (pattern) filters.push({ id: "pattern", value: pattern.split(",") }); + const companies = searchParams.get("companies"); + if (companies) filters.push({ id: "companies", value: companies.split(",") }); + return filters; +} diff --git a/src/lib/useIsMobile.ts b/src/lib/useIsMobile.ts new file mode 100644 index 00000000..e4a71c1b --- /dev/null +++ b/src/lib/useIsMobile.ts @@ -0,0 +1,23 @@ +"use client"; + +import { useSyncExternalStore } from "react"; + +const mobileQuery = "(max-width: 639px)"; + +function subscribeMobile(callback: () => void) { + const mq = window.matchMedia(mobileQuery); + mq.addEventListener("change", callback); + return () => mq.removeEventListener("change", callback); +} + +function getMobileSnapshot() { + return window.matchMedia(mobileQuery).matches; +} + +function getMobileServerSnapshot() { + return false; +} + +export function useIsMobile() { + return useSyncExternalStore(subscribeMobile, getMobileSnapshot, getMobileServerSnapshot); +}