Skip to content
Closed
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: string | null;
}

interface GoalHistory {
Expand All @@ -34,6 +35,7 @@ interface GoalHistory {
type Recurrence = "none" | "weekly" | "monthly";

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 +203,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 @@ -239,6 +241,11 @@ try {
}
}

const safeCategory =
typeof category === "string" && VALID_CATEGORIES.includes(category as any)
? category
: null;

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) return Response.json({ error: "User not found" }, { status: 404 });

Expand Down Expand Up @@ -287,6 +294,7 @@ try {
deadline: safeDeadline,
current: 0,
goal_reset_version: 0,
category: safeCategory,
})
.select()
.single();
Expand Down
83 changes: 81 additions & 2 deletions src/components/GoalTracker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ interface Goal {
achieved: number;
completed: boolean;
} | null;
category?: string | null;
}

const RECURRENCE_LABELS: Record<Recurrence, string> = {
Expand All @@ -39,6 +40,15 @@ const RECURRENCE_LABELS: Record<Recurrence, string> = {
monthly: "Monthly",
};

export const CATEGORIES = ["Side Project", "Work", "DSA", "Open Source"];

export const CATEGORY_COLORS: Record<string, string> = {
"Side Project": "bg-purple-500/10 text-purple-500 border-purple-500/30",
"Work": "bg-blue-500/10 text-blue-500 border-blue-500/30",
"DSA": "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
"Open Source": "bg-amber-500/10 text-amber-500 border-amber-500/30",
};

export function useGoalTracker() {
const [goals, setGoals] = useState<Goal[]>([]);
const [loading, setLoading] = useState(true);
Expand All @@ -51,6 +61,7 @@ export function useGoalTracker() {
const [unit, setUnit] = useState("commits");
const [recurrence, setRecurrence] = useState<Recurrence>("none");
const [deadline, setDeadline] = useState("");
const [category, setCategory] = useState("");
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [confirmingId, setConfirmingId] = useState<string | null>(null);
Expand Down Expand Up @@ -161,7 +172,7 @@ 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 @@ -176,6 +187,7 @@ export function useGoalTracker() {
setUnit("commits");
setRecurrence("none");
setDeadline("");
setCategory("");

if (unit === "commits" || unit === "prs") {
await handleSync();
Expand Down Expand Up @@ -293,6 +305,8 @@ export function useGoalTracker() {
setRecurrence,
deadline,
setDeadline,
category,
setCategory,
creating,
createError,
confirmingId,
Expand Down Expand Up @@ -331,6 +345,8 @@ export default function GoalTracker() {
setRecurrence,
deadline,
setDeadline,
category,
setCategory,
creating,
createError,
confirmingId,
Expand All @@ -347,6 +363,8 @@ export default function GoalTracker() {

const { setSummary, setIsUpdating } = useDashboardWidgetA11y("goal-tracker");

const [filterCategory, setFilterCategory] = useState<string>("All");

useEffect(() => {
setIsUpdating(loading);
}, [loading, setIsUpdating]);
Expand Down Expand Up @@ -520,6 +538,35 @@ export default function GoalTracker() {
</div>
)}

{/* Filter Toggle Pills */}
{goals.length > 0 && (
<div className="mb-4 flex flex-wrap gap-2">
<button
onClick={() => setFilterCategory("All")}
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
filterCategory === "All"
? "bg-[var(--foreground)] text-[var(--background)] border-[var(--foreground)]"
: "bg-transparent text-[var(--muted-foreground)] border-[var(--border)] hover:border-[var(--muted-foreground)]"
}`}
>
All
</button>
{CATEGORIES.map((cat) => (
<button
key={cat}
onClick={() => setFilterCategory(cat)}
className={`px-3 py-1 text-xs font-medium rounded-full border transition-colors ${
filterCategory === cat
? "bg-[var(--foreground)] text-[var(--background)] border-[var(--foreground)]"
: "bg-transparent text-[var(--muted-foreground)] border-[var(--border)] hover:border-[var(--muted-foreground)]"
}`}
>
{cat}
</button>
))}
</div>
)}

{goals.length === 0 ? (
<div className="mt-6">
<EmptyState
Expand All @@ -534,7 +581,9 @@ export default function GoalTracker() {
) : (
<ul className="space-y-4">

{goals.map((goal) => {
{goals
.filter((goal) => filterCategory === "All" || goal.category === filterCategory)
.map((goal) => {
const pct =
goal.current > 0
? Math.max(1, Math.min(Math.round((goal.current / goal.target) * 100), 100))
Expand Down Expand Up @@ -577,6 +626,13 @@ export default function GoalTracker() {
{RECURRENCE_LABELS[goal.recurrence]}
</span>
)}
{goal.category && (
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full border ${CATEGORY_COLORS[goal.category] || "bg-[var(--card-muted)] text-[var(--muted-foreground)] border-[var(--border)]"}`}
>
{goal.category}
</span>
)}
{isAutoSynced && (
<span
title={
Expand Down Expand Up @@ -875,6 +931,29 @@ export default function GoalTracker() {
)}
</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)}
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>
{CATEGORIES.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
</select>
</div>

{(unit === "commits" || unit === "prs") && (
<p className="text-xs text-[var(--muted-foreground)] rounded-lg bg-[var(--accent)]/10 px-3 py-2">
⚡ This goal will auto-update from your GitHub activity.
Expand Down
4 changes: 2 additions & 2 deletions src/lib/consistency-score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,15 +105,15 @@ function computeLongestGap(sortedDates: string[]): number {
return longestGap;
}

function scoreToGrade(score: number): ConsistencyScoreResult["grade"] {
export function scoreToGrade(score: number): ConsistencyScoreResult["grade"] {
if (score >= 90) return "S";
if (score >= 75) return "A";
if (score >= 60) return "B";
if (score >= 40) return "C";
return "D";
}

function getImprovementTip(score: number): string {
export function getImprovementTip(score: number): string {
if (score < 40) {
return "Try committing at least once every 2-3 days to build consistency";
}
Expand Down
1 change: 1 addition & 0 deletions src/lib/goal-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface CreateGoalPayload {
unit: string;
recurrence: Recurrence;
deadline: string | null;
category?: string | null;
}

interface SubmitGoalOptions {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/ssrf-protection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ const PRIVATE_RANGES = [
{ start: 0xa9fe0000, end: 0xa9feffff },
];

function ipToNumber(ip: string): number {
export function ipToNumber(ip: string): number {
const parts = ip.split(".");
if (parts.length !== 4) return NaN;
const numParts = parts.map(Number);
if (numParts.some((n) => isNaN(n) || n < 0 || n > 255 || !Number.isInteger(n))) return NaN;
return ((numParts[0] << 24) | (numParts[1] << 16) | (numParts[2] << 8) | numParts[3]) >>> 0;
}

function isPrivateIP(ip: string): boolean {
export function isPrivateIP(ip: string): boolean {
ip = ip.toLowerCase();

// Extract IPv4 from IPv6-mapped IPv4 address
Expand Down
1 change: 1 addition & 0 deletions supabase/migrations/20260622000000_add_goal_category.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE goals ADD COLUMN category TEXT;
Loading
Loading