Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
41 changes: 39 additions & 2 deletions apps/web/src/app/(main)/dashboard/oss-programs/ProgramsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState, useMemo, useCallback } from "react";
import { Program } from "@/data/oss-programs/types";
import { SearchInput, TagFilter, ProgramCard } from "@/components/oss-programs";
import StatusFilter from "@/components/oss-programs/StatusFilter";

interface ProgramsListProps {
programs: Program[];
Expand All @@ -12,6 +13,7 @@ interface ProgramsListProps {
export default function ProgramsList({ programs, tags }: ProgramsListProps) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);

// Memoize handlers to prevent child re-renders
const handleSearchChange = useCallback((value: string) => {
Expand All @@ -22,6 +24,10 @@ export default function ProgramsList({ programs, tags }: ProgramsListProps) {
setSelectedTags(newTags);
}, []);

const handleStatusesChange = useCallback((newStatuses: string[]) => {
setSelectedStatuses(newStatuses);
}, []);

const filteredPrograms = useMemo(() => {
return programs.filter((program) => {
const matchesSearch =
Expand All @@ -34,9 +40,35 @@ export default function ProgramsList({ programs, tags }: ProgramsListProps) {
selectedTags.length === 0 ||
selectedTags.every((tag) => program.tags.includes(tag));

return matchesSearch && matchesTags;
const findMatchesStatusesBasedOnApplicationDates = () => {
if (selectedStatuses.length === 0) return true;

if(selectedStatuses.includes("TBD")) {
if(!program.applicationStart || !program.applicationEnd) return true;
}

if(!program.applicationStart || !program.applicationEnd) return false;

const today = new Date();
const start = new Date(program.applicationStart);
const end = new Date(program.applicationEnd);

if (selectedStatuses.includes("Active")) {
if (today >= start && today <= end) return true;
}
if (selectedStatuses.includes("Upcoming")) {
if (today < start) return true;
}
if (selectedStatuses.includes("Historical")) {
if (today > end) return true;
}

return false;
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# verify current date literal quality in OSS program data
python - <<'PY'
import re
from pathlib import Path

root = Path("apps/web/src/data/oss-programs")
iso = re.compile(r"^\d{4}-\d{2}-\d{2}$")
pat = re.compile(r'application(Start|End)\s*:\s*"([^"]+)"')

found = False
for p in root.rglob("*.ts"):
    text = p.read_text(encoding="utf-8")
    for m in pat.finditer(text):
        val = m.group(2)
        if not iso.match(val):
            found = True
            print(f"{p}: non-ISO {m.group(1)} -> {val}")

if not found:
    print("all applicationStart/applicationEnd values match YYYY-MM-DD")
PY

Repository: apsinghdev/opensox

Length of output: 121


🏁 Script executed:

# Search for test files related to ProgramsList or status filtering
find apps/web -type f \( -name "*.test.ts" -o -name "*.test.tsx" -o -name "*.spec.ts" -o -name "*.spec.tsx" \) | grep -i "program\|status" | head -20

Repository: apsinghdev/opensox

Length of output: 44


🏁 Script executed:

# Check how ProgramCard.tsx uses similar date logic (mentioned in original scratchpad)
cat -n apps/web/src/components/oss-programs/ProgramCard.tsx | head -100

Repository: apsinghdev/opensox

Length of output: 3391


🏁 Script executed:

# Search for other date comparison patterns in the codebase
rg "new Date\(\)" apps/web/src --type ts --type tsx -B 2 -A 2 | head -60

Repository: apsinghdev/opensox

Length of output: 89


🏁 Script executed:

# Look for timezone-related patterns or date utilities
fd -t f "\(date\|time\)" apps/web/src --type f | head -20

Repository: apsinghdev/opensox

Length of output: 44


Status filtering has timezone-sensitive boundary date logic affecting multiple components.

The date comparisons use ISO date strings parsed as UTC but compare against new Date() in local timezone, causing programs to be misclassified on boundary dates depending on timezone offset. This same pattern exists identically in apps/web/src/components/oss-programs/ProgramCard.tsx (lines 11-25), suggesting a systemic issue.

recommended fix

Extract a shared date normalization function to handle both locations consistently:

+type DerivedStatus = "TBD" | "Active" | "Upcoming" | "Historical";
+
+const getDerivedStatus = (program: Program): DerivedStatus => {
+  if (!program.applicationStart || !program.applicationEnd) return "TBD";
+
+  const startRaw = new Date(program.applicationStart);
+  const endRaw = new Date(program.applicationEnd);
+  if (Number.isNaN(startRaw.getTime()) || Number.isNaN(endRaw.getTime())) return "TBD";
+
+  const today = new Date();
+  const todayKey = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
+  const startKey = Date.UTC(startRaw.getUTCFullYear(), startRaw.getUTCMonth(), startRaw.getUTCDate());
+  const endKey = Date.UTC(endRaw.getUTCFullYear(), endRaw.getUTCMonth(), endRaw.getUTCDate());
+
+  if (todayKey >= startKey && todayKey <= endKey) return "Active";
+  if (todayKey < startKey) return "Upcoming";
+  return "Historical";
+};
+
       const findMatchesStatusesBasedOnApplicationDates = () => {
         if (selectedStatuses.length === 0) return true;
-
-        if(selectedStatuses.includes("TBD")) {
-          if(!program.applicationStart || !program.applicationEnd) return true;
-        }
-        
-        if(!program.applicationStart || !program.applicationEnd) return false;
-        
-        const today = new Date();
-        const start = new Date(program.applicationStart);
-        const end = new Date(program.applicationEnd);
-      
-        if (selectedStatuses.includes("Active")) {
-          if (today >= start && today <= end) return true;
-        }
-        if (selectedStatuses.includes("Upcoming")) {
-          if (today < start) return true;
-        }
-        if (selectedStatuses.includes("Historical")) {
-          if (today > end) return true;
-        }
-        
-        return false;
+        const derivedStatus = getDerivedStatus(program);
+        return selectedStatuses.includes(derivedStatus);
       };

Apply the same getDerivedStatus function to ProgramCard.tsx to eliminate the duplicate logic.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/`(main)/dashboard/oss-programs/ProgramsList.tsx around lines
43 - 67, The status filtering is timezone-sensitive; create a shared
date-normalization helper (e.g., normalizeApplicationDate or
parseApplicationBoundary) that converts program.applicationStart and
program.applicationEnd into comparable Date values using a consistent rule (for
example, parse ISO and set to local midnight or convert to UTC midnight) and
export it for reuse; then update findMatchesStatusesBasedOnApplicationDates in
ProgramsList.tsx to use that helper when building start/end and today
comparisons, and replace the duplicate logic in ProgramCard.tsx by calling the
same helper and applying the shared getDerivedStatus logic so both components
use identical, timezone-normalized boundary comparisons.


return matchesSearch && matchesTags && findMatchesStatusesBasedOnApplicationDates();
});
}, [programs, searchQuery, selectedTags]);
}, [programs, searchQuery, selectedTags, selectedStatuses]);

return (
<div className="min-h-full w-[99vw] lg:w-[80vw] bg-dash-base text-white p-4 md:p-8 lg:p-12 overflow-x-hidden">
Expand All @@ -58,6 +90,11 @@ export default function ProgramsList({ programs, tags }: ProgramsListProps) {
selectedTags={selectedTags}
onTagsChange={handleTagsChange}
/>
<StatusFilter
statuses={["Active","Upcoming","TBD","Historical"]}
selectedStatuses={selectedStatuses}
onStatusesChange={handleStatusesChange}
/>
</div>
</div>

Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/components/oss-programs/ProgramCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,28 @@ import React from "react";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { Program } from "@/data/oss-programs/types";
import { Badge } from "../ui/badge";
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

interface ProgramCardProps {
program: Program;
}

export const getProgramStatus = (program: Program) => {

if (!program.applicationStart || !program.applicationEnd) {
return <span className="text-neutral-500 p-0.5 px-2 rounded-full border border-neutral-500/20 border-0.5 text-xs">TBD</span>;;
}

const today = new Date();
const start = new Date(program.applicationStart);
const end = new Date(program.applicationEnd);

if (today >= start && today <= end) return <span className="text-green-500 bg-green-950 p-0.5 px-2 rounded-full border border-green-500/20 border-0.5 text-xs">Active</span>;
if (today < start) return <span className="text-yellow-500 bg-yellow-950 p-0.5 px-2 rounded-full border border-yellow-500/20 border-0.5 text-xs">Upcoming</span>;
return <span className="text-neutral-300 bg-neutral-700 p-0.5 px-2 rounded-full border border-neutral-500/20 border-0.5 text-xs">Historical</span>;

};

function ProgramCard({ program }: ProgramCardProps) {
return (
<Link
Expand Down Expand Up @@ -34,6 +51,13 @@ function ProgramCard({ program }: ProgramCardProps) {
</div>
</div>

<div className="hidden md:flex items-center gap-6 flex-shrink-0">
<div className="text-center">
<p className="text-xs uppercase tracking-wide">Status</p>
{getProgramStatus(program)}
</div>
</div>

<div className="flex-shrink-0 text-text-muted group-hover:text-brand-purple transition-all duration-200 group-hover:translate-x-1">
<ChevronRight className="w-4 h-4 md:w-5 md:h-5" />
</div>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/oss-programs/SearchInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default function SearchInput({
}, [value]);

return (
<div className="relative flex-1 min-w-0">
<div className="relative flex-[3] min-w-0">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 w-5 h-5 pointer-events-none z-10" />
<input
type="text"
Expand Down
139 changes: 139 additions & 0 deletions apps/web/src/components/oss-programs/StatusFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"use client";

import { useState, useRef, useEffect, useMemo } from "react";
import { X, ChevronDown, Filter } from "lucide-react";

interface StatusFilterProps {
statuses: string[];
selectedStatuses: string[];
onStatusesChange: (statuses: string[]) => void;
}

export default function StatusFilter({
statuses,
selectedStatuses,
onStatusesChange,
}: StatusFilterProps) {
const [filterInput, setFilterInput] = useState("");
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (!isDropdownOpen) return; // Only attach listener when open

function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsDropdownOpen(false);
}
}

// Use passive listener for better scroll performance
document.addEventListener("mousedown", handleClickOutside, {
passive: true,
});
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isDropdownOpen]);

const availableTags = useMemo(() => {
return statuses.filter(
(status) =>
!selectedStatuses.includes(status) &&
status.toLowerCase().includes(filterInput.toLowerCase())
);
}, [statuses, selectedStatuses, filterInput]);

const addTag = (tag: string) => {
onStatusesChange([...selectedStatuses, tag]);
setFilterInput("");
setIsDropdownOpen(true);
inputRef.current?.focus();
};

const removeTag = (tagToRemove: string) => {
onStatusesChange(selectedStatuses.filter((status) => status !== tagToRemove));
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (
e.key === "Backspace" &&
filterInput === "" &&
selectedStatuses.length > 0
) {
removeTag(selectedStatuses[selectedStatuses.length - 1]);
}
};

return (
<div className="relative flex-1 min-w-0" ref={dropdownRef}>
<div
className="flex items-center gap-2 bg-dash-surface border border-dash-border rounded-xl p-2 min-h-[50px] focus-within:border-brand-purple transition-colors cursor-text min-w-0"
onClick={() => {
inputRef.current?.focus();
setIsDropdownOpen(true);
}}
>
<div className="flex flex-wrap items-center gap-2 flex-1 min-w-0">
<Filter className="w-4 h-4 text-text-secondary" />
{selectedStatuses.map((status) => (
<span
key={status}
className="flex items-center gap-1 bg-brand-purple/20 text-brand-purple-light px-3 py-1 rounded-full text-sm flex-shrink-0"
>
{status}
<button
onClick={(e) => {
e.stopPropagation();
removeTag(status);
}}
aria-label={`Remove ${status}`}
className="hover:text-white"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
ref={inputRef}
type="text"
placeholder={selectedStatuses.length === 0 ? "Status..." : ""}
value={filterInput}
onChange={(e) => {
setFilterInput(e.target.value);
setIsDropdownOpen(true);
}}
onKeyDown={handleKeyDown}
onFocus={() => setIsDropdownOpen(true)}
className="bg-transparent text-white placeholder-gray-500 focus:outline-none flex-1 min-w-0"
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
<ChevronDown className="w-5 h-5 text-gray-400 flex-shrink-0" />
</div>

{/* Replaced Framer Motion with CSS transitions for better performance */}
{isDropdownOpen && (
<div className="absolute z-20 top-full left-0 right-0 mt-2 bg-dash-surface border border-dash-border rounded-xl shadow-xl max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-2 duration-150">
{availableTags.length === 0 ? (
<div className="p-4 text-gray-500 text-center">
No matching statuses found
</div>
) : (
availableTags.map((status) => (
<button
key={status}
onClick={() => addTag(status)}
aria-label={`Add status ${status}`}
className="w-full text-left px-4 py-3 hover:bg-dash-hover text-gray-300 hover:text-white transition-colors flex items-center justify-between"
>
{status}
</button>
))
)}
</div>
)}
</div>
);
}
7 changes: 4 additions & 3 deletions apps/web/src/components/oss-programs/TagFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useState, useRef, useEffect, useMemo } from "react";
import { X, ChevronDown } from "lucide-react";
import { X, ChevronDown, Filter } from "lucide-react";

interface TagFilterProps {
tags: string[];
Expand Down Expand Up @@ -77,10 +77,11 @@ export default function TagFilter({
}}
>
<div className="flex flex-wrap items-center gap-2 flex-1 min-w-0">
<Filter className="w-4 h-4 text-text-secondary" />
{selectedTags.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 bg-brand-purple/20 text-brand-purple-light px-3 py-1 rounded-full text-sm flex-shrink-0"
className="flex items-center gap-1 bg-brand-purple/20 text-brand-purple-light px-2 py-0.5 rounded-full text-sm flex-shrink-0"
>
{tag}
<button
Expand All @@ -98,7 +99,7 @@ export default function TagFilter({
<input
ref={inputRef}
type="text"
placeholder={selectedTags.length === 0 ? "Filter by tags..." : ""}
placeholder={selectedTags.length === 0 ? "Tags..." : ""}
value={filterInput}
onChange={(e) => {
setFilterInput(e.target.value);
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/data/oss-programs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export type ProgramSectionId =
| "how-to-prepare"
| "application-process";

export type ProgramStatus = "active" | "historical" | "unknown";
export type ProgramStatus = "active" | "historical" | "unknown" | "upcoming";

export interface ProgramSection {
id: ProgramSectionId;
Expand Down Expand Up @@ -73,4 +73,7 @@ export interface Program {
title: string;
description: string;
};

applicationStart?: string;
applicationEnd?: string;
}
13 changes: 11 additions & 2 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
packages:
- 'apps/*'
- 'packages/*'
- apps/*
- packages/*
onlyBuiltDependencies:
- '@prisma/client'
- '@prisma/engines'
- core-js
- core-js-pure
- esbuild
- prisma
- sharp
- unrs-resolver