Skip to content

Commit

Permalink
Add time required
Browse files Browse the repository at this point in the history
  • Loading branch information
evadecker committed Nov 17, 2024
1 parent 3904cb1 commit 1a42c47
Show file tree
Hide file tree
Showing 12 changed files with 273 additions and 93 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
33 changes: 33 additions & 0 deletions convex/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,3 +329,36 @@ 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;
};

export const FREE = "free";

/**
* Estimated time units.
* Used to display estimated time in quest details.
*/
export const ESTIMATED_TIME_UNITS = {
minutes: "Minutes",
hours: "Hours",
days: "Days",
weeks: "Weeks",
months: "Months",
} as const;

export type TimeRequiredUnit = keyof typeof ESTIMATED_TIME_UNITS;

export type TimeRequired = {
min: number;
max: number;
unit: TimeRequiredUnit;
};

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
5 changes: 5 additions & 0 deletions convex/validators.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { v } from "convex/values";
import {
CATEGORIES,
ESTIMATED_TIME_UNITS,
FIELDS,
GROUP_QUESTS_BY,
JURISDICTIONS,
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(ESTIMATED_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
136 changes: 80 additions & 56 deletions src/routes/_authenticated/_home/quests.$questId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
} from "@/components";
import { api } from "@convex/_generated/api";
import type { Id } from "@convex/_generated/dataModel";
import type { Status } from "@convex/constants";
import type { Cost, Status, TimeRequired } from "@convex/constants";
import {
RiLink,
RiMoreFill,
Expand All @@ -33,68 +33,87 @@ export const Route = createFileRoute("/_authenticated/_home/quests/$questId")({
component: QuestDetailRoute,
});

const getTotalCosts = (costs?: { cost: number; description: string }[]) => {
if (!costs) return "Free";
const StatGroup = ({
label,
value,
children,
}: { label: string; value: string; children?: React.ReactNode }) => (
<div className="flex flex-col">
<div className="text-gray-dim">{label}</div>
<div className="text-2xl flex gap-0.5 items-center">
{value}
{children}
</div>
</div>
);

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 (
<div className="flex flex-col mb-4">
<div className="flex flex-col">
<div className="text-gray-dim">Cost</div>
<div className="text-2xl flex gap-0.5 items-center">
{totalCosts}
{costs?.length && (
<DialogTrigger>
<TooltipTrigger>
<Button variant="icon" size="small">
<RiQuestionLine />
</Button>
<Tooltip>See cost breakdown</Tooltip>
</TooltipTrigger>
<Popover className="p-4">
<dl className="grid grid-cols-[1fr_auto]">
{costs.map(({ cost, description }) => (
<Fragment key={description}>
<dt className="text-gray-dim pr-4">{description}</dt>
<dd className="text-right">
{cost.toLocaleString("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
})}
</dd>
</Fragment>
))}
<dt className="text-gray-dim pr-4 border-t border-gray-dim pt-2 mt-2">
Total
</dt>
<dd className="text-right border-t border-gray-dim pt-2 mt-2">
{totalCosts}
<StatGroup label="Cost" value={getTotalCosts(costs)}>
{costs?.length && (
<DialogTrigger>
<TooltipTrigger>
<Button variant="icon" size="small">
<RiQuestionLine />
</Button>
<Tooltip>See cost breakdown</Tooltip>
</TooltipTrigger>
<Popover className="p-4">
<dl className="grid grid-cols-[1fr_auto]">
{costs.map(({ cost, description }) => (
<Fragment key={description}>
<dt className="text-gray-dim pr-4">{description}</dt>
<dd className="text-right">
{cost.toLocaleString("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
})}
</dd>
</dl>
</Popover>
</DialogTrigger>
)}
</div>
</div>
</div>
</Fragment>
))}
<dt className="text-gray-dim pr-4 border-t border-gray-dim pt-2 mt-2">
Total
</dt>
<dd className="text-right border-t border-gray-dim pt-2 mt-2">
{getTotalCosts(costs)}
</dd>
</dl>
</Popover>
</DialogTrigger>
)}
</StatGroup>
);
};

const QuestTimeRequired = ({
timeRequired,
}: {
timeRequired: TimeRequired;
}) => {
const getFormattedTime = (timeRequired: TimeRequired) => {
return timeRequired
? `${timeRequired.min}${timeRequired.max} ${timeRequired.unit}`
: "Unknown";
};

const formattedTime = getFormattedTime(timeRequired);
return <StatGroup label="Time" value={formattedTime} />;
};

function QuestDetailRoute() {
const { questId } = Route.useParams();
const navigate = useNavigate();
Expand Down Expand Up @@ -154,7 +173,12 @@ function QuestDetailRoute() {
</MenuTrigger>
</PageHeader>
<Container className="overflow-y-auto">
<QuestCosts costs={quest.costs} />
<div className="flex gap-4 mb-4">
<QuestCosts costs={quest.costs} />
<QuestTimeRequired
timeRequired={quest.timeRequired as TimeRequired}
/>
</div>
{quest.urls && (
<div className="flex flex-col items-start gap-1 mb-4">
{quest.urls.map((url) => (
Expand Down
Loading

0 comments on commit 1a42c47

Please sign in to comment.