Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/app/api/goals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ interface Goal {
created_at: string;
goal_reset_version: number;
is_public: boolean;
category: GoalCategory | null;
}

interface GoalHistory {
Expand All @@ -32,8 +33,10 @@ interface GoalHistory {
}

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

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

const { title, target, unit, recurrence, deadline } = body as Record<string, unknown>;
const { title, target, unit, recurrence, deadline, category } = body as Record<string, unknown>;

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

let safeDeadline: string | null = null;
if (typeof deadline === "string") {
Expand Down Expand Up @@ -285,6 +291,7 @@ try {
recurrence: safeRecurrence,
period_start: getPeriodStart(safeRecurrence),
deadline: safeDeadline,
category: safeCategory,
current: 0,
goal_reset_version: 0,
})
Expand All @@ -300,6 +307,7 @@ try {
target: goal.target,
unit: goal.unit,
recurrence: goal.recurrence,
category: goal.category,
}).catch(() => {});

return Response.json({ goal }, { status: 201 });
Expand Down
112 changes: 110 additions & 2 deletions src/components/GoalTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import WidgetSkeleton, { SkeletonBlock } from "./WidgetSkeleton";


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

interface Goal {
id: string;
Expand All @@ -25,6 +26,7 @@ interface Goal {
period_start: string;
last_synced_at: string | null;
week_start: string | null;
category: GoalCategory | null;
last_period: {
period_start: string;
period_end: string;
Expand All @@ -40,6 +42,22 @@ const RECURRENCE_LABELS: Record<Recurrence, string> = {
monthly: "Monthly",
};

const CATEGORY_LABELS: Record<GoalCategory, string> = {
"side-project": "Side Project",
work: "Work",
dsa: "DSA",
"open-source": "Open Source",
};

const CATEGORY_BADGE_CLASSES: Record<GoalCategory, string> = {
"side-project": "bg-purple-500/10 text-purple-400 border-purple-500/30",
work: "bg-blue-500/10 text-blue-400 border-blue-500/30",
dsa: "bg-amber-500/10 text-amber-400 border-amber-500/30",
"open-source": "bg-emerald-500/10 text-emerald-400 border-emerald-500/30",
};

const CATEGORY_OPTIONS: GoalCategory[] = ["side-project", "work", "dsa", "open-source"];

export function useGoalTracker() {
const [goals, setGoals] = useState<Goal[]>([]);
const [loading, setLoading] = useState(true);
Expand All @@ -52,6 +70,8 @@ export function useGoalTracker() {
const [unit, setUnit] = useState("commits");
const [recurrence, setRecurrence] = useState<Recurrence>("none");
const [deadline, setDeadline] = useState("");
const [category, setCategory] = useState<GoalCategory | "">("");
const [activeCategoryFilter, setActiveCategoryFilter] = useState<GoalCategory | null>(null);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [confirmingId, setConfirmingId] = useState<string | null>(null);
Expand Down Expand Up @@ -162,7 +182,14 @@ export function useGoalTracker() {

try {
const result = await submitGoalWithRefresh({
payload: { title, target, unit, recurrence, deadline: deadline || null },
payload: {
title,
target,
unit,
recurrence,
deadline: deadline || null,
category: category || null,
},
handleSync,
loadGoals,
});
Expand All @@ -177,6 +204,7 @@ export function useGoalTracker() {
setUnit("commits");
setRecurrence("none");
setDeadline("");
setCategory("");

if (unit === "commits" || unit === "prs") {
await handleSync();
Expand Down Expand Up @@ -294,6 +322,10 @@ export function useGoalTracker() {
setRecurrence,
deadline,
setDeadline,
category,
setCategory,
activeCategoryFilter,
setActiveCategoryFilter,
creating,
createError,
confirmingId,
Expand Down Expand Up @@ -332,6 +364,10 @@ export default function GoalTracker() {
setRecurrence,
deadline,
setDeadline,
category,
setCategory,
activeCategoryFilter,
setActiveCategoryFilter,
creating,
createError,
confirmingId,
Expand Down Expand Up @@ -530,9 +566,50 @@ export default function GoalTracker() {

</div>
) : (
<>
{goals.some((g) => g.category) && (
<div
role="group"
aria-label="Filter goals by category"
className="mb-3 flex flex-wrap gap-1.5"
>
<button
type="button"
onClick={() => setActiveCategoryFilter(null)}
aria-pressed={activeCategoryFilter === null}
className={`rounded-full border px-2.5 py-1 text-xs font-medium transition ${
activeCategoryFilter === null
? "border-[var(--accent)] bg-[var(--accent)] text-[var(--accent-foreground)]"
: "border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--accent)]"
}`}
>
All
</button>
{CATEGORY_OPTIONS.map((c) => (
<button
key={c}
type="button"
onClick={() =>
setActiveCategoryFilter((curr) => (curr === c ? null : c))
}
aria-pressed={activeCategoryFilter === c}
className={`rounded-full border px-2.5 py-1 text-xs font-medium transition ${
activeCategoryFilter === c
? CATEGORY_BADGE_CLASSES[c].replace("/10", "/20")
: "border-[var(--border)] text-[var(--muted-foreground)] hover:border-[var(--accent)]"
}`}
>
{CATEGORY_LABELS[c]}
</button>
))}
</div>
)}

<ul className="space-y-4">

{goals.map((goal) => {
{goals
.filter((goal) => !activeCategoryFilter || goal.category === activeCategoryFilter)
.map((goal) => {
const pct =
goal.current > 0 && goal.target > 0
? Math.max(
Expand Down Expand Up @@ -567,6 +644,13 @@ export default function GoalTracker() {
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[var(--card-foreground)]">{goal.title}</span>
{goal.category && (
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full border ${CATEGORY_BADGE_CLASSES[goal.category]}`}
>
{CATEGORY_LABELS[goal.category]}
</span>
)}
{goal.recurrence !== "none" && (
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full border ${
Expand Down Expand Up @@ -749,6 +833,7 @@ export default function GoalTracker() {
);
})}
</ul>
</>
)}

{lastUpdated && (
Expand Down Expand Up @@ -825,6 +910,29 @@ export default function GoalTracker() {
</div>
</div>

<div>
<label
htmlFor="goal-category"
className="mb-1 block text-xs font-medium uppercase tracking-wide text-[var(--muted-foreground)]"
>
Category (Optional)
</label>
<select
id="goal-category"
value={category}
onChange={(e) => setCategory(e.target.value as GoalCategory | "")}
disabled={creating}
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)]"
>
<option value="">No category</option>
{CATEGORY_OPTIONS.map((c) => (
<option key={c} value={c}>
{CATEGORY_LABELS[c]}
</option>
))}
</select>
</div>

{recurrence === "none" && (
<div>
<label
Expand Down
2 changes: 2 additions & 0 deletions src/lib/goal-tracker.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
type Recurrence = "none" | "weekly" | "monthly";
export type GoalCategory = "side-project" | "work" | "dsa" | "open-source";

export interface CreateGoalPayload {
title: string;
target: number;
unit: string;
recurrence: Recurrence;
deadline: string | null;
category: GoalCategory | null;
}

interface SubmitGoalOptions {
Expand Down
6 changes: 6 additions & 0 deletions supabase/migrations/20260713010000_add_goal_category.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Add customizable category tags to goals (e.g. Side Project, Work, DSA, Open Source)
alter table goals
add column if not exists category text
check (category is null or category in ('side-project', 'work', 'dsa', 'open-source'));

create index if not exists goals_user_category on goals(user_id, category);
4 changes: 3 additions & 1 deletion supabase/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,11 @@ create table if not exists goals (
last_synced_at timestamptz,
created_at timestamptz default now(),
updated_at timestamptz default now(),
week_start date
week_start date,
category text check (category is null or category in ('side-project', 'work', 'dsa', 'open-source'))
);
create index if not exists goals_user_period on goals(user_id, period_start);
create index if not exists goals_user_category on goals(user_id, category);

create table if not exists goal_history (
id text primary key default gen_random_uuid()::text,
Expand Down
Loading