diff --git a/app/props/[propId]/prop-page-header.tsx b/app/props/[propId]/prop-page-header.tsx index 1406455..78ca1c0 100644 --- a/app/props/[propId]/prop-page-header.tsx +++ b/app/props/[propId]/prop-page-header.tsx @@ -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; @@ -41,22 +43,7 @@ export default function PropPageHeader({ {prop.category_name} )} - - {prop.resolution === null - ? "Unresolved" - : prop.resolution - ? "Yes" - : "No"} - + {canResolve && ( {prop.category_name} - {prop.resolution !== null && ( - - {prop.resolution ? "Yes" : "No"} - - )} + @@ -134,14 +129,7 @@ export function ForecastCard({ prop, showCommunityAvg }: ForecastCardProps) { {prop.category_name} - {prop.resolution !== null && ( - - {prop.resolution ? "Yes" : "No"} - - )} + diff --git a/components/ui/prop-status-badge.stories.tsx b/components/ui/prop-status-badge.stories.tsx new file mode 100644 index 0000000..6c9d9e3 --- /dev/null +++ b/components/ui/prop-status-badge.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +// 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: () => ( + + + + + + + ), +}; + +// In context - showing how it might appear in a card +export const InContext: Story = { + args: { + status: "open", + }, + render: () => ( + + + Category + + + + Will the temperature exceed 30°C tomorrow? + + + Based on local weather station data + + + ), +}; + +// Resolved in context +export const ResolvedInContext: Story = { + args: { + status: "resolved-yes", + }, + render: () => ( + + + Category + + + + Will the temperature exceed 30°C tomorrow? + + + Based on local weather station data + + + ), +}; diff --git a/components/ui/prop-status-badge.tsx b/components/ui/prop-status-badge.tsx new file mode 100644 index 0000000..d3f9710 --- /dev/null +++ b/components/ui/prop-status-badge.tsx @@ -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, "children">, + VariantProps { + status: PropStatus; + /** Optional custom label (defaults to status label) */ + label?: string; +} + +/** + * Badge component for displaying prop lifecycle status + * + * @example + * + * + */ +function PropStatusBadge({ + status, + label, + className, + ...props +}: PropStatusBadgeProps) { + const displayLabel = label ?? getPropStatusLabel(status); + + return ( + + {displayLabel} + + ); +} + +export { PropStatusBadge, propStatusBadgeVariants }; diff --git a/lib/prop-status.test.ts b/lib/prop-status.test.ts new file mode 100644 index 0000000..2c7a640 --- /dev/null +++ b/lib/prop-status.test.ts @@ -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); + }); +}); diff --git a/lib/prop-status.ts b/lib/prop-status.ts new file mode 100644 index 0000000..5460745 --- /dev/null +++ b/lib/prop-status.ts @@ -0,0 +1,101 @@ +/** + * Prop status utilities + * + * This module provides centralized logic for determining prop lifecycle status + * based on deadlines and resolution state. + */ + +export type PropStatus = + | "open" // Can still forecast + | "closed" // Past deadline, awaiting resolution + | "resolved-yes" // Resolved as true + | "resolved-no"; // Resolved as false + +export interface PropStatusOptions { + /** Current date for testing (default: new Date()) */ + currentDate?: Date; +} + +/** + * Get the status of a prop based on its deadline and resolution + * + * @param closeDate - The deadline for forecasts (forecasts_due_date for private, competition_forecasts_close_date for public) + * @param resolution - The resolution value (null if unresolved, true/false if resolved) + * @param options - Optional configuration + * @returns The prop status + * + * @example + * // Open prop with no deadline + * getPropStatus(null, null) // "open" + * + * // Prop closing in 12 hours + * const soon = new Date(Date.now() + 12 * 60 * 60 * 1000); + * getPropStatus(soon, null) // "closing-soon" + * + * // Resolved prop + * getPropStatus(pastDate, true) // "resolved-yes" + */ +export function getPropStatus( + closeDate: Date | null, + resolution: boolean | null, + options?: PropStatusOptions, +): PropStatus { + const currentDate = options?.currentDate ?? new Date(); + + // Check resolution first - resolved props have a definitive status regardless of dates + if (resolution !== null) { + return resolution ? "resolved-yes" : "resolved-no"; + } + + // No deadline means always open + if (closeDate === null) { + return "open"; + } + + const timeUntilClose = closeDate.getTime() - currentDate.getTime(); + + // Past deadline + if (timeUntilClose <= 0) { + return "closed"; + } + + return "open"; +} + +/** + * Helper to get prop status from a VProp-like object + * Automatically determines the correct close date based on competition type + */ +export function getPropStatusFromProp( + prop: { + prop_forecasts_due_date?: Date | null; + competition_forecasts_close_date?: Date | null; + competition_is_private?: boolean | null; + resolution: boolean | null; + }, + options?: PropStatusOptions, +): PropStatus { + // For private competitions, use prop-level deadline + // For public competitions, use competition-level deadline + const closeDate = prop.competition_is_private + ? (prop.prop_forecasts_due_date ?? null) + : (prop.competition_forecasts_close_date ?? null); + + return getPropStatus(closeDate, prop.resolution, options); +} + +/** + * Get human-readable label for a prop status + */ +export function getPropStatusLabel(status: PropStatus): string { + switch (status) { + case "open": + return "Open"; + case "closed": + return "Closed"; + case "resolved-yes": + return "Yes"; + case "resolved-no": + return "No"; + } +}
+ Based on local weather station data +