Skip to content

Commit ed38834

Browse files
feat: add customizable category tags to Goal Tracker (#2647) (#3194)
* feat: add customizable category tags to Goal Tracker (#2647) * fix(goals): make category field optional to satisfy existing type fixtures --------- Co-authored-by: Priyanshu Doshi <doshipriyanshu3@gmail.com>
1 parent 51bb10a commit ed38834

5 files changed

Lines changed: 130 additions & 4 deletions

File tree

src/app/api/goals/route.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface Goal {
2020
created_at: string;
2121
goal_reset_version: number;
2222
is_public: boolean;
23+
category: GoalCategory | null;
2324
}
2425

2526
interface GoalHistory {
@@ -32,8 +33,10 @@ interface GoalHistory {
3233
}
3334

3435
type Recurrence = "none" | "weekly" | "monthly";
36+
type GoalCategory = "side-project" | "work" | "dsa" | "open-source";
3537

3638
const VALID_RECURRENCES = ["none", "weekly", "monthly"] as const;
39+
const VALID_CATEGORIES = ["side-project", "work", "dsa", "open-source"] as const;
3740
const MAX_TITLE_LEN = 100;
3841
const MAX_UNIT_LEN = 30;
3942
const MIN_TARGET = 1;
@@ -201,7 +204,7 @@ try {
201204
return Response.json({ error: "Invalid request body" }, { status: 400 });
202205
}
203206

204-
const { title, target, unit, recurrence, deadline } = body as Record<string, unknown>;
207+
const { title, target, unit, recurrence, deadline, category } = body as Record<string, unknown>;
205208

206209
if (typeof title !== "string" || title.trim().length === 0) {
207210
return Response.json({ error: "title must be a non-empty string" }, { status: 400 });
@@ -229,6 +232,9 @@ try {
229232
const safeRecurrence: Recurrence = VALID_RECURRENCES.includes(recurrence as Recurrence)
230233
? (recurrence as Recurrence)
231234
: "none";
235+
const safeCategory: GoalCategory | null = VALID_CATEGORIES.includes(category as GoalCategory)
236+
? (category as GoalCategory)
237+
: null;
232238

233239
let safeDeadline: string | null = null;
234240
if (typeof deadline === "string") {
@@ -285,6 +291,7 @@ try {
285291
recurrence: safeRecurrence,
286292
period_start: getPeriodStart(safeRecurrence),
287293
deadline: safeDeadline,
294+
category: safeCategory,
288295
current: 0,
289296
goal_reset_version: 0,
290297
})
@@ -300,6 +307,7 @@ try {
300307
target: goal.target,
301308
unit: goal.unit,
302309
recurrence: goal.recurrence,
310+
category: goal.category,
303311
}).catch(() => {});
304312

305313
return Response.json({ goal }, { status: 201 });

src/components/GoalTracker.tsx

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import WidgetSkeleton, { SkeletonBlock } from "./WidgetSkeleton";
1212

1313

1414
type Recurrence = "none" | "weekly" | "monthly";
15+
type GoalCategory = "side-project" | "work" | "dsa" | "open-source";
1516

1617
interface Goal {
1718
id: string;
@@ -25,6 +26,7 @@ interface Goal {
2526
period_start: string;
2627
last_synced_at: string | null;
2728
week_start: string | null;
29+
category?: GoalCategory | null;
2830
last_period: {
2931
period_start: string;
3032
period_end: string;
@@ -40,6 +42,22 @@ const RECURRENCE_LABELS: Record<Recurrence, string> = {
4042
monthly: "Monthly",
4143
};
4244

45+
const CATEGORY_LABELS: Record<GoalCategory, string> = {
46+
"side-project": "Side Project",
47+
work: "Work",
48+
dsa: "DSA",
49+
"open-source": "Open Source",
50+
};
51+
52+
const CATEGORY_BADGE_CLASSES: Record<GoalCategory, string> = {
53+
"side-project": "bg-purple-500/10 text-purple-400 border-purple-500/30",
54+
work: "bg-blue-500/10 text-blue-400 border-blue-500/30",
55+
dsa: "bg-amber-500/10 text-amber-400 border-amber-500/30",
56+
"open-source": "bg-emerald-500/10 text-emerald-400 border-emerald-500/30",
57+
};
58+
59+
const CATEGORY_OPTIONS: GoalCategory[] = ["side-project", "work", "dsa", "open-source"];
60+
4361
export function useGoalTracker() {
4462
const [goals, setGoals] = useState<Goal[]>([]);
4563
const [loading, setLoading] = useState(true);
@@ -52,6 +70,8 @@ export function useGoalTracker() {
5270
const [unit, setUnit] = useState("commits");
5371
const [recurrence, setRecurrence] = useState<Recurrence>("none");
5472
const [deadline, setDeadline] = useState("");
73+
const [category, setCategory] = useState<GoalCategory | "">("");
74+
const [activeCategoryFilter, setActiveCategoryFilter] = useState<GoalCategory | null>(null);
5575
const [creating, setCreating] = useState(false);
5676
const [createError, setCreateError] = useState<string | null>(null);
5777
const [confirmingId, setConfirmingId] = useState<string | null>(null);
@@ -162,7 +182,14 @@ export function useGoalTracker() {
162182

163183
try {
164184
const result = await submitGoalWithRefresh({
165-
payload: { title, target, unit, recurrence, deadline: deadline || null },
185+
payload: {
186+
title,
187+
target,
188+
unit,
189+
recurrence,
190+
deadline: deadline || null,
191+
category: category || null,
192+
},
166193
handleSync,
167194
loadGoals,
168195
});
@@ -177,6 +204,7 @@ export function useGoalTracker() {
177204
setUnit("commits");
178205
setRecurrence("none");
179206
setDeadline("");
207+
setCategory("");
180208

181209
if (unit === "commits" || unit === "prs") {
182210
await handleSync();
@@ -294,6 +322,10 @@ export function useGoalTracker() {
294322
setRecurrence,
295323
deadline,
296324
setDeadline,
325+
category,
326+
setCategory,
327+
activeCategoryFilter,
328+
setActiveCategoryFilter,
297329
creating,
298330
createError,
299331
confirmingId,
@@ -332,6 +364,10 @@ export default function GoalTracker() {
332364
setRecurrence,
333365
deadline,
334366
setDeadline,
367+
category,
368+
setCategory,
369+
activeCategoryFilter,
370+
setActiveCategoryFilter,
335371
creating,
336372
createError,
337373
confirmingId,
@@ -530,9 +566,50 @@ export default function GoalTracker() {
530566

531567
</div>
532568
) : (
569+
<>
570+
{goals.some((g) => g.category) && (
571+
<div
572+
role="group"
573+
aria-label="Filter goals by category"
574+
className="mb-3 flex flex-wrap gap-1.5"
575+
>
576+
<button
577+
type="button"
578+
onClick={() => setActiveCategoryFilter(null)}
579+
aria-pressed={activeCategoryFilter === null}
580+
className={`rounded-full border px-2.5 py-1 text-xs font-medium transition ${
581+
activeCategoryFilter === null
582+
? "border-[var(--accent)] bg-[var(--accent)] text-[var(--accent-foreground)]"
583+
: "border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--accent)]"
584+
}`}
585+
>
586+
All
587+
</button>
588+
{CATEGORY_OPTIONS.map((c) => (
589+
<button
590+
key={c}
591+
type="button"
592+
onClick={() =>
593+
setActiveCategoryFilter((curr) => (curr === c ? null : c))
594+
}
595+
aria-pressed={activeCategoryFilter === c}
596+
className={`rounded-full border px-2.5 py-1 text-xs font-medium transition ${
597+
activeCategoryFilter === c
598+
? CATEGORY_BADGE_CLASSES[c].replace("/10", "/20")
599+
: "border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--accent)]"
600+
}`}
601+
>
602+
{CATEGORY_LABELS[c]}
603+
</button>
604+
))}
605+
</div>
606+
)}
607+
533608
<ul className="space-y-4">
534609

535-
{goals.map((goal) => {
610+
{goals
611+
.filter((goal) => !activeCategoryFilter || goal.category === activeCategoryFilter)
612+
.map((goal) => {
536613
const pct =
537614
goal.current > 0 && goal.target > 0
538615
? Math.max(
@@ -567,6 +644,13 @@ export default function GoalTracker() {
567644
<div className="flex flex-col gap-0.5">
568645
<div className="flex items-center gap-2 flex-wrap">
569646
<span className="text-[var(--card-foreground)]">{goal.title}</span>
647+
{goal.category && (
648+
<span
649+
className={`text-xs font-medium px-2 py-0.5 rounded-full border ${CATEGORY_BADGE_CLASSES[goal.category]}`}
650+
>
651+
{CATEGORY_LABELS[goal.category]}
652+
</span>
653+
)}
570654
{goal.recurrence !== "none" && (
571655
<span
572656
className={`text-xs font-medium px-2 py-0.5 rounded-full border ${
@@ -749,6 +833,7 @@ export default function GoalTracker() {
749833
);
750834
})}
751835
</ul>
836+
</>
752837
)}
753838

754839
{lastUpdated && (
@@ -825,6 +910,29 @@ export default function GoalTracker() {
825910
</div>
826911
</div>
827912

913+
<div>
914+
<label
915+
htmlFor="goal-category"
916+
className="mb-1 block text-xs font-medium uppercase tracking-wide text-[var(--muted-foreground)]"
917+
>
918+
Category (Optional)
919+
</label>
920+
<select
921+
id="goal-category"
922+
value={category}
923+
onChange={(e) => setCategory(e.target.value as GoalCategory | "")}
924+
disabled={creating}
925+
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm text-[var(--foreground)] transition focus-visible:border-[var(--accent)]"
926+
>
927+
<option value="">No category</option>
928+
{CATEGORY_OPTIONS.map((c) => (
929+
<option key={c} value={c}>
930+
{CATEGORY_LABELS[c]}
931+
</option>
932+
))}
933+
</select>
934+
</div>
935+
828936
{recurrence === "none" && (
829937
<div>
830938
<label

src/lib/goal-tracker.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
type Recurrence = "none" | "weekly" | "monthly";
2+
export type GoalCategory = "side-project" | "work" | "dsa" | "open-source";
23

34
export interface CreateGoalPayload {
45
title: string;
56
target: number;
67
unit: string;
78
recurrence: Recurrence;
89
deadline: string | null;
10+
category?: GoalCategory | null;
911
}
1012

1113
interface SubmitGoalOptions {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Add customizable category tags to goals (e.g. Side Project, Work, DSA, Open Source)
2+
alter table goals
3+
add column if not exists category text
4+
check (category is null or category in ('side-project', 'work', 'dsa', 'open-source'));
5+
6+
create index if not exists goals_user_category on goals(user_id, category);

supabase/schema.sql

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,11 @@ create table if not exists goals (
5252
last_synced_at timestamptz,
5353
created_at timestamptz default now(),
5454
updated_at timestamptz default now(),
55-
week_start date
55+
week_start date,
56+
category text check (category is null or category in ('side-project', 'work', 'dsa', 'open-source'))
5657
);
5758
create index if not exists goals_user_period on goals(user_id, period_start);
59+
create index if not exists goals_user_category on goals(user_id, category);
5860

5961
create table if not exists goal_history (
6062
id text primary key default gen_random_uuid()::text,

0 commit comments

Comments
 (0)