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
21 changes: 0 additions & 21 deletions .claude/settings.local.json

This file was deleted.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,7 @@ Thumbs.db
.idea/
*.swp

# Claude Code local settings
.claude/

desktop.ini
58 changes: 42 additions & 16 deletions src/app/api/goals/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ interface Goal {

type Recurrence = "none" | "weekly" | "monthly";

const VALID_RECURRENCES = ["none", "weekly", "monthly"] as const;
const MAX_TITLE_LEN = 100;
const MAX_UNIT_LEN = 30;
const MIN_TARGET = 1;
const MAX_TARGET = 10_000;

function getPeriodStart(recurrence: Recurrence): string {
const now = new Date();
if (recurrence === "weekly") {
Expand Down Expand Up @@ -106,21 +112,41 @@ export async function POST(req: Request) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

const body = (await req.json()) as {
title?: string;
target?: number;
unit?: string;
recurrence?: Recurrence;
};
let body: unknown;
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}

if (!body.title || typeof body.target !== "number" || body.target <= 0) {
return Response.json({ error: "title and positive target required" }, { status: 400 });
if (typeof body !== "object" || body === null) {
return Response.json({ error: "Invalid request body" }, { status: 400 });
}

const recurrence: Recurrence = body.recurrence ?? "none";
if (!["none", "weekly", "monthly"].includes(recurrence)) {
return Response.json({ error: "Invalid recurrence value" }, { status: 400 });
const { title, target, unit, recurrence } = 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 });
}
if (title.length > MAX_TITLE_LEN) {
return Response.json({ error: `title must be ${MAX_TITLE_LEN} characters or fewer` }, { status: 400 });
}
if (
typeof target !== "number" ||
!Number.isInteger(target) ||
target < MIN_TARGET ||
target > MAX_TARGET
) {
return Response.json(
{ error: `target must be an integer between ${MIN_TARGET} and ${MAX_TARGET}` },
{ status: 400 }
);
}

const safeUnit = typeof unit === "string" ? unit.slice(0, MAX_UNIT_LEN) : "commits";
const safeRecurrence: Recurrence = VALID_RECURRENCES.includes(recurrence as Recurrence)
? (recurrence as Recurrence)
: "none";

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) return Response.json({ error: "User not found" }, { status: 404 });
Expand All @@ -129,11 +155,11 @@ export async function POST(req: Request) {
.from("goals")
.insert({
user_id: user.id,
title: body.title,
target: body.target,
unit: body.unit ?? "commits",
recurrence,
period_start: getPeriodStart(recurrence),
title: title.trim(),
target,
unit: safeUnit,
recurrence: safeRecurrence,
period_start: getPeriodStart(safeRecurrence),
current: 0,
})
.select()
Expand Down
1 change: 1 addition & 0 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await getServerSession(authOptions);
if (!session) redirect("/");
if (session.error === "TokenRevoked") redirect("/?error=TokenRevoked");

return (
<div className="min-h-screen bg-[var(--background)] p-4 md:p-8 text-[var(--foreground)] transition-colors">
Expand Down
8 changes: 4 additions & 4 deletions src/components/CommitTimeChart.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import {
BarChart,
Bar,
Expand All @@ -27,7 +27,7 @@ export default function CommitTimeChart() {
const [days, setDays] = useState(30);
const [peakTime, setPeakTime] = useState<string | null>(null);

const fetchContributions = () => {
const fetchContributions = useCallback(() => {
setLoading(true);
setError(null);
fetch(`/api/metrics/contributions?days=${days}`)
Expand Down Expand Up @@ -81,11 +81,11 @@ export default function CommitTimeChart() {
setError("We couldn't load your time-of-day data right now."),
)
.finally(() => setLoading(false));
};
}, [days]);

useEffect(() => {
fetchContributions();
}, [days]);
}, [fetchContributions]);

return (
<div className="rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm flex flex-col h-full">
Expand Down
31 changes: 30 additions & 1 deletion src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,39 @@ export const authOptions: NextAuthOptions = {
return true;
},
async jwt({ token, account, profile }) {
if (account?.access_token) token.accessToken = account.access_token;
if (account?.access_token) {
token.accessToken = account.access_token;
token.accessTokenValidatedAt = Date.now();
}
if (profile) {
const p = profile as { id: number; login: string };
token.githubId = String(p.id);
token.githubLogin = p.login;
}

const validatedAt =
typeof token.accessTokenValidatedAt === "number"
? token.accessTokenValidatedAt
: 0;
const VALIDATION_INTERVAL = 24 * 60 * 60 * 1000;

if (token.accessToken && Date.now() - validatedAt > VALIDATION_INTERVAL) {
try {
const res = await fetch("https://api.github.com/user", {
headers: { Authorization: `Bearer ${token.accessToken}` },
cache: "no-store",
});
if (res.status === 401) {
token.error = "TokenRevoked";
} else if (res.ok) {
token.accessTokenValidatedAt = Date.now();
delete token.error;
}
} catch {
// Network error: preserve session, do not mark token as invalid
}
}

return token;
},
async session({ session, token }) {
Expand All @@ -69,6 +96,8 @@ export const authOptions: NextAuthOptions = {
session.githubId = token.githubId;
if (typeof token.githubLogin === "string")
session.githubLogin = token.githubLogin;
if (typeof token.error === "string")
session.error = token.error;
return session;
},
},
Expand Down
3 changes: 3 additions & 0 deletions src/types/next-auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ declare module "next-auth" {
githubId?: string;
githubLogin?: string;
gitlabToken?: string;
error?: string;
user?: DefaultSession["user"];
}
}
Expand All @@ -16,5 +17,7 @@ declare module "next-auth/jwt" {
githubId?: string;
githubLogin?: string;
gitlabToken?: string;
accessTokenValidatedAt?: number;
error?: string;
}
}
Loading