Skip to content

Commit

Permalink
feat: Add "ready to file", "filed", and "not started" statuses (#192)
Browse files Browse the repository at this point in the history
  • Loading branch information
evadecker authored Nov 16, 2024
1 parent 061a174 commit ba1c5a9
Show file tree
Hide file tree
Showing 19 changed files with 328 additions and 116 deletions.
5 changes: 5 additions & 0 deletions .changeset/cool-tips-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"namesake": minor
---

Add additional statuses for tracking core quests and improve status selection for quests
104 changes: 93 additions & 11 deletions convex/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import {
RiBankLine,
RiCalendarLine,
RiChat3Line,
RiCheckLine,
RiCheckboxBlankCircleLine,
RiCheckboxCircleFill,
RiCheckboxLine,
RiComputerLine,
RiDropdownList,
RiFolderCheckLine,
RiFolderLine,
RiGamepadLine,
RiGraduationCapLine,
RiHashtag,
Expand Down Expand Up @@ -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<string, FieldDetails> = {
text: {
label: "Text",
Expand Down Expand Up @@ -123,7 +140,6 @@ export const FIELDS: Record<string, FieldDetails> = {
icon: RiPhoneLine,
},
} as const;
export type Field = keyof typeof FIELDS;

export const THEMES = {
system: "System",
Expand All @@ -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<string, GroupDetails> = {
/**
* 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<Category, GroupDetails> = {
core: {
label: "Core",
icon: RiSignpostLine,
Expand Down Expand Up @@ -213,10 +254,16 @@ export const CATEGORIES: Record<string, GroupDetails> = {
icon: RiQuestionLine,
},
};

export const CATEGORY_ORDER: Category[] = Object.keys(CATEGORIES) as Category[];
export type Category = keyof typeof CATEGORIES;

export const DATE_ADDED: Record<string, GroupDetails> = {
/**
* Date added filters.
* Used to filter quests in the quests list.
*/
export type DateAdded = "lastWeek" | "lastMonth" | "earlier";

export const DATE_ADDED: Record<DateAdded, GroupDetails> = {
lastWeek: {
label: "Last 7 days",
icon: RiTimeLine,
Expand All @@ -230,20 +277,55 @@ export const DATE_ADDED: Record<string, GroupDetails> = {
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<string, GroupDetails> = {
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<Status, StatusDetails> = {
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;
3 changes: 3 additions & 0 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
groupQuestsBy,
jurisdiction,
role,
status,
theme,
} from "./validators";

Expand Down Expand Up @@ -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"])
Expand Down
75 changes: 56 additions & 19 deletions convex/userQuests.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 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
Expand Down Expand Up @@ -104,6 +105,7 @@ export const create = userMutation({
await ctx.db.insert("userQuests", {
userId: ctx.userId,
questId: args.questId,
status: "notStarted",
});
},
});
Expand All @@ -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 });
},
});

Expand Down Expand Up @@ -284,16 +318,19 @@ export const getUserQuestsByStatus = userQuery({
(q): q is NonNullable<typeof q> => 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<string, typeof validQuests>,
);
// Initialize an object with all possible status keys
const initial: Record<string, typeof validQuests> = {
notStarted: [],
inProgress: [],
readyToFile: [],
filed: [],
complete: [],
};

// Group quests by their status
return validQuests.reduce((acc, quest) => {
acc[quest.status].push(quest);
return acc;
}, initial);
},
});
5 changes: 5 additions & 0 deletions convex/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ import {
GROUP_QUESTS_BY,
JURISDICTIONS,
ROLES,
STATUS,
THEMES,
} from "./constants";

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)),
);
Expand Down
14 changes: 8 additions & 6 deletions src/components/Badge/Badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tv } from "tailwind-variants";
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
size?: "sm" | "lg";
variant?: "info" | "success" | "danger" | "warning";
variant?: "info" | "success" | "danger" | "warning" | "waiting";
icon?: RemixiconComponentType;
}

Expand All @@ -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: {
Expand All @@ -36,6 +37,7 @@ const icon = tv({
success: "text-green-dim",
danger: "text-red-dim",
warning: "text-amber-dim",
waiting: "text-purple-dim",
},
},
});
Expand Down
6 changes: 3 additions & 3 deletions src/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading

0 comments on commit ba1c5a9

Please sign in to comment.