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
19 changes: 3 additions & 16 deletions app/props/[propId]/prop-page-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button";
import { ChevronLeft, CheckCircle2 } from "lucide-react";
import { ResolutionDialog } from "@/components/dialogs/resolution-dialog";
import { MarkdownRenderer } from "@/components/markdown";
import { PropStatusBadge } from "@/components/ui/prop-status-badge";
import { getPropStatusFromProp } from "@/lib/prop-status";

interface PropPageHeaderProps {
prop: VProp;
Expand Down Expand Up @@ -41,22 +43,7 @@ export default function PropPageHeader({
{prop.category_name}
</Badge>
)}
<Badge
variant={
prop.resolution === null
? "outline"
: prop.resolution
? "default"
: "destructive"
}
className="text-xs"
>
{prop.resolution === null
? "Unresolved"
: prop.resolution
? "Yes"
: "No"}
</Badge>
<PropStatusBadge status={getPropStatusFromProp(prop)} />
</div>
{canResolve && (
<Button
Expand Down
20 changes: 4 additions & 16 deletions components/forecast-card/forecast-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { PropWithUserForecast } from "@/types/db_types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { PropStatusBadge } from "@/components/ui/prop-status-badge";
import { getPropStatusFromProp } from "@/lib/prop-status";
import {
Tooltip,
TooltipContent,
Expand Down Expand Up @@ -57,14 +59,7 @@ export function ForecastCard({ prop, showCommunityAvg }: ForecastCardProps) {
<Badge variant="secondary" className="text-xs font-medium">
{prop.category_name}
</Badge>
{prop.resolution !== null && (
<Badge
variant={prop.resolution ? "default" : "destructive"}
className="text-xs"
>
{prop.resolution ? "Yes" : "No"}
</Badge>
)}
<PropStatusBadge status={getPropStatusFromProp(prop)} />
</div>
<Link href={`/props/${prop.prop_id}`}>
<Button variant="ghost" size="sm" className="h-7 px-2">
Expand Down Expand Up @@ -134,14 +129,7 @@ export function ForecastCard({ prop, showCommunityAvg }: ForecastCardProps) {
<Badge variant="secondary" className="text-xs font-medium">
{prop.category_name}
</Badge>
{prop.resolution !== null && (
<Badge
variant={prop.resolution ? "default" : "destructive"}
className="text-xs"
>
{prop.resolution ? "Yes" : "No"}
</Badge>
)}
<PropStatusBadge status={getPropStatusFromProp(prop)} />
</div>
<Link href={`/props/${prop.prop_id}`}>
<Button variant="ghost" size="sm" className="h-7 px-2">
Expand Down
107 changes: 107 additions & 0 deletions components/ui/prop-status-badge.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PropStatusBadge } from "./prop-status-badge";

const meta = {
title: "UI/PropStatusBadge",
component: PropStatusBadge,
parameters: {
layout: "centered",
},
tags: ["autodocs"],
argTypes: {
status: {
control: "select",
options: ["open", "closed", "resolved-yes", "resolved-no"],
description: "The lifecycle status of the prop",
},
label: {
control: "text",
description: "Custom label (defaults to status name)",
},
},
} satisfies Meta<typeof PropStatusBadge>;

export default meta;
type Story = StoryObj<typeof meta>;

// Individual status stories
export const Open: Story = {
args: {
status: "open",
},
};

export const Closed: Story = {
args: {
status: "closed",
},
};

export const ResolvedYes: Story = {
args: {
status: "resolved-yes",
},
};

export const ResolvedNo: Story = {
args: {
status: "resolved-no",
},
};

// All states comparison
export const AllStates: Story = {
args: {
status: "open",
},
render: () => (
<div className="flex flex-wrap gap-2">
<PropStatusBadge status="open" />
<PropStatusBadge status="closed" />
<PropStatusBadge status="resolved-yes" />
<PropStatusBadge status="resolved-no" />
</div>
),
};

// In context - showing how it might appear in a card
export const InContext: Story = {
args: {
status: "open",
},
render: () => (
<div className="bg-card rounded-lg border border-border p-4 w-80">
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-xs text-muted-foreground">Category</span>
<PropStatusBadge status="open" />
</div>
<h3 className="font-medium text-foreground">
Will the temperature exceed 30°C tomorrow?
</h3>
<p className="text-sm text-muted-foreground mt-1">
Based on local weather station data
</p>
</div>
),
};

// Resolved in context
export const ResolvedInContext: Story = {
args: {
status: "resolved-yes",
},
render: () => (
<div className="bg-card rounded-lg border border-border p-4 w-80">
<div className="flex items-center justify-between gap-2 mb-2">
<span className="text-xs text-muted-foreground">Category</span>
<PropStatusBadge status="resolved-yes" />
</div>
<h3 className="font-medium text-foreground">
Will the temperature exceed 30°C tomorrow?
</h3>
<p className="text-sm text-muted-foreground mt-1">
Based on local weather station data
</p>
</div>
),
};
59 changes: 59 additions & 0 deletions components/ui/prop-status-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { PropStatus, getPropStatusLabel } from "@/lib/prop-status";

const propStatusBadgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors",
{
variants: {
status: {
open: "border-green-200 bg-green-100 text-green-700 dark:border-green-800 dark:bg-green-950 dark:text-green-400",
closed: "border-transparent bg-secondary text-secondary-foreground",
"resolved-yes":
"border-transparent bg-primary text-primary-foreground shadow-sm",
"resolved-no":
"border-transparent bg-destructive text-destructive-foreground shadow-sm",
},
},
defaultVariants: {
status: "open",
},
},
);

export interface PropStatusBadgeProps
extends
Omit<React.HTMLAttributes<HTMLDivElement>, "children">,
VariantProps<typeof propStatusBadgeVariants> {
status: PropStatus;
/** Optional custom label (defaults to status label) */
label?: string;
}

/**
* Badge component for displaying prop lifecycle status
*
* @example
* <PropStatusBadge status="open" />
* <PropStatusBadge status="resolved-yes" />
*/
function PropStatusBadge({
status,
label,
className,
...props
}: PropStatusBadgeProps) {
const displayLabel = label ?? getPropStatusLabel(status);

return (
<div
className={cn(propStatusBadgeVariants({ status }), className)}
{...props}
>
{displayLabel}
</div>
);
}

export { PropStatusBadge, propStatusBadgeVariants };
121 changes: 121 additions & 0 deletions lib/prop-status.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, it, expect } from "vitest";
import {
getPropStatus,
getPropStatusFromProp,
getPropStatusLabel,
PropStatus,
} from "./prop-status";

describe("getPropStatus", () => {
const now = new Date("2024-06-15T12:00:00Z");

describe("resolution states", () => {
it("should return resolved-yes when resolution is true", () => {
const pastDate = new Date("2024-06-10T12:00:00Z");
expect(getPropStatus(pastDate, true, { currentDate: now })).toBe(
"resolved-yes",
);
});

it("should return resolved-no when resolution is false", () => {
const pastDate = new Date("2024-06-10T12:00:00Z");
expect(getPropStatus(pastDate, false, { currentDate: now })).toBe(
"resolved-no",
);
});

it("should prioritize resolution over dates", () => {
// Even if date is in the future, resolved props show resolution status
const futureDate = new Date("2024-06-20T12:00:00Z");
expect(getPropStatus(futureDate, true, { currentDate: now })).toBe(
"resolved-yes",
);
});
});

describe("open state", () => {
it("should return open when no close date", () => {
expect(getPropStatus(null, null, { currentDate: now })).toBe("open");
});

it("should return open when close date is in the future", () => {
const futureDate = new Date("2024-06-20T12:00:00Z"); // 5 days away
expect(getPropStatus(futureDate, null, { currentDate: now })).toBe(
"open",
);
});

it("should return open when close date is soon but still in the future", () => {
const soonDate = new Date("2024-06-16T10:00:00Z"); // 22 hours away
expect(getPropStatus(soonDate, null, { currentDate: now })).toBe("open");
});
});

describe("closed state", () => {
it("should return closed when past deadline and not resolved", () => {
const pastDate = new Date("2024-06-14T12:00:00Z"); // 1 day ago
expect(getPropStatus(pastDate, null, { currentDate: now })).toBe(
"closed",
);
});

it("should return closed when exactly at deadline", () => {
expect(getPropStatus(now, null, { currentDate: now })).toBe("closed");
});
});
});

describe("getPropStatusFromProp", () => {
const now = new Date("2024-06-15T12:00:00Z");

it("should use prop_forecasts_due_date for private competitions", () => {
const prop = {
prop_forecasts_due_date: new Date("2024-06-20T12:00:00Z"),
competition_forecasts_close_date: new Date("2024-06-10T12:00:00Z"), // past
competition_is_private: true,
resolution: null,
};
// Private competition uses prop date (future), not competition date (past)
expect(getPropStatusFromProp(prop, { currentDate: now })).toBe("open");
});

it("should use competition_forecasts_close_date for public competitions", () => {
const prop = {
prop_forecasts_due_date: new Date("2024-06-20T12:00:00Z"), // future
competition_forecasts_close_date: new Date("2024-06-10T12:00:00Z"), // past
competition_is_private: false,
resolution: null,
};
// Public competition uses competition date (past)
expect(getPropStatusFromProp(prop, { currentDate: now })).toBe("closed");
});

it("should handle missing dates gracefully", () => {
const prop = {
resolution: null,
};
expect(getPropStatusFromProp(prop, { currentDate: now })).toBe("open");
});

it("should return resolution status regardless of competition type", () => {
const prop = {
prop_forecasts_due_date: new Date("2024-06-20T12:00:00Z"),
competition_is_private: true,
resolution: true,
};
expect(getPropStatusFromProp(prop, { currentDate: now })).toBe(
"resolved-yes",
);
});
});

describe("getPropStatusLabel", () => {
it.each<[PropStatus, string]>([
["open", "Open"],
["closed", "Closed"],
["resolved-yes", "Yes"],
["resolved-no", "No"],
])("should return correct label for %s", (status, expected) => {
expect(getPropStatusLabel(status)).toBe(expected);
});
});
Loading
Loading