From 03cc1ecb558110f3c3708e5b0e243bdfebb557ef Mon Sep 17 00:00:00 2001 From: Eva Decker Date: Sun, 17 Nov 2024 02:22:41 -0500 Subject: [PATCH] feat: Display time estimates for each quest (#198) --- .changeset/swift-suns-laugh.md | 5 + convex/constants.ts | 58 ++++++- convex/quests.ts | 12 +- convex/schema.ts | 7 + convex/seed.ts | 3 +- convex/userQuests.ts | 47 ++++- convex/validators.ts | 5 + src/components/NumberField/NumberField.tsx | 2 +- src/components/Select/Select.tsx | 2 +- src/components/TextArea/TextArea.tsx | 2 +- src/components/TextField/TextField.tsx | 2 +- src/routes/_authenticated/_home.tsx | 41 ++++- .../_authenticated/_home/quests.$questId.tsx | 136 +++++++++------ .../_authenticated/admin/quests/$questId.tsx | 163 ++++++++++++++---- 14 files changed, 378 insertions(+), 107 deletions(-) create mode 100644 .changeset/swift-suns-laugh.md diff --git a/.changeset/swift-suns-laugh.md b/.changeset/swift-suns-laugh.md new file mode 100644 index 0000000..624d06d --- /dev/null +++ b/.changeset/swift-suns-laugh.md @@ -0,0 +1,5 @@ +--- +"namesake": minor +--- + +Display time required to complete a quest diff --git a/convex/constants.ts b/convex/constants.ts index bf0fc87..d725e8e 100644 --- a/convex/constants.ts +++ b/convex/constants.ts @@ -3,13 +3,16 @@ import { RiAccountCircleLine, RiAtLine, RiBankLine, + RiCalendar2Line, RiCalendarLine, + RiCalendarScheduleLine, RiChat3Line, RiCheckboxBlankCircleLine, RiCheckboxCircleFill, RiCheckboxLine, RiComputerLine, RiDropdownList, + RiFlashlightLine, RiFolderCheckLine, RiFolderLine, RiGamepadLine, @@ -159,6 +162,7 @@ export const GROUP_QUESTS_BY = { dateAdded: "Date added", category: "Category", status: "Status", + timeRequired: "Time required", } as const; export type GroupQuestsBy = keyof typeof GROUP_QUESTS_BY; @@ -166,10 +170,10 @@ export type GroupQuestsBy = keyof typeof GROUP_QUESTS_BY; * Generic group details. * Used for UI display of filter groups. */ -interface GroupDetails { +export type GroupDetails = { label: string; icon: RemixiconComponentType; -} +}; /** * Categories. @@ -329,3 +333,53 @@ export const STATUS: Record = { } as const; export const STATUS_ORDER: Status[] = Object.keys(STATUS) as Status[]; + +export type Cost = { + cost: number; + description: string; +}; + +/** + * Time units. + * Used to display time required in quest details. + */ +export type TimeUnit = "minutes" | "hours" | "days" | "weeks" | "months"; + +export const TIME_UNITS: Record = { + minutes: { + label: "Minutes", + icon: RiFlashlightLine, + }, + hours: { + label: "Hours", + icon: RiTimeLine, + }, + days: { + label: "Days", + icon: RiCalendarLine, + }, + weeks: { + label: "Weeks", + icon: RiCalendar2Line, + }, + months: { + label: "Months", + icon: RiCalendarScheduleLine, + }, +}; + +export const TIME_UNITS_ORDER: TimeUnit[] = Object.keys( + TIME_UNITS, +) as TimeUnit[]; + +export type TimeRequired = { + min: number; + max: number; + unit: TimeUnit; +}; + +export const DEFAULT_TIME_REQUIRED: TimeRequired = { + min: 5, + max: 10, + unit: "minutes", +}; diff --git a/convex/quests.ts b/convex/quests.ts index 145bd9a..45a3cb0 100644 --- a/convex/quests.ts +++ b/convex/quests.ts @@ -1,7 +1,8 @@ import { v } from "convex/values"; import { query } from "./_generated/server"; +import { DEFAULT_TIME_REQUIRED } from "./constants"; import { userMutation } from "./helpers"; -import { category, jurisdiction } from "./validators"; +import { category, jurisdiction, timeRequiredUnit } from "./validators"; // TODO: Add `returns` value validation // https://docs.convex.dev/functions/validation @@ -51,6 +52,7 @@ export const createQuest = userMutation({ title: args.title, category: args.category, jurisdiction: args.jurisdiction, + timeRequired: DEFAULT_TIME_REQUIRED, creationUser: ctx.userId, }); }, @@ -71,6 +73,13 @@ export const updateQuest = userMutation({ }), ), ), + timeRequired: v.optional( + v.object({ + min: v.number(), + max: v.number(), + unit: timeRequiredUnit, + }), + ), urls: v.optional(v.array(v.string())), content: v.optional(v.string()), }, @@ -81,6 +90,7 @@ export const updateQuest = userMutation({ jurisdiction: args.jurisdiction, category: args.category, costs: args.costs, + timeRequired: args.timeRequired, urls: args.urls, content: args.content, }); diff --git a/convex/schema.ts b/convex/schema.ts index 14e8cce..3df07d6 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -9,6 +9,7 @@ import { role, status, theme, + timeRequiredUnit, } from "./validators"; /** @@ -18,6 +19,7 @@ import { * @param creationUser - The user who created the quest. * @param jurisdiction - The US State the quest applies to. (e.g. "MA") * @param costs - The costs of the quest in USD. + * @param timeRequired - The estimated time required to complete the quest. * @param urls - Links to official documentation about changing names for this quest. * @param deletionTime - Time in ms since epoch when the quest was deleted. * @param content - Text written in markdown comprising the contents of the quest. @@ -35,6 +37,11 @@ const quests = defineTable({ }), ), ), + timeRequired: v.object({ + min: v.number(), + max: v.number(), + unit: timeRequiredUnit, + }), urls: v.optional(v.array(v.string())), deletionTime: v.optional(v.number()), content: v.optional(v.string()), diff --git a/convex/seed.ts b/convex/seed.ts index 13a1cb5..a9593aa 100644 --- a/convex/seed.ts +++ b/convex/seed.ts @@ -1,6 +1,6 @@ import { faker } from "@faker-js/faker"; import { internalMutation } from "./_generated/server"; -import { JURISDICTIONS } from "./constants"; +import { DEFAULT_TIME_REQUIRED, JURISDICTIONS } from "./constants"; const seed = internalMutation(async (ctx) => { if (process.env.NODE_ENV === "production") { @@ -49,6 +49,7 @@ const seed = internalMutation(async (ctx) => { title: questTitle, category: "core", jurisdiction: questJurisdiction, + timeRequired: DEFAULT_TIME_REQUIRED, creationUser: userId, }); console.log(`Created quest ${questTitle} (${questJurisdiction})`); diff --git a/convex/userQuests.ts b/convex/userQuests.ts index 1f6025d..f2f9505 100644 --- a/convex/userQuests.ts +++ b/convex/userQuests.ts @@ -1,6 +1,6 @@ import { v } from "convex/values"; import { query } from "./_generated/server"; -import { type Category, STATUS, type Status } from "./constants"; +import { type Category, STATUS, type Status, type TimeUnit } from "./constants"; import { userMutation, userQuery } from "./helpers"; import { status } from "./validators"; @@ -319,7 +319,7 @@ export const getUserQuestsByStatus = userQuery({ ); // Initialize an object with all possible status keys - const initial: Record = { + const initial: Record = { notStarted: [], inProgress: [], readyToFile: [], @@ -329,8 +329,49 @@ export const getUserQuestsByStatus = userQuery({ // Group quests by their status return validQuests.reduce((acc, quest) => { - acc[quest.status].push(quest); + acc[quest.status as Status].push(quest); return acc; }, initial); }, }); + +export const getUserQuestsByTimeRequired = 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, + ); + + // Initialize an object with all possible time required keys + const initial: Record = { + minutes: [], + hours: [], + days: [], + weeks: [], + months: [], + }; + + // Group quests by their time required unit + const group = validQuests.reduce((acc, quest) => { + acc[quest.timeRequired.unit as TimeUnit].push(quest); + return acc; + }, initial); + + console.log("group", group); + return group; + }, +}); diff --git a/convex/validators.ts b/convex/validators.ts index c171688..353e70c 100644 --- a/convex/validators.ts +++ b/convex/validators.ts @@ -7,6 +7,7 @@ import { ROLES, STATUS, THEMES, + TIME_UNITS, } from "./constants"; export const jurisdiction = v.union( @@ -38,3 +39,7 @@ export const groupQuestsBy = v.union( export const category = v.union( ...Object.keys(CATEGORIES).map((category) => v.literal(category)), ); + +export const timeRequiredUnit = v.union( + ...Object.keys(TIME_UNITS).map((unit) => v.literal(unit)), +); diff --git a/src/components/NumberField/NumberField.tsx b/src/components/NumberField/NumberField.tsx index fdd217c..331af23 100644 --- a/src/components/NumberField/NumberField.tsx +++ b/src/components/NumberField/NumberField.tsx @@ -35,7 +35,7 @@ export function NumberField({ {...props} className={composeTailwindRenderProps( props.className, - "group flex flex-col gap-1", + "group flex flex-col gap-1.5", )} > diff --git a/src/components/Select/Select.tsx b/src/components/Select/Select.tsx index 4657de8..b29c3f1 100644 --- a/src/components/Select/Select.tsx +++ b/src/components/Select/Select.tsx @@ -53,7 +53,7 @@ export function Select({ {...props} className={composeTailwindRenderProps( props.className, - "group flex flex-col gap-2", + "group flex flex-col gap-1.5", )} > {label && } diff --git a/src/components/TextArea/TextArea.tsx b/src/components/TextArea/TextArea.tsx index 5f16b1c..547d7fe 100644 --- a/src/components/TextArea/TextArea.tsx +++ b/src/components/TextArea/TextArea.tsx @@ -39,7 +39,7 @@ export function TextArea({ {...props} className={composeTailwindRenderProps( props.className, - "flex flex-col gap-2", + "flex flex-col gap-1.5", )} > {label && } diff --git a/src/components/TextField/TextField.tsx b/src/components/TextField/TextField.tsx index 107482b..4111182 100644 --- a/src/components/TextField/TextField.tsx +++ b/src/components/TextField/TextField.tsx @@ -39,7 +39,7 @@ export function TextField({ {...props} className={composeTailwindRenderProps( props.className, - "flex flex-col gap-2", + "flex flex-col gap-1.5", )} type={ props.type === "password" && isPasswordVisible ? "text" : props.type diff --git a/src/routes/_authenticated/_home.tsx b/src/routes/_authenticated/_home.tsx index 9de1e9c..7c6e185 100644 --- a/src/routes/_authenticated/_home.tsx +++ b/src/routes/_authenticated/_home.tsx @@ -24,10 +24,14 @@ import { DATE_ADDED, DATE_ADDED_ORDER, type DateAdded, + type GroupDetails, type GroupQuestsBy, STATUS, STATUS_ORDER, type Status, + TIME_UNITS, + TIME_UNITS_ORDER, + type TimeUnit, } from "@convex/constants"; import { RiAddLine, RiListCheck2, RiSignpostLine } from "@remixicon/react"; import { Outlet, createFileRoute } from "@tanstack/react-router"; @@ -61,6 +65,11 @@ function sortGroupedQuests( const indexB = DATE_ADDED_ORDER.indexOf(groupB as DateAdded); return indexA - indexB; } + case "timeRequired": { + const indexA = TIME_UNITS_ORDER.indexOf(groupA as TimeUnit); + const indexB = TIME_UNITS_ORDER.indexOf(groupB as TimeUnit); + return indexA - indexB; + } } } @@ -85,11 +94,15 @@ function IndexRoute() { const questsByCategory = useQuery(api.userQuests.getUserQuestsByCategory); const questsByDate = useQuery(api.userQuests.getUserQuestsByDate); const questsByStatus = useQuery(api.userQuests.getUserQuestsByStatus); + const questsByTimeRequired = useQuery( + api.userQuests.getUserQuestsByTimeRequired, + ); const groupedQuests = { category: questsByCategory, dateAdded: questsByDate, status: questsByStatus, + timeRequired: questsByTimeRequired, }[groupByValue]; if (groupedQuests === undefined) return; @@ -111,8 +124,9 @@ function IndexRoute() { const allCategoryKeys = [ ...Object.values(CATEGORIES).map((category) => category.label), - ...Object.values(DATE_ADDED).map((date) => date.label), ...Object.values(STATUS).map((status) => status.label), + ...Object.values(DATE_ADDED).map((date) => date.label), + ...Object.values(TIME_UNITS).map((timeUnit) => timeUnit.label), ]; return ( @@ -136,8 +150,9 @@ function IndexRoute() { > Category - Date added Status + Date added + Time required @@ -165,12 +180,22 @@ function IndexRoute() { ) .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]; + let groupDetails: GroupDetails; + switch (groupByValue) { + case "category": + groupDetails = CATEGORIES[group as keyof typeof CATEGORIES]; + break; + case "status": + groupDetails = STATUS[group as keyof typeof STATUS]; + break; + case "timeRequired": + groupDetails = TIME_UNITS[group as keyof typeof TIME_UNITS]; + break; + case "dateAdded": + groupDetails = DATE_ADDED[group as keyof typeof DATE_ADDED]; + break; + } + const { label, icon: Icon } = groupDetails; return ( { - if (!costs) return "Free"; +const StatGroup = ({ + label, + value, + children, +}: { label: string; value: string; children?: React.ReactNode }) => ( +
+
{label}
+
+ {value} + {children} +
+
+); - const total = costs.reduce((acc, cost) => acc + cost.cost, 0); - return total > 0 - ? total.toLocaleString("en-US", { - style: "currency", - currency: "USD", - maximumFractionDigits: 0, - }) - : "Free"; -}; +const QuestCosts = ({ costs }: { costs?: Cost[] }) => { + const getTotalCosts = (costs?: Cost[]) => { + if (!costs) return "Free"; -const QuestCosts = ({ - costs, -}: { costs?: { cost: number; description: string }[] }) => { - const totalCosts = getTotalCosts(costs); + const total = costs.reduce((acc, cost) => acc + cost.cost, 0); + return total > 0 + ? total.toLocaleString("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }) + : "Free"; + }; return ( -
-
-
Cost
-
- {totalCosts} - {costs?.length && ( - - - - See cost breakdown - - -
- {costs.map(({ cost, description }) => ( - -
{description}
-
- {cost.toLocaleString("en-US", { - style: "currency", - currency: "USD", - maximumFractionDigits: 0, - })} -
-
- ))} -
- Total -
-
- {totalCosts} + + {costs?.length && ( + + + + See cost breakdown + + +
+ {costs.map(({ cost, description }) => ( + +
{description}
+
+ {cost.toLocaleString("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + })}
-
-
-
- )} -
-
-
+ + ))} +
+ Total +
+
+ {getTotalCosts(costs)} +
+ + + + )} + ); }; +const QuestTimeRequired = ({ + timeRequired, +}: { + timeRequired: TimeRequired; +}) => { + const getFormattedTime = (timeRequired: TimeRequired) => { + return timeRequired + ? `${timeRequired.min}–${timeRequired.max} ${timeRequired.unit}` + : "Unknown"; + }; + + const formattedTime = getFormattedTime(timeRequired); + return ; +}; + function QuestDetailRoute() { const { questId } = Route.useParams(); const navigate = useNavigate(); @@ -154,7 +173,12 @@ function QuestDetailRoute() { - +
+ + +
{quest.urls && (
{quest.urls.map((url) => ( diff --git a/src/routes/_authenticated/admin/quests/$questId.tsx b/src/routes/_authenticated/admin/quests/$questId.tsx index e8036bc..1ef1471 100644 --- a/src/routes/_authenticated/admin/quests/$questId.tsx +++ b/src/routes/_authenticated/admin/quests/$questId.tsx @@ -1,5 +1,7 @@ import { + AnimateChangeInHeight, Button, + Checkbox, Form, NumberField, RichTextEditor, @@ -12,8 +14,13 @@ import type { Id } from "@convex/_generated/dataModel"; import { CATEGORIES, type Category, + type Cost, + DEFAULT_TIME_REQUIRED, JURISDICTIONS, type Jurisdiction, + TIME_UNITS, + type TimeRequired, + type TimeUnit, } from "@convex/constants"; import { RiQuestionLine } from "@remixicon/react"; import { createFileRoute } from "@tanstack/react-router"; @@ -29,17 +36,20 @@ const URLInput = memo(function URLInput({ value, onChange, onRemove, + hideLabel = false, }: { value: string; onChange: (value: string) => void; onRemove: () => void; + hideLabel?: boolean; }) { return (
+
+ )} + + ); +}); + +const TimeRequiredInput = memo(function TimeRequiredInput({ + timeRequired, + onChange, +}: { + timeRequired: TimeRequired; + onChange: (timeRequired: TimeRequired) => void; +}) { + if (!timeRequired) return null; + + return ( +
+ + onChange({ + ...timeRequired, + min: value, + }) + } + /> + + onChange({ + ...timeRequired, + max: value, + }) + } + /> + +
+ ); +}); + function AdminQuestDetailRoute() { const { questId } = Route.useParams(); const quest = useQuery(api.quests.getQuest, { @@ -96,8 +211,9 @@ function AdminQuestDetailRoute() { const [title, setTitle] = useState(""); const [category, setCategory] = useState(null); const [jurisdiction, setJurisdiction] = useState(null); - const [costs, setCosts] = useState<{ cost: number; description: string }[]>( - [], + const [costs, setCosts] = useState(null); + const [timeRequired, setTimeRequired] = useState( + DEFAULT_TIME_REQUIRED, ); const [urls, setUrls] = useState([]); const [content, setContent] = useState(""); @@ -107,7 +223,8 @@ function AdminQuestDetailRoute() { setTitle(quest.title ?? ""); setCategory(quest.category as Category); setJurisdiction(quest.jurisdiction as Jurisdiction); - setCosts(quest.costs ?? []); + setCosts(quest.costs as Cost[]); + setTimeRequired(quest.timeRequired as TimeRequired); setUrls(quest.urls ?? []); setContent(quest.content ?? ""); } @@ -124,6 +241,7 @@ function AdminQuestDetailRoute() { category: category ?? undefined, jurisdiction: jurisdiction ?? undefined, costs: costs ?? undefined, + timeRequired: timeRequired ?? undefined, urls: urls ?? undefined, content, }).then(() => { @@ -170,31 +288,11 @@ function AdminQuestDetailRoute() { ))} -
- {costs.map((cost, index) => ( - { - const newCosts = [...costs]; - newCosts[index] = value; - setCosts(newCosts); - }} - onRemove={() => { - setCosts(costs.filter((_, i) => i !== index)); - }} - hideLabel={index > 0} - /> - ))} - -
+ +
{urls.map((url, index) => ( { setUrls(urls.filter((_, i) => i !== index)); }} + hideLabel={index > 0} /> ))}