Skip to content

Commit

Permalink
feat: Display time estimates for each quest (#198)
Browse files Browse the repository at this point in the history
  • Loading branch information
evadecker authored Nov 17, 2024
1 parent fa35737 commit 03cc1ec
Show file tree
Hide file tree
Showing 14 changed files with 378 additions and 107 deletions.
5 changes: 5 additions & 0 deletions .changeset/swift-suns-laugh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"namesake": minor
---

Display time required to complete a quest
58 changes: 56 additions & 2 deletions convex/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import {
RiAccountCircleLine,
RiAtLine,
RiBankLine,
RiCalendar2Line,
RiCalendarLine,
RiCalendarScheduleLine,
RiChat3Line,
RiCheckboxBlankCircleLine,
RiCheckboxCircleFill,
RiCheckboxLine,
RiComputerLine,
RiDropdownList,
RiFlashlightLine,
RiFolderCheckLine,
RiFolderLine,
RiGamepadLine,
Expand Down Expand Up @@ -159,17 +162,18 @@ 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;

/**
* Generic group details.
* Used for UI display of filter groups.
*/
interface GroupDetails {
export type GroupDetails = {
label: string;
icon: RemixiconComponentType;
}
};

/**
* Categories.
Expand Down Expand Up @@ -329,3 +333,53 @@ export const STATUS: Record<Status, StatusDetails> = {
} 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<TimeUnit, GroupDetails> = {
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",
};
12 changes: 11 additions & 1 deletion convex/quests.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -51,6 +52,7 @@ export const createQuest = userMutation({
title: args.title,
category: args.category,
jurisdiction: args.jurisdiction,
timeRequired: DEFAULT_TIME_REQUIRED,
creationUser: ctx.userId,
});
},
Expand All @@ -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()),
},
Expand All @@ -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,
});
Expand Down
7 changes: 7 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
role,
status,
theme,
timeRequiredUnit,
} from "./validators";

/**
Expand All @@ -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.
Expand All @@ -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()),
Expand Down
3 changes: 2 additions & 1 deletion convex/seed.ts
Original file line number Diff line number Diff line change
@@ -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") {
Expand Down Expand Up @@ -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})`);
Expand Down
47 changes: 44 additions & 3 deletions convex/userQuests.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -319,7 +319,7 @@ export const getUserQuestsByStatus = userQuery({
);

// Initialize an object with all possible status keys
const initial: Record<string, typeof validQuests> = {
const initial: Record<Status, typeof validQuests> = {
notStarted: [],
inProgress: [],
readyToFile: [],
Expand All @@ -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<typeof q> => q !== null,
);

// Initialize an object with all possible time required keys
const initial: Record<TimeUnit, typeof validQuests> = {
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;
},
});
5 changes: 5 additions & 0 deletions convex/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ROLES,
STATUS,
THEMES,
TIME_UNITS,
} from "./constants";

export const jurisdiction = v.union(
Expand Down Expand Up @@ -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)),
);
2 changes: 1 addition & 1 deletion src/components/NumberField/NumberField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)}
>
<Label>{label}</Label>
Expand Down
2 changes: 1 addition & 1 deletion src/components/Select/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export function Select<T extends object>({
{...props}
className={composeTailwindRenderProps(
props.className,
"group flex flex-col gap-2",
"group flex flex-col gap-1.5",
)}
>
{label && <Label>{label}</Label>}
Expand Down
2 changes: 1 addition & 1 deletion src/components/TextArea/TextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function TextArea({
{...props}
className={composeTailwindRenderProps(
props.className,
"flex flex-col gap-2",
"flex flex-col gap-1.5",
)}
>
{label && <Label>{label}</Label>}
Expand Down
2 changes: 1 addition & 1 deletion src/components/TextField/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 33 additions & 8 deletions src/routes/_authenticated/_home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
}
}

Expand All @@ -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;
Expand All @@ -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 (
Expand All @@ -136,8 +150,9 @@ function IndexRoute() {
>
<MenuSection title="Group by">
<MenuItem id="category">Category</MenuItem>
<MenuItem id="dateAdded">Date added</MenuItem>
<MenuItem id="status">Status</MenuItem>
<MenuItem id="dateAdded">Date added</MenuItem>
<MenuItem id="timeRequired">Time required</MenuItem>
</MenuSection>
</Menu>
</MenuTrigger>
Expand Down Expand Up @@ -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 (
<Disclosure
Expand Down
Loading

0 comments on commit 03cc1ec

Please sign in to comment.