Skip to content
Merged
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
5 changes: 3 additions & 2 deletions app/api/bounties/route.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { NextResponse } from 'next/server';
import { getAllBounties } from '@/lib/mock-bounty';
import { BountyLogic } from '@/lib/logic/bounty-logic';

export async function GET(request: Request) {
const { searchParams } = new URL(request.url);

// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));

const allBounties = getAllBounties();
const allBounties = getAllBounties().map(b => BountyLogic.processBountyStatus(b));

// Apply filters from params
let filtered = allBounties;
Expand All @@ -28,7 +29,7 @@ export async function GET(request: Request) {
if (tags) {
const tagArray = tags.split(',').filter(Boolean);
if (tagArray.length > 0) {
filtered = filtered.filter(b =>
filtered = filtered.filter(b =>
tagArray.some(tag => b.tags.includes(tag))
);
}
Expand Down
7 changes: 6 additions & 1 deletion app/bounty/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { notFound } from "next/navigation"
import { Metadata } from "next"
import { getBountyById } from "@/lib/mock-bounty"
import { truncateAtWordBoundary } from "@/lib/truncate"
import { BountyLogic } from "@/lib/logic/bounty-logic"
import { BountyHeader } from "@/components/bounty/bounty-header"
import { BountyContent } from "@/components/bounty/bounty-content"
import { BountySidebar } from "@/components/bounty/bounty-sidebar"
Expand All @@ -27,7 +28,11 @@ export async function generateMetadata({ params }: BountyPageProps): Promise<Met

export default async function BountyPage({ params }: BountyPageProps) {
const { id } = await params
const bounty = getBountyById(id)
let bounty = getBountyById(id)

if (bounty) {
bounty = BountyLogic.processBountyStatus(bounty)
}

if (!bounty) {
notFound()
Expand Down
18 changes: 10 additions & 8 deletions app/discover/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { FilterPanel } from "@/components/filters/filter-panel";
import { ProjectCard } from "@/components/cards/project-card";
import { BountyCard } from "@/components/cards/bounty-card";
import { Skeleton } from "@/components/ui/skeleton";
import { mockProjects, mockBounties } from "@/lib/mock-data";
import { mockProjects, mockBounties as rawMockBounties } from "@/lib/mock-data";
import { FilterState, TabType } from "@/lib/types";
import { PackageOpen, Coins } from "lucide-react";
import { BountyLogic } from '@/lib/logic/bounty-logic';

// Validation helpers
const isValidTab = (value: string | null): value is TabType => {
Expand Down Expand Up @@ -117,14 +118,14 @@ export default function DiscoverPage() {
// Apply sort (only valid sorts for projects)
switch (filters.sort) {
case "newest":
result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
case "recentlyUpdated":
result.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
result.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
break;
default:
// Fallback to newest for unsupported sort values (e.g. "highestReward")
result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
}

Expand All @@ -133,7 +134,8 @@ export default function DiscoverPage() {

// Filter and sort bounties
const filteredBounties = useCallback(() => {
let result = [...mockBounties];
// Process bounties for dynamic status (e.g. expiration)
let result = rawMockBounties.map(b => BountyLogic.processBountyStatus(b));

// Apply search filter
if (filters.search) {
Expand All @@ -156,10 +158,10 @@ export default function DiscoverPage() {
// Apply sort
switch (filters.sort) {
case "newest":
result.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
case "recentlyUpdated":
result.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
result.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
break;
case "highestReward":
result.sort((a, b) => b.reward - a.reward);
Expand Down Expand Up @@ -212,7 +214,7 @@ export default function DiscoverPage() {
<Coins className="mr-2 h-4 w-4" />
Bounties
<span className="ml-2 text-xs opacity-70">
({mockBounties.length})
({rawMockBounties.length})
</span>
</TabsTrigger>
</TabsList>
Expand Down
6 changes: 6 additions & 0 deletions lib/api/bounties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export const bountySchema = z.object({
status: bountyStatusSchema,
createdAt: z.string(),
updatedAt: z.string(),
claimedAt: z.string().optional(),
claimedBy: z.string().optional(),
lastActivityAt: z.string().optional(),
claimExpiresAt: z.string().optional(),
submissionsEndDate: z.string().optional(),

requirements: z.array(z.string()).optional(),
scope: z.string().optional(),
});
Expand Down
105 changes: 105 additions & 0 deletions lib/logic/bounty-logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { differenceInDays, isPast, parseISO, isValid } from 'date-fns';


/**
* Interface defining the minimal fields required for status logic.
* Compatible with both `types/bounty.ts` and `lib/types.ts`.
*/
export interface StatusAwareBounty {
status: string;
claimingModel: string;
claimExpiresAt?: string;
lastActivityAt?: string;
claimedBy?: string;
claimedAt?: string;
}

export class BountyLogic {
/**
* Configuration for inactivity thresholds (in days)
*/
static readonly INACTIVITY_THRESHOLD_DAYS = 7;

/**
* Processes the bounty status based on its model and timestamps.
* - Checks for inactivity auto-release for single-claim.
* - Checks for expired claims.
* - Returns the potentially modified bounty (this simulates the backend update).
*/
static processBountyStatus<T extends StatusAwareBounty>(bounty: T): T {
if (bounty.status !== 'claimed' && bounty.status !== 'open') return bounty;

const now = new Date();
// Shallow copy works for pure property updates
const newBounty = { ...bounty };

// Anti-squatting: Check inactivity for single-claim
if (
bounty.claimingModel === 'single-claim' &&
bounty.status === 'claimed'
) {
// Helper to get Date object safely
const getDate = (val?: string) => {
if (!val) return null;
const date = parseISO(val);
return isValid(date) ? date : null;
};

const expiresAt = getDate(bounty.claimExpiresAt);

// If claim expired
if (expiresAt && isPast(expiresAt)) {
// Auto-release
newBounty.status = 'open';
newBounty.claimedBy = undefined;
newBounty.claimedAt = undefined;
newBounty.claimExpiresAt = undefined;
newBounty.lastActivityAt = undefined;
}

// If inactive for too long
const lastActive = getDate(bounty.lastActivityAt) || getDate(bounty.claimedAt);
if (lastActive) {
const daysInactive = differenceInDays(now, lastActive);
if (daysInactive > this.INACTIVITY_THRESHOLD_DAYS) {
// Auto-release due to inactivity
newBounty.status = 'open';
newBounty.claimedBy = undefined;
newBounty.claimedAt = undefined;
newBounty.claimExpiresAt = undefined;
newBounty.lastActivityAt = undefined;
}
}
}

return newBounty;
}

/**
* Returns metadata about the claim status suitable for UI display.
*/
static getClaimStatusDisplay(bounty: StatusAwareBounty) {
if (bounty.status === 'open') return { label: 'Available', color: 'green' };

if (bounty.status === 'claimed') {
if (bounty.claimingModel === 'single-claim') {
return {
label: 'Claimed',
color: 'orange',
details: bounty.claimExpiresAt ? `Expires ${BountyLogic.formatDate(bounty.claimExpiresAt)}` : 'In Progress'
};
}
if (bounty.claimingModel === 'application') {
return { label: 'Applications Open', color: 'blue' };
}
}

return { label: bounty.status, color: 'gray' };
}

private static formatDate(dateStr: string) {
const date = parseISO(dateStr);
if (!isValid(date)) return 'Invalid Date';
return date.toLocaleDateString();
}
}
3 changes: 3 additions & 0 deletions lib/mock-bounty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ Add a dark mode toggle to the application settings page that persists user prefe
difficulty: "beginner",
tags: ["ui", "theme", "settings", "dark-mode"],
status: "claimed",
claimedBy: "dev_user_123",
claimedAt: "2024-01-01T00:00:00Z",
claimExpiresAt: "2024-01-15T00:00:00Z", // Expired
createdAt: "2025-01-10T08:00:00Z",
updatedAt: "2025-01-17T11:00:00Z",
},
Expand Down
Loading