From 9a7282aaf468b23de06cebb49038c684e5b420dd Mon Sep 17 00:00:00 2001 From: Eva Decker Date: Tue, 5 Nov 2024 23:11:08 -0500 Subject: [PATCH] feat: Support "group by" for quests (#170) --- .changeset/green-lions-reply.md | 5 + convex/auth.ts | 2 +- convex/constants.ts | 51 +++- convex/schema.ts | 4 +- convex/seed.ts | 2 +- convex/userQuests.ts | 125 ++++++++++ convex/users.ts | 8 +- convex/validators.ts | 8 +- src/components/GridList/GridList.tsx | 2 +- src/convex/constants.ts | 0 .../_authenticated/_home.quests.$questId.tsx | 16 +- src/routes/_authenticated/_home.tsx | 224 ++++++++---------- .../_authenticated/admin/quests/$questId.tsx | 3 +- .../_authenticated/admin/quests/index.tsx | 11 +- src/routes/_authenticated/browse/index.tsx | 6 +- 15 files changed, 319 insertions(+), 148 deletions(-) create mode 100644 .changeset/green-lions-reply.md create mode 100644 src/convex/constants.ts diff --git a/.changeset/green-lions-reply.md b/.changeset/green-lions-reply.md new file mode 100644 index 0000000..127399a --- /dev/null +++ b/.changeset/green-lions-reply.md @@ -0,0 +1,5 @@ +--- +"namesake": minor +--- + +Support grouping quests by category, status, and date added diff --git a/convex/auth.ts b/convex/auth.ts index bf4d4e4..385b795 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -28,7 +28,7 @@ export const { auth, signIn, signOut, store } = convexAuth({ emailVerified: args.profile.emailVerified ?? false, role: "user", theme: "system", - sortQuestsBy: "newest", + groupQuestsBy: "dateAdded", }); }, diff --git a/convex/constants.ts b/convex/constants.ts index c4fa05b..e798f6f 100644 --- a/convex/constants.ts +++ b/convex/constants.ts @@ -5,6 +5,7 @@ import { RiBankLine, RiCalendarLine, RiChat3Line, + RiCheckLine, RiCheckboxLine, RiComputerLine, RiDropdownList, @@ -19,6 +20,7 @@ import { RiMovie2Line, RiParagraph, RiPhoneLine, + RiProgress4Line, RiQuestionLine, RiScales3Line, RiShoppingBag4Line, @@ -135,17 +137,19 @@ export const ROLES = { } as const; export type Role = keyof typeof ROLES; -export const SORT_QUESTS_BY = { - newest: "Newest", - oldest: "Oldest", +export const GROUP_QUESTS_BY = { + dateAdded: "Date added", + category: "Category", + status: "Status", } as const; -export type SortQuestsBy = keyof typeof SORT_QUESTS_BY; +export type GroupQuestsBy = keyof typeof GROUP_QUESTS_BY; -interface CategoryDetails { +interface GroupDetails { label: string; - icon: RemixiconComponentType; + icon: RemixiconComponentType | null; } -export const CATEGORIES: Record = { + +export const CATEGORIES: Record = { core: { label: "Core", icon: RiSignpostLine, @@ -207,4 +211,37 @@ export const CATEGORIES: Record = { icon: RiQuestionLine, }, }; +export const CATEGORY_ORDER: Category[] = Object.keys(CATEGORIES) as Category[]; export type Category = keyof typeof CATEGORIES; + +export const DATE_ADDED: Record = { + lastWeek: { + label: "Last 7 days", + icon: null, + }, + lastMonth: { + label: "Last 30 days", + icon: null, + }, + earlier: { + label: "Earlier", + icon: null, + }, +}; +export const DATE_ADDED_ORDER: DateAdded[] = Object.keys( + DATE_ADDED, +) as DateAdded[]; +export type DateAdded = keyof typeof DATE_ADDED; + +export const STATUS: Record = { + incomplete: { + label: "In Progress", + icon: RiProgress4Line, + }, + complete: { + label: "Completed", + icon: RiCheckLine, + }, +} as const; +export const STATUS_ORDER: Status[] = Object.keys(STATUS) as Status[]; +export type Status = keyof typeof STATUS; diff --git a/convex/schema.ts b/convex/schema.ts index 5970392..cd80f29 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -4,9 +4,9 @@ import { v } from "convex/values"; import { category, field, + groupQuestsBy, jurisdiction, role, - sortQuestsBy, theme, } from "./validators"; @@ -86,7 +86,7 @@ const users = defineTable({ jurisdiction: v.optional(jurisdiction), isMinor: v.optional(v.boolean()), theme: theme, - sortQuestsBy: v.optional(sortQuestsBy), + groupQuestsBy: v.optional(groupQuestsBy), }).index("email", ["email"]); /** diff --git a/convex/seed.ts b/convex/seed.ts index ea2fdc4..13a1cb5 100644 --- a/convex/seed.ts +++ b/convex/seed.ts @@ -33,7 +33,7 @@ const seed = internalMutation(async (ctx) => { role: "admin", emailVerified: faker.datatype.boolean(), theme: faker.helpers.arrayElement(["system", "light", "dark"]), - sortQuestsBy: "newest", + groupQuestsBy: "dateAdded", }); console.log(`Created user ${firstName} ${lastName}`); diff --git a/convex/userQuests.ts b/convex/userQuests.ts index 463787f..eed6526 100644 --- a/convex/userQuests.ts +++ b/convex/userQuests.ts @@ -1,5 +1,6 @@ import { v } from "convex/values"; import { query } from "./_generated/server"; +import type { Category } from "./constants"; import { userMutation, userQuery } from "./helpers"; // TODO: Add `returns` value validation @@ -26,6 +27,18 @@ export const getQuestsForCurrentUser = userQuery({ }, }); +export const getUserQuestCount = userQuery({ + args: {}, + returns: v.number(), + handler: async (ctx) => { + const userQuests = await ctx.db + .query("userQuests") + .withIndex("userId", (q) => q.eq("userId", ctx.userId)) + .collect(); + return userQuests.length; + }, +}); + export const getAvailableQuestsForUser = userQuery({ args: {}, handler: async (ctx, _args) => { @@ -172,3 +185,115 @@ export const getQuestCounts = query({ return counts; }, }); + +export const getUserQuestsByCategory = userQuery({ + args: {}, + handler: async (ctx) => { + const userQuests = await ctx.db + .query("userQuests") + .withIndex("userId", (q) => q.eq("userId", ctx.userId)) + .collect(); + + const questsWithDetails = await Promise.all( + userQuests.map(async (userQuest) => { + const quest = await ctx.db.get(userQuest.questId); + return quest && quest.deletionTime === undefined + ? { ...quest, ...userQuest } + : null; + }), + ); + + const validQuests = questsWithDetails.filter( + (q): q is NonNullable => q !== null, + ); + + return validQuests.reduce( + (acc, quest) => { + const category = (quest.category ?? "other") as Category; + if (!acc[category]) acc[category] = []; + acc[category].push(quest); + return acc; + }, + {} as Record, + ); + }, +}); + +export const getUserQuestsByDate = userQuery({ + args: {}, + handler: async (ctx) => { + const userQuests = await ctx.db + .query("userQuests") + .withIndex("userId", (q) => q.eq("userId", ctx.userId)) + .collect(); + + const questsWithDetails = await Promise.all( + userQuests.map(async (userQuest) => { + const quest = await ctx.db.get(userQuest.questId); + return quest && quest.deletionTime === undefined + ? { ...quest, ...userQuest } + : null; + }), + ); + + const validQuests = questsWithDetails.filter( + (q): q is NonNullable => q !== null, + ); + + const now = Date.now(); + const oneWeekAgo = now - 7 * 24 * 60 * 60 * 1000; + const oneMonthAgo = now - 30 * 24 * 60 * 60 * 1000; + + return validQuests.reduce( + (acc, quest) => { + if (quest._creationTime > oneWeekAgo) { + acc.lastWeek.push(quest); + } else if (quest._creationTime > oneMonthAgo) { + acc.lastMonth.push(quest); + } else { + acc.earlier.push(quest); + } + return acc; + }, + { lastWeek: [], lastMonth: [], earlier: [] } as Record< + string, + typeof validQuests + >, + ); + }, +}); + +export const getUserQuestsByStatus = userQuery({ + args: {}, + handler: async (ctx) => { + const userQuests = await ctx.db + .query("userQuests") + .withIndex("userId", (q) => q.eq("userId", ctx.userId)) + .collect(); + + const questsWithDetails = await Promise.all( + userQuests.map(async (userQuest) => { + const quest = await ctx.db.get(userQuest.questId); + return quest && quest.deletionTime === undefined + ? { ...quest, ...userQuest } + : null; + }), + ); + + const validQuests = questsWithDetails.filter( + (q): q is NonNullable => q !== null, + ); + + return validQuests.reduce( + (acc, quest) => { + if (quest.completionTime) { + acc.complete.push(quest); + } else { + acc.incomplete.push(quest); + } + return acc; + }, + { incomplete: [], complete: [] } as Record, + ); + }, +}); diff --git a/convex/users.ts b/convex/users.ts index 174d8e7..528ec19 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -3,7 +3,7 @@ import { v } from "convex/values"; import { query } from "./_generated/server"; import type { Role } from "./constants"; import { userMutation, userQuery } from "./helpers"; -import { sortQuestsBy, theme } from "./validators"; +import { groupQuestsBy, theme } from "./validators"; // TODO: Add `returns` value validation // https://docs.convex.dev/functions/validation @@ -64,10 +64,10 @@ export const setUserTheme = userMutation({ }, }); -export const setSortQuestsBy = userMutation({ - args: { sortQuestsBy: sortQuestsBy }, +export const setGroupQuestsBy = userMutation({ + args: { groupQuestsBy: groupQuestsBy }, handler: async (ctx, args) => { - await ctx.db.patch(ctx.userId, { sortQuestsBy: args.sortQuestsBy }); + await ctx.db.patch(ctx.userId, { groupQuestsBy: args.groupQuestsBy }); }, }); diff --git a/convex/validators.ts b/convex/validators.ts index d4d8aa6..7288f18 100644 --- a/convex/validators.ts +++ b/convex/validators.ts @@ -2,9 +2,9 @@ import { v } from "convex/values"; import { CATEGORIES, FIELDS, + GROUP_QUESTS_BY, JURISDICTIONS, ROLES, - SORT_QUESTS_BY, THEMES, } from "./constants"; @@ -24,8 +24,10 @@ export const field = v.union( ...Object.keys(FIELDS).map((field) => v.literal(field)), ); -export const sortQuestsBy = v.union( - ...Object.keys(SORT_QUESTS_BY).map((sortQuestsBy) => v.literal(sortQuestsBy)), +export const groupQuestsBy = v.union( + ...Object.keys(GROUP_QUESTS_BY).map((groupQuestsBy) => + v.literal(groupQuestsBy), + ), ); export const category = v.union( diff --git a/src/components/GridList/GridList.tsx b/src/components/GridList/GridList.tsx index b632ec9..2241f86 100644 --- a/src/components/GridList/GridList.tsx +++ b/src/components/GridList/GridList.tsx @@ -28,7 +28,7 @@ export function GridList({ const itemStyles = tv({ extend: focusRing, - base: "relative text-gray-normal flex items-center gap-3 cursor-pointer select-none py-2 px-3 text-sm first:rounded-t-md last:rounded-b-md -mb-px last:mb-0 -outline-offset-2", + base: "relative text-gray-normal flex items-center gap-3 cursor-pointer select-none py-2 px-4 text-sm first:rounded-t-md last:rounded-b-md -mb-px last:mb-0 -outline-offset-2", variants: { isSelected: { false: "", diff --git a/src/convex/constants.ts b/src/convex/constants.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/routes/_authenticated/_home.quests.$questId.tsx b/src/routes/_authenticated/_home.quests.$questId.tsx index 8fb6f49..b5b79b1 100644 --- a/src/routes/_authenticated/_home.quests.$questId.tsx +++ b/src/routes/_authenticated/_home.quests.$questId.tsx @@ -12,7 +12,13 @@ import { } from "@/components"; import { api } from "@convex/_generated/api"; import type { Id } from "@convex/_generated/dataModel"; -import { RiLink, RiMoreFill, RiSignpostLine } from "@remixicon/react"; +import { + RiCheckLine, + RiLink, + RiMoreFill, + RiProgress4Line, + RiSignpostLine, +} from "@remixicon/react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useMutation, useQuery } from "convex/react"; import Markdown from "react-markdown"; @@ -66,9 +72,13 @@ function QuestDetailRoute() {
{quest.jurisdiction && {quest.jurisdiction}} {userQuest?.completionTime ? ( - Complete + + Complete + ) : ( - In progress + + In progress + )}
} diff --git a/src/routes/_authenticated/_home.tsx b/src/routes/_authenticated/_home.tsx index 40a3251..97c4821 100644 --- a/src/routes/_authenticated/_home.tsx +++ b/src/routes/_authenticated/_home.tsx @@ -7,24 +7,27 @@ import { Link, Menu, MenuItem, - MenuSeparator, + MenuSection, MenuTrigger, ProgressBar, Tooltip, TooltipTrigger, } from "@/components"; import { api } from "@convex/_generated/api"; -import type { SortQuestsBy } from "@convex/constants"; import { - RiAddLine, - RiCheckLine, - RiMoreFill, - RiSignpostLine, -} from "@remixicon/react"; + CATEGORIES, + CATEGORY_ORDER, + DATE_ADDED, + DATE_ADDED_ORDER, + type GroupQuestsBy, + STATUS, + STATUS_ORDER, +} from "@convex/constants"; +import { RiAddLine, RiListCheck2, RiSignpostLine } from "@remixicon/react"; import { Outlet, createFileRoute } from "@tanstack/react-router"; import { Authenticated, Unauthenticated, useQuery } from "convex/react"; import { useEffect, useState } from "react"; -import { toast } from "sonner"; +import type { Selection } from "react-aria-components"; import { twMerge } from "tailwind-merge"; export const Route = createFileRoute("/_authenticated/_home")({ @@ -32,38 +35,36 @@ export const Route = createFileRoute("/_authenticated/_home")({ }); function IndexRoute() { - const [showCompleted, setShowCompleted] = useState( - localStorage.getItem("showCompleted") === "true", + const [groupBy, setGroupBy] = useState( + new Set([localStorage.getItem("groupQuestsBy") ?? "dateAdded"]), ); - const [sortBy, setSortBy] = useState( - (localStorage.getItem("sortQuestsBy") as SortQuestsBy) ?? "newest", - ); - - const toggleShowCompleted = () => { - toast( - showCompleted ? "Hiding completed quests" : "Showing completed quests", - ); - setShowCompleted(!showCompleted); - }; - - useEffect(() => { - localStorage.setItem("showCompleted", showCompleted.toString()); - }, [showCompleted]); useEffect(() => { - localStorage.setItem("sortQuestsBy", sortBy); - }, [sortBy]); + const selected = [...groupBy][0] as GroupQuestsBy; + localStorage.setItem("groupQuestsBy", selected); + }, [groupBy]); const MyQuests = () => { - const myQuests = useQuery(api.userQuests.getQuestsForCurrentUser); + const userQuestCount = useQuery(api.userQuests.getUserQuestCount); const completedQuests = useQuery(api.userQuests.getCompletedQuestCount); - const setLastSelectedQuestId = (questId: string) => { - localStorage.setItem("lastSelectedQuestId", questId); - }; - if (myQuests === undefined) return; + // Get the selected grouping method + const groupByValue = [...groupBy][0] as GroupQuestsBy; - if (myQuests === null || myQuests.length === 0) + // Use the appropriate query based on grouping selection + const questsByCategory = useQuery(api.userQuests.getUserQuestsByCategory); + const questsByDate = useQuery(api.userQuests.getUserQuestsByDate); + const questsByStatus = useQuery(api.userQuests.getUserQuestsByStatus); + + const groupedQuests = { + category: questsByCategory, + dateAdded: questsByDate, + status: questsByStatus, + }[groupByValue]; + + if (groupedQuests === undefined) return; + + if (groupedQuests === null || Object.keys(groupedQuests).length === 0) return ( ); - const totalQuests = myQuests.length; - - const sortedQuests = myQuests.sort((a, b) => { - switch (sortBy) { - case "oldest": - return a._creationTime - b._creationTime; - case "newest": - return b._creationTime - a._creationTime; - default: - return 0; - } - }); - - const filteredQuests = sortedQuests.filter((quest) => { - if (showCompleted) return true; - return !quest.completionTime; - }); - return (
-
- - {filteredQuests.map((quest) => { - if (quest === null) return null; + {Object.entries(groupedQuests) + .sort(([groupA], [groupB]) => { + const orderArray = + groupByValue === "category" + ? CATEGORY_ORDER + : groupByValue === "status" + ? STATUS_ORDER + : DATE_ADDED_ORDER; return ( - setLastSelectedQuestId(quest.questId)} - > -
-
-

{quest.title}

- {quest.jurisdiction && {quest.jurisdiction}} -
- {quest.completionTime ? ( - - ) : null} + orderArray.indexOf(groupA as any) - + orderArray.indexOf(groupB as any) + ); + }) + .map(([group, quests]) => { + if (quests.length === 0) return null; + const { label, icon: Icon } = + groupByValue === "category" + ? CATEGORIES[group as keyof typeof CATEGORIES] + : groupByValue === "status" + ? STATUS[group as keyof typeof STATUS] + : DATE_ADDED[group as keyof typeof DATE_ADDED]; + + return ( +
+
+ {label}
- + + {quests.map((quest) => ( + +
+
+ {Icon ? ( + + ) : null} +

{quest.title}

+ {quest.jurisdiction && ( + {quest.jurisdiction} + )} +
+
+
+ ))} +
+
); })} - {!showCompleted && completedQuests && completedQuests > 0 && ( - setShowCompleted(true)} - > -
- - {`${completedQuests} completed ${completedQuests > 1 ? "quests" : "quest"} hidden`} -
-
- )} -
); }; diff --git a/src/routes/_authenticated/admin/quests/$questId.tsx b/src/routes/_authenticated/admin/quests/$questId.tsx index e6a59e5..ec21c98 100644 --- a/src/routes/_authenticated/admin/quests/$questId.tsx +++ b/src/routes/_authenticated/admin/quests/$questId.tsx @@ -14,6 +14,7 @@ import { JURISDICTIONS, type Jurisdiction, } from "@convex/constants"; +import { RiQuestionLine } from "@remixicon/react"; import { createFileRoute } from "@tanstack/react-router"; import { useMutation, useQuery } from "convex/react"; import { memo, useEffect, useState } from "react"; @@ -93,7 +94,7 @@ function AdminQuestDetailRoute() { isRequired > {Object.entries(CATEGORIES).map(([key, { label, icon }]) => { - const Icon = icon; + const Icon = icon ?? RiQuestionLine; return ( {label} diff --git a/src/routes/_authenticated/admin/quests/index.tsx b/src/routes/_authenticated/admin/quests/index.tsx index 676dd7e..2b6932e 100644 --- a/src/routes/_authenticated/admin/quests/index.tsx +++ b/src/routes/_authenticated/admin/quests/index.tsx @@ -26,7 +26,12 @@ import { JURISDICTIONS, type Jurisdiction, } from "@convex/constants"; -import { RiAddLine, RiMoreFill, RiSignpostLine } from "@remixicon/react"; +import { + RiAddLine, + RiMoreFill, + RiQuestionLine, + RiSignpostLine, +} from "@remixicon/react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useMutation, useQuery } from "convex/react"; import { useState } from "react"; @@ -89,7 +94,7 @@ const NewQuestModal = ({ isRequired > {Object.entries(CATEGORIES).map(([key, { label, icon }]) => { - const Icon = icon; + const Icon = icon ?? RiQuestionLine; return ( {label} @@ -147,7 +152,7 @@ const QuestTableRow = ({ const { icon, label } = CATEGORIES[quest.category]; - return {label}; + return {label}; }; return ( diff --git a/src/routes/_authenticated/browse/index.tsx b/src/routes/_authenticated/browse/index.tsx index 68c48b6..cfdb041 100644 --- a/src/routes/_authenticated/browse/index.tsx +++ b/src/routes/_authenticated/browse/index.tsx @@ -9,7 +9,7 @@ import { import { api } from "@convex/_generated/api"; import type { Doc } from "@convex/_generated/dataModel"; import { CATEGORIES, type Category } from "@convex/constants"; -import { RiLoader4Line } from "@remixicon/react"; +import { RiLoader4Line, RiQuestionLine } from "@remixicon/react"; import { createFileRoute } from "@tanstack/react-router"; import { useMutation, useQuery } from "convex/react"; import { useState } from "react"; @@ -98,12 +98,12 @@ const QuestCategoryRow = ({ category }: { category: Category }) => { if (!quests || quests.length === 0) return; const { label, icon } = CATEGORIES[category]; - const Icon = icon; + const Icon = icon ?? RiQuestionLine; return (

- + {label}