diff --git a/.changeset/cool-tips-buy.md b/.changeset/cool-tips-buy.md new file mode 100644 index 0000000..756831a --- /dev/null +++ b/.changeset/cool-tips-buy.md @@ -0,0 +1,5 @@ +--- +"namesake": minor +--- + +Add additional statuses for tracking core quests and improve status selection for quests diff --git a/convex/constants.ts b/convex/constants.ts index ce3cfc3..bf0fc87 100644 --- a/convex/constants.ts +++ b/convex/constants.ts @@ -5,10 +5,13 @@ import { RiBankLine, RiCalendarLine, RiChat3Line, - RiCheckLine, + RiCheckboxBlankCircleLine, + RiCheckboxCircleFill, RiCheckboxLine, RiComputerLine, RiDropdownList, + RiFolderCheckLine, + RiFolderLine, RiGamepadLine, RiGraduationCapLine, RiHashtag, @@ -85,10 +88,24 @@ export const JURISDICTIONS = { } as const; export type Jurisdiction = keyof typeof JURISDICTIONS; +/** + * Fields for input forms. + */ interface FieldDetails { label: string; icon: RemixiconComponentType; } + +export type Field = + | "text" + | "textarea" + | "date" + | "select" + | "checkbox" + | "number" + | "email" + | "phone"; + export const FIELDS: Record = { text: { label: "Text", @@ -123,7 +140,6 @@ export const FIELDS: Record = { icon: RiPhoneLine, }, } as const; -export type Field = keyof typeof FIELDS; export const THEMES = { system: "System", @@ -146,12 +162,37 @@ export const GROUP_QUESTS_BY = { } as const; export type GroupQuestsBy = keyof typeof GROUP_QUESTS_BY; +/** + * Generic group details. + * Used for UI display of filter groups. + */ interface GroupDetails { label: string; icon: RemixiconComponentType; } -export const CATEGORIES: Record = { +/** + * Categories. + * Used to filter quests in the quests list. + */ +export type Category = + | "core" + | "entertainment" + | "devices" + | "education" + | "finance" + | "gaming" + | "government" + | "health" + | "housing" + | "personal" + | "shopping" + | "social" + | "subscriptions" + | "travel" + | "other"; + +export const CATEGORIES: Record = { core: { label: "Core", icon: RiSignpostLine, @@ -213,10 +254,16 @@ 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 = { +/** + * Date added filters. + * Used to filter quests in the quests list. + */ +export type DateAdded = "lastWeek" | "lastMonth" | "earlier"; + +export const DATE_ADDED: Record = { lastWeek: { label: "Last 7 days", icon: RiTimeLine, @@ -230,20 +277,55 @@ export const DATE_ADDED: Record = { icon: RiHistoryLine, }, }; + 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", +/** + * User quest statuses. + * "readyToFile" and "filed" are only available for core quests. + * "notStarted", "inProgress", and "complete" are available for all quests. + */ +export type Status = + | "notStarted" + | "inProgress" + | "readyToFile" + | "filed" + | "complete"; + +interface StatusDetails extends GroupDetails { + variant?: "info" | "success" | "danger" | "warning" | "waiting"; + isCoreOnly?: boolean; +} + +export const STATUS: Record = { + notStarted: { + label: "Not started", + icon: RiCheckboxBlankCircleLine, + }, + inProgress: { + label: "In progress", icon: RiProgress4Line, + variant: "warning", + }, + readyToFile: { + label: "Ready to file", + icon: RiFolderLine, + isCoreOnly: true, + variant: "info", + }, + filed: { + label: "Filed", + icon: RiFolderCheckLine, + isCoreOnly: true, + variant: "waiting", }, complete: { label: "Completed", - icon: RiCheckLine, + icon: RiCheckboxCircleFill, + variant: "success", }, } 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 cd80f29..621fe13 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -7,6 +7,7 @@ import { groupQuestsBy, jurisdiction, role, + status, theme, } from "./validators"; @@ -93,11 +94,13 @@ const users = defineTable({ * Represents a user's unique progress in completing a quest. * @param userId * @param questId + * @param status - The status of the quest. * @param completionTime - Time in ms since epoch when the user marked the quest as complete. */ const userQuests = defineTable({ userId: v.id("users"), questId: v.id("quests"), + status: status, completionTime: v.optional(v.number()), }) .index("userId", ["userId"]) diff --git a/convex/userQuests.ts b/convex/userQuests.ts index eed6526..1f6025d 100644 --- a/convex/userQuests.ts +++ b/convex/userQuests.ts @@ -1,7 +1,8 @@ import { v } from "convex/values"; import { query } from "./_generated/server"; -import type { Category } from "./constants"; +import { type Category, STATUS, type Status } from "./constants"; import { userMutation, userQuery } from "./helpers"; +import { status } from "./validators"; // TODO: Add `returns` value validation // https://docs.convex.dev/functions/validation @@ -104,6 +105,7 @@ export const create = userMutation({ await ctx.db.insert("userQuests", { userId: ctx.userId, questId: args.questId, + status: "notStarted", }); }, }); @@ -121,27 +123,59 @@ export const getUserQuestByQuestId = userQuery({ }, }); -export const markComplete = userMutation({ +export const getUserQuestStatus = userQuery({ args: { questId: v.id("quests") }, - returns: v.null(), + returns: v.string(), handler: async (ctx, args) => { const userQuest = await getUserQuestByQuestId(ctx, { questId: args.questId, }); if (userQuest === null) throw new Error("Quest not found"); - await ctx.db.patch(userQuest._id, { completionTime: Date.now() }); + return userQuest.status; }, }); -export const markIncomplete = userMutation({ - args: { questId: v.id("quests") }, +export const updateQuestStatus = userMutation({ + args: { questId: v.id("quests"), status: status }, returns: v.null(), handler: async (ctx, args) => { + console.log("updateQuestStatus", args); + const quest = await ctx.db.get(args.questId); + if (quest === null) throw new Error("Quest not found"); + const userQuest = await getUserQuestByQuestId(ctx, { questId: args.questId, }); - if (userQuest === null) throw new Error("Quest not found"); - await ctx.db.patch(userQuest._id, { completionTime: undefined }); + if (userQuest === null) throw new Error("User quest not found"); + + // Prevent setting "ready to file" and "filed" on non-core quests + if ( + STATUS[args.status as Status].isCoreOnly === true && + quest.category !== "core" + ) + throw new Error("This status is reserved for core quests only."); + + // Prevent setting the existing status + if (userQuest.status === args.status) return; + + // If the status is changing to complete, set the completion time + if (args.status === "complete") { + await ctx.db.patch(userQuest._id, { + status: args.status, + completionTime: Date.now(), + }); + } + + // If the status was already complete, unset completion time + if (userQuest.status === "complete") { + await ctx.db.patch(userQuest._id, { + status: args.status, + completionTime: undefined, + }); + } + + // Otherwise, just update the status + await ctx.db.patch(userQuest._id, { status: args.status }); }, }); @@ -284,16 +318,19 @@ export const getUserQuestsByStatus = userQuery({ (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, - ); + // Initialize an object with all possible status keys + const initial: Record = { + notStarted: [], + inProgress: [], + readyToFile: [], + filed: [], + complete: [], + }; + + // Group quests by their status + return validQuests.reduce((acc, quest) => { + acc[quest.status].push(quest); + return acc; + }, initial); }, }); diff --git a/convex/validators.ts b/convex/validators.ts index 7288f18..c171688 100644 --- a/convex/validators.ts +++ b/convex/validators.ts @@ -5,6 +5,7 @@ import { GROUP_QUESTS_BY, JURISDICTIONS, ROLES, + STATUS, THEMES, } from "./constants"; @@ -12,6 +13,10 @@ export const jurisdiction = v.union( ...Object.keys(JURISDICTIONS).map((jurisdiction) => v.literal(jurisdiction)), ); +export const status = v.union( + ...Object.keys(STATUS).map((status) => v.literal(status)), +); + export const theme = v.union( ...Object.keys(THEMES).map((theme) => v.literal(theme)), ); diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index b1e336f..57b335b 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -4,7 +4,7 @@ import { tv } from "tailwind-variants"; export interface BadgeProps extends React.HTMLAttributes { children: React.ReactNode; size?: "sm" | "lg"; - variant?: "info" | "success" | "danger" | "warning"; + variant?: "info" | "success" | "danger" | "warning" | "waiting"; icon?: RemixiconComponentType; } @@ -13,13 +13,14 @@ const badge = tv({ variants: { size: { sm: "text-xs rounded", - lg: "text-sm rounded-md", + lg: "text-sm rounded-md px-2 gap-1.5", }, variant: { - info: "bg-blue-3 dark:bg-bluedark-3 text-blue-normal", - success: "bg-green-3 dark:bg-greendark-3 text-green-normal", - danger: "bg-red-3 dark:bg-reddark-3 text-red-normal", - warning: "bg-amber-3 dark:bg-amberdark-3 text-amber-normal", + info: "bg-bluea-3 dark:bg-bluedarka-3 text-blue-normal", + success: "bg-greena-3 dark:bg-greendarka-3 text-green-normal", + danger: "bg-reda-3 dark:bg-reddarka-3 text-red-normal", + warning: "bg-ambera-3 dark:bg-amberdarka-3 text-amber-normal", + waiting: "bg-purplea-3 dark:bg-purpledarka-3 text-purple-normal", }, }, defaultVariants: { @@ -36,6 +37,7 @@ const icon = tv({ success: "text-green-dim", danger: "text-red-dim", warning: "text-amber-dim", + waiting: "text-purple-dim", }, }, }); diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx index c9c501d..1822bbf 100644 --- a/src/components/Checkbox/Checkbox.tsx +++ b/src/components/Checkbox/Checkbox.tsx @@ -58,16 +58,16 @@ const boxStyles = tv({ true: "bg-purple-9 dark:bg-purpledark-9 border-transparent", }, isInvalid: { - true: "text-red-9 dark:text-reddark-9 forced-colors:![--color:Mark]", + true: "text-red-9 dark:text-reddark-9", }, isDisabled: { - true: "text-gray-7 dark:text-graydark-7 forced-colors:![--color:GrayText]", + true: "text-gray-7 dark:text-graydark-7", }, }, }); const iconStyles = - "w-5 h-5 text-white group-disabled:text-gray-4 dark:group-disabled:text-gray-9 forced-colors:text-[HighlightText]"; + "w-5 h-5 text-white group-disabled:text-gray-4 dark:group-disabled:text-gray-9"; export interface CheckboxProps extends AriaCheckboxProps { label?: string; diff --git a/src/components/ListBox/ListBox.tsx b/src/components/ListBox/ListBox.tsx index 45909de..7bedaa4 100644 --- a/src/components/ListBox/ListBox.tsx +++ b/src/components/ListBox/ListBox.tsx @@ -38,7 +38,7 @@ export const itemStyles = tv({ variants: { isSelected: { false: "text-gray-normal -outline-offset-2", - true: "bg-purple-9 dark:bg-purpledark-9 text-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText] [&:has(+[data-selected])]:rounded-b-none [&+[data-selected]]:rounded-t-none -outline-offset-4 outline-white dark:outline-white forced-colors:outline-[HighlightText]", + true: "bg-gray-3 dark:bg-graydark-3 text-gray-normalforced-colors:bg-[Highlight] forced-colors:text-[HighlightText] [&:has(+[data-selected])]:rounded-b-none [&+[data-selected]]:rounded-t-none -outline-offset-4 outline-white dark:outline-white forced-colors:outline-[HighlightText]", }, isDisabled: { true: "text-gray-dim opacity-50 cursor-default forced-colors:text-[GrayText]", @@ -63,14 +63,14 @@ export function ListBoxItem(props: ListBoxItemProps) { } export const dropdownItemStyles = tv({ - base: "group flex items-center gap-4 cursor-pointer select-none py-2 px-3 rounded-lg outline outline-0 text-sm forced-color-adjust-none", + base: "group flex items-center gap-1.5 cursor-pointer select-none py-2 px-2.5 rounded-lg outline outline-0 text-sm forced-color-adjust-none", variants: { isDisabled: { false: "text-gray-normal", true: "text-gray-dim opacity-50 forced-colors:text-[GrayText] cursor-default", }, isFocused: { - true: "bg-purple-9 dark:bg-purpledark-9 text-white forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]", + true: "bg-gray-3 dark:bg-graydark-3 text-gray-normal forced-colors:bg-[Highlight] forced-colors:text-[HighlightText]", }, }, compoundVariants: [ diff --git a/src/components/PageHeader/PageHeader.tsx b/src/components/PageHeader/PageHeader.tsx index c5ca7f8..88ae348 100644 --- a/src/components/PageHeader/PageHeader.tsx +++ b/src/components/PageHeader/PageHeader.tsx @@ -33,7 +33,7 @@ export const PageHeader = ({ {subtitle &&

{subtitle}

} - {children} +
{children}
); }; diff --git a/src/components/StatusSelect/StatusSelect.stories.tsx b/src/components/StatusSelect/StatusSelect.stories.tsx new file mode 100644 index 0000000..cfac080 --- /dev/null +++ b/src/components/StatusSelect/StatusSelect.stories.tsx @@ -0,0 +1,24 @@ +import type { Status } from "@convex/constants"; +import type { Meta } from "@storybook/react"; +import { StatusSelect } from "./StatusSelect"; + +const meta: Meta = { + component: StatusSelect, +}; + +export default meta; + +export const Example = (args: any) => ; + +Example.args = { + status: "notStarted", + onChange: (status: Status) => console.log("Status changed to:", status), +}; + +export const WithCoreStatuses = (args: any) => ; + +WithCoreStatuses.args = { + status: "readyToFile", + onChange: (status: Status) => console.log("Status changed to:", status), + isCore: true, +}; diff --git a/src/components/StatusSelect/StatusSelect.tsx b/src/components/StatusSelect/StatusSelect.tsx new file mode 100644 index 0000000..425988b --- /dev/null +++ b/src/components/StatusSelect/StatusSelect.tsx @@ -0,0 +1,70 @@ +import { STATUS, type Status } from "@convex/constants"; +import { RiArrowDropDownFill } from "@remixicon/react"; +import { useState } from "react"; +import type { Selection } from "react-aria-components"; +import { + Badge, + type BadgeProps, + Button, + Menu, + MenuItem, + MenuTrigger, +} from "../"; + +interface StatusBadgeProps extends Omit { + status: Status; +} + +export function StatusBadge({ status, ...props }: StatusBadgeProps) { + if (status === undefined) return null; + + const { label, icon, variant } = STATUS[status]; + + return ( + + {label} + + ); +} + +interface StatusSelectProps { + status: Status; + isCore?: boolean; + onChange: (status: Status) => void; +} + +export function StatusSelect({ status, isCore, onChange }: StatusSelectProps) { + const [selectedStatus, setSelectedStatus] = useState( + new Set([status]), + ); + + const handleSelectionChange = (status: Selection) => { + onChange([...status][0] as Status); + setSelectedStatus(status); + }; + + return ( + + + + {Object.entries(STATUS).map(([status, details]) => { + if (!isCore && details.isCoreOnly) return null; + return ( + + + + ); + })} + + + ); +} diff --git a/src/components/StatusSelect/index.ts b/src/components/StatusSelect/index.ts new file mode 100644 index 0000000..39cfe91 --- /dev/null +++ b/src/components/StatusSelect/index.ts @@ -0,0 +1 @@ +export * from "./StatusSelect"; diff --git a/src/components/index.ts b/src/components/index.ts index 594e6d1..591c6d5 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -14,6 +14,7 @@ export * from "./DateField"; export * from "./DatePicker"; export * from "./DateRangePicker"; export * from "./Dialog"; +export * from "./Disclosure"; export * from "./Empty"; export * from "./Field"; export * from "./FileTrigger"; @@ -38,6 +39,7 @@ export * from "./SearchField"; export * from "./Select"; export * from "./Separator"; export * from "./Slider"; +export * from "./StatusSelect"; export * from "./Switch"; export * from "./Table"; export * from "./Tabs"; diff --git a/src/routes/_authenticated/_home.quests.$questId.tsx b/src/routes/_authenticated/_home.quests.$questId.tsx index b5b79b1..558331a 100644 --- a/src/routes/_authenticated/_home.quests.$questId.tsx +++ b/src/routes/_authenticated/_home.quests.$questId.tsx @@ -6,19 +6,14 @@ import { Link, Menu, MenuItem, - MenuSeparator, MenuTrigger, PageHeader, + StatusSelect, } from "@/components"; import { api } from "@convex/_generated/api"; import type { Id } from "@convex/_generated/dataModel"; -import { - RiCheckLine, - RiLink, - RiMoreFill, - RiProgress4Line, - RiSignpostLine, -} from "@remixicon/react"; +import type { Status } from "@convex/constants"; +import { RiLink, RiMoreFill, RiSignpostLine } from "@remixicon/react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useMutation, useQuery } from "convex/react"; import Markdown from "react-markdown"; @@ -38,20 +33,14 @@ function QuestDetailRoute() { const userQuest = useQuery(api.userQuests.getUserQuestByQuestId, { questId: questId as Id<"quests">, }); - const markComplete = useMutation(api.userQuests.markComplete); - const markIncomplete = useMutation(api.userQuests.markIncomplete); + + const changeStatus = useMutation(api.userQuests.updateQuestStatus); const removeQuest = useMutation(api.userQuests.removeQuest); - const handleMarkComplete = (questId: Id<"quests">, title: string) => { - markComplete({ questId }).then(() => { - toast(`Marked ${title} complete`); - }); - }; - const handleMarkIncomplete = (questId: Id<"quests">, title: string) => { - markIncomplete({ questId }).then(() => { - toast(`Marked ${title} as in progress`); - }); + const handleStatusChange = (status: Status) => { + changeStatus({ questId: questId as Id<"quests">, status: status }); }; + const handleRemoveQuest = (questId: Id<"quests">, title: string) => { removeQuest({ questId }).then(() => { toast(`Removed ${title} quest`); @@ -68,22 +57,14 @@ function QuestDetailRoute() {
- {quest.jurisdiction && {quest.jurisdiction}} - {userQuest?.completionTime ? ( - - Complete - - ) : ( - - In progress - - )} -
- } + badge={quest.jurisdiction && {quest.jurisdiction}} className="px-6 border-b border-gray-dim h-16" > +