Add scientific tag filtering and weekly/daily heatmap view toggle
Browse filesFeatures added:
- Scientific area tag selector with 9 categories (antibody, hormones, materials-science, drug-discovery, biology, medicine, physics, data, education)
- Tagged all 14 organizations with relevant scientific areas
- Weekly activity view as default (aggregated from daily data)
- Toggle between weekly and daily heatmap views
- Enhanced tooltips for both view modes
- Improved visual design with proper theme support
- Responsive layout for all screen sizes
Technical changes:
- Added TagSelector component for filtering organizations
- Added ViewToggle component for switching heatmap granularity
- Updated ProviderInfo type to include tags property
- Created weeklyCalendar utility for data aggregation
- Enhanced Heatmap component to support both view modes
- Updated all related components to pass viewMode prop
- Added theme context and toggle (prepared for future use)
- package-lock.json +0 -0
- package.json +3 -3
- src/components/ClientNavbar.tsx +20 -0
- src/components/Heatmap.tsx +24 -4
- src/components/HeatmapGrid.tsx +16 -1
- src/components/Navbar.tsx +1 -1
- src/components/OrganizationCard.tsx +5 -1
- src/components/TagSelector.tsx +61 -0
- src/components/ThemeToggle.tsx +80 -0
- src/components/ViewToggle.tsx +41 -0
- src/constants/organizations.ts +83 -14
- src/contexts/ThemeContext.tsx +68 -0
- src/pages/index.tsx +36 -4
- src/styles/globals.css +27 -29
- src/types/heatmap.ts +1 -0
- src/utils/weeklyCalendar.ts +63 -0
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -19,7 +19,7 @@
|
|
| 19 |
"class-variance-authority": "^0.7.0",
|
| 20 |
"clsx": "^2.1.1",
|
| 21 |
"lucide-react": "^0.427.0",
|
| 22 |
-
"next": "14.2.
|
| 23 |
"react": "^18",
|
| 24 |
"react-activity-calendar": "^2.2.11",
|
| 25 |
"react-dom": "^18",
|
|
@@ -27,11 +27,11 @@
|
|
| 27 |
"tailwindcss-animate": "^1.0.7"
|
| 28 |
},
|
| 29 |
"devDependencies": {
|
| 30 |
-
"typescript": "^5",
|
| 31 |
"@types/node": "^20",
|
| 32 |
"@types/react": "^18",
|
| 33 |
"@types/react-dom": "^18",
|
| 34 |
"postcss": "^8",
|
| 35 |
-
"tailwindcss": "^3.4.1"
|
|
|
|
| 36 |
}
|
| 37 |
}
|
|
|
|
| 19 |
"class-variance-authority": "^0.7.0",
|
| 20 |
"clsx": "^2.1.1",
|
| 21 |
"lucide-react": "^0.427.0",
|
| 22 |
+
"next": "^14.2.33",
|
| 23 |
"react": "^18",
|
| 24 |
"react-activity-calendar": "^2.2.11",
|
| 25 |
"react-dom": "^18",
|
|
|
|
| 27 |
"tailwindcss-animate": "^1.0.7"
|
| 28 |
},
|
| 29 |
"devDependencies": {
|
|
|
|
| 30 |
"@types/node": "^20",
|
| 31 |
"@types/react": "^18",
|
| 32 |
"@types/react-dom": "^18",
|
| 33 |
"postcss": "^8",
|
| 34 |
+
"tailwindcss": "^3.4.1",
|
| 35 |
+
"typescript": "^5"
|
| 36 |
}
|
| 37 |
}
|
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import React from "react";
|
| 4 |
+
import UserSearchDialog from "./UserSearchDialog";
|
| 5 |
+
import ThemeToggle from "./ThemeToggle";
|
| 6 |
+
|
| 7 |
+
const Navbar: React.FC = () => {
|
| 8 |
+
return (
|
| 9 |
+
<nav className="w-full mt-4">
|
| 10 |
+
<div className="max-w-6xl mx-auto px-4 py-3">
|
| 11 |
+
<div className="flex items-center justify-end gap-3">
|
| 12 |
+
<ThemeToggle />
|
| 13 |
+
<UserSearchDialog />
|
| 14 |
+
</div>
|
| 15 |
+
</div>
|
| 16 |
+
</nav>
|
| 17 |
+
);
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export default Navbar;
|
|
@@ -2,6 +2,9 @@ import React from "react";
|
|
| 2 |
import ActivityCalendar from "react-activity-calendar";
|
| 3 |
import { Tooltip, Avatar } from "@mui/material";
|
| 4 |
import Link from "next/link";
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
type HeatmapProps = {
|
| 7 |
data: Array<{ date: string; count: number; level: number }>;
|
|
@@ -11,9 +14,22 @@ type HeatmapProps = {
|
|
| 11 |
avatarUrl: string;
|
| 12 |
authorId: string;
|
| 13 |
showHeader?: boolean;
|
|
|
|
| 14 |
};
|
| 15 |
|
| 16 |
-
const Heatmap: React.FC<HeatmapProps> = ({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
return (
|
| 18 |
<div className="flex flex-col items-center w-full mx-auto">
|
| 19 |
{showHeader && (
|
|
@@ -37,16 +53,20 @@ const Heatmap: React.FC<HeatmapProps> = ({ data, color, providerName, fullName,
|
|
| 37 |
)}
|
| 38 |
<div className="w-full overflow-x-auto flex justify-center">
|
| 39 |
<ActivityCalendar
|
| 40 |
-
data={
|
| 41 |
theme={{
|
| 42 |
dark: ["#161b22", color],
|
| 43 |
light: ["#e0e0e0", color],
|
| 44 |
}}
|
| 45 |
-
blockSize={11}
|
| 46 |
hideTotalCount
|
| 47 |
renderBlock={(block, activity) => (
|
| 48 |
<Tooltip
|
| 49 |
-
title={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
arrow
|
| 51 |
>
|
| 52 |
{block}
|
|
|
|
| 2 |
import ActivityCalendar from "react-activity-calendar";
|
| 3 |
import { Tooltip, Avatar } from "@mui/material";
|
| 4 |
import Link from "next/link";
|
| 5 |
+
import { aggregateToWeeklyData, getWeekDateRange } from "../utils/weeklyCalendar";
|
| 6 |
+
|
| 7 |
+
type ViewMode = 'daily' | 'weekly';
|
| 8 |
|
| 9 |
type HeatmapProps = {
|
| 10 |
data: Array<{ date: string; count: number; level: number }>;
|
|
|
|
| 14 |
avatarUrl: string;
|
| 15 |
authorId: string;
|
| 16 |
showHeader?: boolean;
|
| 17 |
+
viewMode: ViewMode;
|
| 18 |
};
|
| 19 |
|
| 20 |
+
const Heatmap: React.FC<HeatmapProps> = ({
|
| 21 |
+
data,
|
| 22 |
+
color,
|
| 23 |
+
providerName,
|
| 24 |
+
fullName,
|
| 25 |
+
avatarUrl,
|
| 26 |
+
authorId,
|
| 27 |
+
showHeader = true,
|
| 28 |
+
viewMode
|
| 29 |
+
}) => {
|
| 30 |
+
// Process data based on view mode
|
| 31 |
+
const processedData = viewMode === 'weekly' ? aggregateToWeeklyData(data) : data;
|
| 32 |
+
|
| 33 |
return (
|
| 34 |
<div className="flex flex-col items-center w-full mx-auto">
|
| 35 |
{showHeader && (
|
|
|
|
| 53 |
)}
|
| 54 |
<div className="w-full overflow-x-auto flex justify-center">
|
| 55 |
<ActivityCalendar
|
| 56 |
+
data={processedData}
|
| 57 |
theme={{
|
| 58 |
dark: ["#161b22", color],
|
| 59 |
light: ["#e0e0e0", color],
|
| 60 |
}}
|
| 61 |
+
blockSize={viewMode === 'weekly' ? 15 : 11}
|
| 62 |
hideTotalCount
|
| 63 |
renderBlock={(block, activity) => (
|
| 64 |
<Tooltip
|
| 65 |
+
title={
|
| 66 |
+
viewMode === 'weekly'
|
| 67 |
+
? `${activity.count} new repos in week of ${getWeekDateRange(activity.date)}`
|
| 68 |
+
: `${activity.count} new repos on ${activity.date}`
|
| 69 |
+
}
|
| 70 |
arrow
|
| 71 |
>
|
| 72 |
{block}
|
|
@@ -1,7 +1,10 @@
|
|
| 1 |
-
import React from "react";
|
| 2 |
import { ProviderInfo, CalendarData } from "../types/heatmap";
|
| 3 |
import OrganizationCard from "./OrganizationCard";
|
| 4 |
import ProviderHeatmapSkeleton from "./ProviderHeatmapSkeleton";
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
interface HeatmapGridProps {
|
| 7 |
sortedProviders: ProviderInfo[];
|
|
@@ -10,6 +13,8 @@ interface HeatmapGridProps {
|
|
| 10 |
}
|
| 11 |
|
| 12 |
const HeatmapGrid: React.FC<HeatmapGridProps> = ({ sortedProviders, calendarData, isLoading }) => {
|
|
|
|
|
|
|
| 13 |
if (isLoading) {
|
| 14 |
return (
|
| 15 |
<div className="flex flex-col gap-8 max-w-4xl mx-auto mb-16">
|
|
@@ -22,12 +27,22 @@ const HeatmapGrid: React.FC<HeatmapGridProps> = ({ sortedProviders, calendarData
|
|
| 22 |
|
| 23 |
return (
|
| 24 |
<div className="flex flex-col gap-8 max-w-4xl mx-auto mb-16">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
{sortedProviders.map((provider, index) => (
|
| 26 |
<OrganizationCard
|
| 27 |
key={provider.fullName || provider.authors[0]}
|
| 28 |
provider={provider}
|
| 29 |
calendarData={calendarData}
|
| 30 |
rank={index + 1}
|
|
|
|
| 31 |
/>
|
| 32 |
))}
|
| 33 |
</div>
|
|
|
|
| 1 |
+
import React, { useState } from "react";
|
| 2 |
import { ProviderInfo, CalendarData } from "../types/heatmap";
|
| 3 |
import OrganizationCard from "./OrganizationCard";
|
| 4 |
import ProviderHeatmapSkeleton from "./ProviderHeatmapSkeleton";
|
| 5 |
+
import ViewToggle from "./ViewToggle";
|
| 6 |
+
|
| 7 |
+
type ViewMode = 'daily' | 'weekly';
|
| 8 |
|
| 9 |
interface HeatmapGridProps {
|
| 10 |
sortedProviders: ProviderInfo[];
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
const HeatmapGrid: React.FC<HeatmapGridProps> = ({ sortedProviders, calendarData, isLoading }) => {
|
| 16 |
+
const [viewMode, setViewMode] = useState<ViewMode>('weekly');
|
| 17 |
+
|
| 18 |
if (isLoading) {
|
| 19 |
return (
|
| 20 |
<div className="flex flex-col gap-8 max-w-4xl mx-auto mb-16">
|
|
|
|
| 27 |
|
| 28 |
return (
|
| 29 |
<div className="flex flex-col gap-8 max-w-4xl mx-auto mb-16">
|
| 30 |
+
{/* View Toggle */}
|
| 31 |
+
<div className="flex justify-center">
|
| 32 |
+
<ViewToggle
|
| 33 |
+
viewMode={viewMode}
|
| 34 |
+
onToggle={setViewMode}
|
| 35 |
+
/>
|
| 36 |
+
</div>
|
| 37 |
+
|
| 38 |
+
{/* Heatmap Cards */}
|
| 39 |
{sortedProviders.map((provider, index) => (
|
| 40 |
<OrganizationCard
|
| 41 |
key={provider.fullName || provider.authors[0]}
|
| 42 |
provider={provider}
|
| 43 |
calendarData={calendarData}
|
| 44 |
rank={index + 1}
|
| 45 |
+
viewMode={viewMode}
|
| 46 |
/>
|
| 47 |
))}
|
| 48 |
</div>
|
|
@@ -5,7 +5,7 @@ const Navbar: React.FC = () => {
|
|
| 5 |
return (
|
| 6 |
<nav className="w-full mt-4">
|
| 7 |
<div className="max-w-6xl mx-auto px-4 py-3">
|
| 8 |
-
<div className="flex items-center justify-end">
|
| 9 |
<UserSearchDialog />
|
| 10 |
</div>
|
| 11 |
</div>
|
|
|
|
| 5 |
return (
|
| 6 |
<nav className="w-full mt-4">
|
| 7 |
<div className="max-w-6xl mx-auto px-4 py-3">
|
| 8 |
+
<div className="flex items-center justify-end gap-3">
|
| 9 |
<UserSearchDialog />
|
| 10 |
</div>
|
| 11 |
</div>
|
|
@@ -3,13 +3,16 @@ import { ProviderInfo, CalendarData } from "../types/heatmap";
|
|
| 3 |
import Heatmap from "./Heatmap";
|
| 4 |
import OrganizationHeader from "./OrganizationHeader";
|
| 5 |
|
|
|
|
|
|
|
| 6 |
interface OrganizationCardProps {
|
| 7 |
provider: ProviderInfo;
|
| 8 |
calendarData: CalendarData;
|
| 9 |
rank: number;
|
|
|
|
| 10 |
}
|
| 11 |
|
| 12 |
-
const OrganizationCard = React.memo(({ provider, calendarData, rank }: OrganizationCardProps) => {
|
| 13 |
const providerName = provider.fullName || provider.authors[0];
|
| 14 |
const calendarKey = provider.authors[0];
|
| 15 |
const totalCount = calendarData[calendarKey]?.reduce((sum, day) => sum + day.count, 0) || 0;
|
|
@@ -32,6 +35,7 @@ const OrganizationCard = React.memo(({ provider, calendarData, rank }: Organizat
|
|
| 32 |
avatarUrl={provider.avatarUrl ?? ''}
|
| 33 |
authorId={calendarKey}
|
| 34 |
showHeader={false}
|
|
|
|
| 35 |
/>
|
| 36 |
</div>
|
| 37 |
|
|
|
|
| 3 |
import Heatmap from "./Heatmap";
|
| 4 |
import OrganizationHeader from "./OrganizationHeader";
|
| 5 |
|
| 6 |
+
type ViewMode = 'daily' | 'weekly';
|
| 7 |
+
|
| 8 |
interface OrganizationCardProps {
|
| 9 |
provider: ProviderInfo;
|
| 10 |
calendarData: CalendarData;
|
| 11 |
rank: number;
|
| 12 |
+
viewMode: ViewMode;
|
| 13 |
}
|
| 14 |
|
| 15 |
+
const OrganizationCard = React.memo(({ provider, calendarData, rank, viewMode }: OrganizationCardProps) => {
|
| 16 |
const providerName = provider.fullName || provider.authors[0];
|
| 17 |
const calendarKey = provider.authors[0];
|
| 18 |
const totalCount = calendarData[calendarKey]?.reduce((sum, day) => sum + day.count, 0) || 0;
|
|
|
|
| 35 |
avatarUrl={provider.avatarUrl ?? ''}
|
| 36 |
authorId={calendarKey}
|
| 37 |
showHeader={false}
|
| 38 |
+
viewMode={viewMode}
|
| 39 |
/>
|
| 40 |
</div>
|
| 41 |
|
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { cn } from "../lib/utils";
|
| 3 |
+
|
| 4 |
+
export interface Tag {
|
| 5 |
+
id: string;
|
| 6 |
+
label: string;
|
| 7 |
+
color?: string;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
interface TagSelectorProps {
|
| 11 |
+
tags: Tag[];
|
| 12 |
+
selectedTags: string[];
|
| 13 |
+
onTagToggle: (tagId: string) => void;
|
| 14 |
+
className?: string;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const TagSelector: React.FC<TagSelectorProps> = ({
|
| 18 |
+
tags,
|
| 19 |
+
selectedTags,
|
| 20 |
+
onTagToggle,
|
| 21 |
+
className,
|
| 22 |
+
}) => {
|
| 23 |
+
return (
|
| 24 |
+
<div className={cn("flex flex-col items-center gap-4", className)}>
|
| 25 |
+
<div className="flex items-center gap-3">
|
| 26 |
+
<h3 className="text-lg font-medium text-foreground">Select Tags</h3>
|
| 27 |
+
<span className="bg-blue-500 text-white text-sm px-2 py-1 rounded-full min-w-[24px] h-6 flex items-center justify-center">
|
| 28 |
+
{selectedTags.length}
|
| 29 |
+
</span>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
<div className="flex flex-wrap gap-3 justify-center max-w-4xl">
|
| 33 |
+
{tags.map((tag) => {
|
| 34 |
+
const isSelected = selectedTags.includes(tag.id);
|
| 35 |
+
return (
|
| 36 |
+
<button
|
| 37 |
+
key={tag.id}
|
| 38 |
+
onClick={() => onTagToggle(tag.id)}
|
| 39 |
+
className={cn(
|
| 40 |
+
"px-4 py-2 rounded-full border-2 transition-all duration-200 font-medium",
|
| 41 |
+
"hover:scale-105 active:scale-95",
|
| 42 |
+
isSelected
|
| 43 |
+
? "bg-blue-500 text-white border-blue-500 shadow-lg"
|
| 44 |
+
: cn(
|
| 45 |
+
"border-border hover:border-accent-foreground/20",
|
| 46 |
+
"bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground",
|
| 47 |
+
"dark:bg-card dark:text-card-foreground dark:border-border",
|
| 48 |
+
"light:bg-white light:text-gray-700 light:border-gray-300 light:hover:border-gray-400"
|
| 49 |
+
)
|
| 50 |
+
)}
|
| 51 |
+
>
|
| 52 |
+
{tag.label}
|
| 53 |
+
</button>
|
| 54 |
+
);
|
| 55 |
+
})}
|
| 56 |
+
</div>
|
| 57 |
+
</div>
|
| 58 |
+
);
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
export default TagSelector;
|
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from 'react';
|
| 2 |
+
import { useTheme } from '../contexts/ThemeContext';
|
| 3 |
+
import { cn } from '../lib/utils';
|
| 4 |
+
|
| 5 |
+
interface ThemeToggleProps {
|
| 6 |
+
className?: string;
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
const ThemeToggle: React.FC<ThemeToggleProps> = ({ className }) => {
|
| 10 |
+
const [mounted, setMounted] = useState(false);
|
| 11 |
+
const { theme, toggleTheme } = useTheme();
|
| 12 |
+
|
| 13 |
+
// Only render after hydration to prevent SSR issues
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
setMounted(true);
|
| 16 |
+
}, []);
|
| 17 |
+
|
| 18 |
+
if (!mounted) {
|
| 19 |
+
// Return a placeholder that matches the final component size
|
| 20 |
+
return (
|
| 21 |
+
<div className={cn(
|
| 22 |
+
"relative inline-flex h-10 w-10 items-center justify-center rounded-lg border border-border bg-background",
|
| 23 |
+
className
|
| 24 |
+
)}>
|
| 25 |
+
<div className="h-5 w-5 animate-pulse bg-muted rounded"></div>
|
| 26 |
+
</div>
|
| 27 |
+
);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<button
|
| 32 |
+
onClick={toggleTheme}
|
| 33 |
+
className={cn(
|
| 34 |
+
"relative inline-flex h-10 w-10 items-center justify-center rounded-lg border border-border bg-background hover:bg-accent hover:text-accent-foreground transition-colors",
|
| 35 |
+
className
|
| 36 |
+
)}
|
| 37 |
+
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
| 38 |
+
>
|
| 39 |
+
<div className="relative h-5 w-5">
|
| 40 |
+
{/* Sun Icon */}
|
| 41 |
+
<svg
|
| 42 |
+
className={cn(
|
| 43 |
+
"absolute inset-0 h-5 w-5 transition-all",
|
| 44 |
+
theme === 'dark' ? "rotate-90 scale-0" : "rotate-0 scale-100"
|
| 45 |
+
)}
|
| 46 |
+
fill="none"
|
| 47 |
+
viewBox="0 0 24 24"
|
| 48 |
+
stroke="currentColor"
|
| 49 |
+
strokeWidth={2}
|
| 50 |
+
>
|
| 51 |
+
<path
|
| 52 |
+
strokeLinecap="round"
|
| 53 |
+
strokeLinejoin="round"
|
| 54 |
+
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
| 55 |
+
/>
|
| 56 |
+
</svg>
|
| 57 |
+
|
| 58 |
+
{/* Moon Icon */}
|
| 59 |
+
<svg
|
| 60 |
+
className={cn(
|
| 61 |
+
"absolute inset-0 h-5 w-5 transition-all",
|
| 62 |
+
theme === 'dark' ? "rotate-0 scale-100" : "-rotate-90 scale-0"
|
| 63 |
+
)}
|
| 64 |
+
fill="none"
|
| 65 |
+
viewBox="0 0 24 24"
|
| 66 |
+
stroke="currentColor"
|
| 67 |
+
strokeWidth={2}
|
| 68 |
+
>
|
| 69 |
+
<path
|
| 70 |
+
strokeLinecap="round"
|
| 71 |
+
strokeLinejoin="round"
|
| 72 |
+
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
| 73 |
+
/>
|
| 74 |
+
</svg>
|
| 75 |
+
</div>
|
| 76 |
+
</button>
|
| 77 |
+
);
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
export default ThemeToggle;
|
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import { cn } from "../lib/utils";
|
| 3 |
+
|
| 4 |
+
type ViewMode = 'daily' | 'weekly';
|
| 5 |
+
|
| 6 |
+
interface ViewToggleProps {
|
| 7 |
+
viewMode: ViewMode;
|
| 8 |
+
onToggle: (mode: ViewMode) => void;
|
| 9 |
+
className?: string;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const ViewToggle: React.FC<ViewToggleProps> = ({ viewMode, onToggle, className }) => {
|
| 13 |
+
return (
|
| 14 |
+
<div className={cn("flex items-center gap-1 bg-muted rounded-lg p-1", className)}>
|
| 15 |
+
<button
|
| 16 |
+
onClick={() => onToggle('weekly')}
|
| 17 |
+
className={cn(
|
| 18 |
+
"px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-200",
|
| 19 |
+
viewMode === 'weekly'
|
| 20 |
+
? "bg-background text-foreground shadow-sm"
|
| 21 |
+
: "text-muted-foreground hover:text-foreground"
|
| 22 |
+
)}
|
| 23 |
+
>
|
| 24 |
+
Weekly
|
| 25 |
+
</button>
|
| 26 |
+
<button
|
| 27 |
+
onClick={() => onToggle('daily')}
|
| 28 |
+
className={cn(
|
| 29 |
+
"px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-200",
|
| 30 |
+
viewMode === 'daily'
|
| 31 |
+
? "bg-background text-foreground shadow-sm"
|
| 32 |
+
: "text-muted-foreground hover:text-foreground"
|
| 33 |
+
)}
|
| 34 |
+
>
|
| 35 |
+
Daily
|
| 36 |
+
</button>
|
| 37 |
+
</div>
|
| 38 |
+
);
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
export default ViewToggle;
|
|
@@ -1,18 +1,87 @@
|
|
| 1 |
import { ProviderInfo } from "../types/heatmap";
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
export const ORGANIZATIONS: ProviderInfo[] = [
|
| 4 |
-
{
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
{
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
{
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
];
|
|
|
|
| 1 |
import { ProviderInfo } from "../types/heatmap";
|
| 2 |
|
| 3 |
+
// Scientific area tags
|
| 4 |
+
export const SCIENTIFIC_TAGS = [
|
| 5 |
+
{ id: "antibody", label: "antibody" },
|
| 6 |
+
{ id: "hormones", label: "hormones" },
|
| 7 |
+
{ id: "materials-science", label: "materials-science" },
|
| 8 |
+
{ id: "drug-discovery", label: "drug-discovery" },
|
| 9 |
+
{ id: "biology", label: "biology" },
|
| 10 |
+
{ id: "medicine", label: "medicine" },
|
| 11 |
+
{ id: "physics", label: "physics" },
|
| 12 |
+
{ id: "data", label: "data" },
|
| 13 |
+
{ id: "education", label: "education" },
|
| 14 |
+
];
|
| 15 |
+
|
| 16 |
export const ORGANIZATIONS: ProviderInfo[] = [
|
| 17 |
+
{
|
| 18 |
+
color: "#ff7000",
|
| 19 |
+
authors: ["LeMaterial", "Entalpic"],
|
| 20 |
+
tags: ["materials-science", "drug-discovery"]
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
color: "#1877F2",
|
| 24 |
+
authors: ["arcinstitute"],
|
| 25 |
+
tags: ["biology", "medicine", "drug-discovery"]
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
color: "#10A37F",
|
| 29 |
+
authors: ["SandboxAQ"],
|
| 30 |
+
tags: ["physics", "materials-science", "drug-discovery"]
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
color: "#cc785c",
|
| 34 |
+
authors: ["Anthropic"],
|
| 35 |
+
tags: ["data", "education"]
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
color: "#DB4437",
|
| 39 |
+
authors: ["polymathic-ai"],
|
| 40 |
+
tags: ["physics", "data"]
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
color: "#F45098",
|
| 44 |
+
authors: ["NASA-AIML", "nasa-ibm-ai4science", "nasa-impact"],
|
| 45 |
+
tags: ["physics", "data", "education"]
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
color: "#FEB800",
|
| 49 |
+
authors: ["facebook"],
|
| 50 |
+
tags: ["data", "education"]
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
color: "#76B900",
|
| 54 |
+
authors: ["nvidia"],
|
| 55 |
+
tags: ["data", "physics", "materials-science"]
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
color: "#0088cc",
|
| 59 |
+
authors: ["Merck"],
|
| 60 |
+
tags: ["drug-discovery", "medicine", "biology"]
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
color: "#0088cc",
|
| 64 |
+
authors: ["wanglab"],
|
| 65 |
+
tags: ["biology", "medicine"]
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
color: "#0088cc",
|
| 69 |
+
authors: ["jablonkagroup"],
|
| 70 |
+
tags: ["materials-science", "drug-discovery"]
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
color: "#4C6EE6",
|
| 74 |
+
authors: ["Orbital-Materials"],
|
| 75 |
+
tags: ["materials-science", "physics"]
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
color: "#4C6EE6",
|
| 79 |
+
authors: ["Xaira-Therapeutics"],
|
| 80 |
+
tags: ["drug-discovery", "medicine", "biology", "antibody"]
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
color: "#FEC912",
|
| 84 |
+
authors: ["hugging-science"],
|
| 85 |
+
tags: ["data", "education", "biology", "physics"]
|
| 86 |
+
},
|
| 87 |
];
|
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { createContext, useContext, useEffect, useState } from 'react';
|
| 2 |
+
|
| 3 |
+
type Theme = 'light' | 'dark';
|
| 4 |
+
|
| 5 |
+
interface ThemeContextType {
|
| 6 |
+
theme: Theme;
|
| 7 |
+
toggleTheme: () => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
| 11 |
+
|
| 12 |
+
export const useTheme = () => {
|
| 13 |
+
const context = useContext(ThemeContext);
|
| 14 |
+
if (!context) {
|
| 15 |
+
throw new Error('useTheme must be used within a ThemeProvider');
|
| 16 |
+
}
|
| 17 |
+
return context;
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
interface ThemeProviderProps {
|
| 21 |
+
children: React.ReactNode;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
| 25 |
+
const [theme, setTheme] = useState<Theme>('dark');
|
| 26 |
+
const [mounted, setMounted] = useState(false);
|
| 27 |
+
|
| 28 |
+
useEffect(() => {
|
| 29 |
+
setMounted(true);
|
| 30 |
+
// Get theme from localStorage or system preference
|
| 31 |
+
const savedTheme = localStorage.getItem('theme') as Theme | null;
|
| 32 |
+
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
| 33 |
+
const initialTheme = savedTheme || systemTheme;
|
| 34 |
+
|
| 35 |
+
setTheme(initialTheme);
|
| 36 |
+
}, []);
|
| 37 |
+
|
| 38 |
+
useEffect(() => {
|
| 39 |
+
if (!mounted) return;
|
| 40 |
+
|
| 41 |
+
// Apply theme to document
|
| 42 |
+
const root = document.documentElement;
|
| 43 |
+
|
| 44 |
+
if (theme === 'dark') {
|
| 45 |
+
root.classList.add('dark');
|
| 46 |
+
} else {
|
| 47 |
+
root.classList.remove('dark');
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// Save to localStorage
|
| 51 |
+
localStorage.setItem('theme', theme);
|
| 52 |
+
}, [theme, mounted]);
|
| 53 |
+
|
| 54 |
+
const toggleTheme = () => {
|
| 55 |
+
setTheme(prev => prev === 'light' ? 'dark' : 'light');
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
// Prevent hydration mismatch by not rendering until mounted
|
| 59 |
+
if (!mounted) {
|
| 60 |
+
return <>{children}</>;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
return (
|
| 64 |
+
<ThemeContext.Provider value={{ theme, toggleTheme }}>
|
| 65 |
+
{children}
|
| 66 |
+
</ThemeContext.Provider>
|
| 67 |
+
);
|
| 68 |
+
};
|
|
@@ -1,10 +1,11 @@
|
|
| 1 |
-
import React, { useState, useEffect } from "react";
|
| 2 |
import { ProviderInfo, ModelData, CalendarData } from "../types/heatmap";
|
| 3 |
import OrganizationButton from "../components/OrganizationButton";
|
| 4 |
import HeatmapGrid from "../components/HeatmapGrid";
|
| 5 |
import Navbar from "../components/Navbar";
|
|
|
|
| 6 |
import { getProviders } from "../utils/ranking";
|
| 7 |
-
import { ORGANIZATIONS } from "../constants/organizations";
|
| 8 |
|
| 9 |
interface PageProps {
|
| 10 |
calendarData: CalendarData;
|
|
@@ -16,6 +17,7 @@ function Page({
|
|
| 16 |
providers,
|
| 17 |
}: PageProps) {
|
| 18 |
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
| 19 |
|
| 20 |
useEffect(() => {
|
| 21 |
if (calendarData && Object.keys(calendarData).length > 0) {
|
|
@@ -23,6 +25,27 @@ function Page({
|
|
| 23 |
}
|
| 24 |
}, [calendarData]);
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
return (
|
| 28 |
<div className="w-full">
|
|
@@ -45,10 +68,19 @@ function Page({
|
|
| 45 |
</p>
|
| 46 |
</div>
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
<div className="mb-16 mx-auto">
|
| 49 |
<div className="overflow-x-auto scrollbar-hide">
|
| 50 |
<div className="flex gap-6 px-4 py-2 min-w-max justify-center">
|
| 51 |
-
{
|
| 52 |
<OrganizationButton
|
| 53 |
key={provider.fullName || provider.authors[0]}
|
| 54 |
provider={provider}
|
|
@@ -61,7 +93,7 @@ function Page({
|
|
| 61 |
</div>
|
| 62 |
|
| 63 |
<HeatmapGrid
|
| 64 |
-
sortedProviders={
|
| 65 |
calendarData={calendarData}
|
| 66 |
isLoading={isLoading}
|
| 67 |
/>
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useMemo } from "react";
|
| 2 |
import { ProviderInfo, ModelData, CalendarData } from "../types/heatmap";
|
| 3 |
import OrganizationButton from "../components/OrganizationButton";
|
| 4 |
import HeatmapGrid from "../components/HeatmapGrid";
|
| 5 |
import Navbar from "../components/Navbar";
|
| 6 |
+
import TagSelector from "../components/TagSelector";
|
| 7 |
import { getProviders } from "../utils/ranking";
|
| 8 |
+
import { ORGANIZATIONS, SCIENTIFIC_TAGS } from "../constants/organizations";
|
| 9 |
|
| 10 |
interface PageProps {
|
| 11 |
calendarData: CalendarData;
|
|
|
|
| 17 |
providers,
|
| 18 |
}: PageProps) {
|
| 19 |
const [isLoading, setIsLoading] = useState(true);
|
| 20 |
+
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
| 21 |
|
| 22 |
useEffect(() => {
|
| 23 |
if (calendarData && Object.keys(calendarData).length > 0) {
|
|
|
|
| 25 |
}
|
| 26 |
}, [calendarData]);
|
| 27 |
|
| 28 |
+
// Filter providers based on selected tags
|
| 29 |
+
const filteredProviders = useMemo(() => {
|
| 30 |
+
if (selectedTags.length === 0) {
|
| 31 |
+
return providers;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
return providers.filter(provider => {
|
| 35 |
+
if (!provider.tags) return false;
|
| 36 |
+
return selectedTags.some(tag => provider.tags!.includes(tag));
|
| 37 |
+
});
|
| 38 |
+
}, [providers, selectedTags]);
|
| 39 |
+
|
| 40 |
+
const handleTagToggle = (tagId: string) => {
|
| 41 |
+
setSelectedTags(prev => {
|
| 42 |
+
if (prev.includes(tagId)) {
|
| 43 |
+
return prev.filter(t => t !== tagId);
|
| 44 |
+
} else {
|
| 45 |
+
return [...prev, tagId];
|
| 46 |
+
}
|
| 47 |
+
});
|
| 48 |
+
};
|
| 49 |
|
| 50 |
return (
|
| 51 |
<div className="w-full">
|
|
|
|
| 68 |
</p>
|
| 69 |
</div>
|
| 70 |
|
| 71 |
+
{/* Tag Selector */}
|
| 72 |
+
<div className="mb-16">
|
| 73 |
+
<TagSelector
|
| 74 |
+
tags={SCIENTIFIC_TAGS}
|
| 75 |
+
selectedTags={selectedTags}
|
| 76 |
+
onTagToggle={handleTagToggle}
|
| 77 |
+
/>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
<div className="mb-16 mx-auto">
|
| 81 |
<div className="overflow-x-auto scrollbar-hide">
|
| 82 |
<div className="flex gap-6 px-4 py-2 min-w-max justify-center">
|
| 83 |
+
{filteredProviders.map((provider, index) => (
|
| 84 |
<OrganizationButton
|
| 85 |
key={provider.fullName || provider.authors[0]}
|
| 86 |
provider={provider}
|
|
|
|
| 93 |
</div>
|
| 94 |
|
| 95 |
<HeatmapGrid
|
| 96 |
+
sortedProviders={filteredProviders}
|
| 97 |
calendarData={calendarData}
|
| 98 |
isLoading={isLoading}
|
| 99 |
/>
|
|
@@ -33,35 +33,33 @@
|
|
| 33 |
--background-rgb: 255, 255, 255;
|
| 34 |
}
|
| 35 |
|
| 36 |
-
|
| 37 |
-
:
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
--chart-5: 340 75% 55%;
|
| 64 |
-
}
|
| 65 |
}
|
| 66 |
}
|
| 67 |
|
|
|
|
| 33 |
--background-rgb: 255, 255, 255;
|
| 34 |
}
|
| 35 |
|
| 36 |
+
.dark {
|
| 37 |
+
--foreground-rgb: 255, 255, 255;
|
| 38 |
+
--background-rgb: 0, 0, 0;
|
| 39 |
+
--background: 222.2 84% 4.9%;
|
| 40 |
+
--foreground: 210 40% 98%;
|
| 41 |
+
--card: 222.2 84% 4.9%;
|
| 42 |
+
--card-foreground: 210 40% 98%;
|
| 43 |
+
--popover: 222.2 84% 4.9%;
|
| 44 |
+
--popover-foreground: 210 40% 98%;
|
| 45 |
+
--primary: 210 40% 98%;
|
| 46 |
+
--primary-foreground: 222.2 47.4% 11.2%;
|
| 47 |
+
--secondary: 217.2 32.6% 17.5%;
|
| 48 |
+
--secondary-foreground: 210 40% 98%;
|
| 49 |
+
--muted: 217.2 32.6% 17.5%;
|
| 50 |
+
--muted-foreground: 215 20.2% 65.1%;
|
| 51 |
+
--accent: 217.2 32.6% 17.5%;
|
| 52 |
+
--accent-foreground: 210 40% 98%;
|
| 53 |
+
--destructive: 0 62.8% 30.6%;
|
| 54 |
+
--destructive-foreground: 210 40% 98%;
|
| 55 |
+
--border: 217.2 32.6% 17.5%;
|
| 56 |
+
--input: 217.2 32.6% 17.5%;
|
| 57 |
+
--ring: 212.7 26.8% 83.9%;
|
| 58 |
+
--chart-1: 220 70% 50%;
|
| 59 |
+
--chart-2: 160 60% 45%;
|
| 60 |
+
--chart-3: 30 80% 55%;
|
| 61 |
+
--chart-4: 280 65% 60%;
|
| 62 |
+
--chart-5: 340 75% 55%;
|
|
|
|
|
|
|
| 63 |
}
|
| 64 |
}
|
| 65 |
|
|
@@ -1,6 +1,7 @@
|
|
| 1 |
export interface ProviderInfo {
|
| 2 |
color: string;
|
| 3 |
authors: string[];
|
|
|
|
| 4 |
fullName?: string;
|
| 5 |
avatarUrl?: string | null;
|
| 6 |
isVerified?: boolean;
|
|
|
|
| 1 |
export interface ProviderInfo {
|
| 2 |
color: string;
|
| 3 |
authors: string[];
|
| 4 |
+
tags?: string[];
|
| 5 |
fullName?: string;
|
| 6 |
avatarUrl?: string | null;
|
| 7 |
isVerified?: boolean;
|
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Activity } from "../types/heatmap";
|
| 2 |
+
|
| 3 |
+
export const aggregateToWeeklyData = (dailyData: Activity[]): Activity[] => {
|
| 4 |
+
if (!dailyData || dailyData.length === 0) return [];
|
| 5 |
+
|
| 6 |
+
const weeklyData: Activity[] = [];
|
| 7 |
+
let currentWeekStart: Date | null = null;
|
| 8 |
+
let currentWeekCount = 0;
|
| 9 |
+
let currentWeekLevel = 0;
|
| 10 |
+
|
| 11 |
+
for (const dayActivity of dailyData) {
|
| 12 |
+
const date = new Date(dayActivity.date);
|
| 13 |
+
const dayOfWeek = date.getDay(); // 0 = Sunday, 1 = Monday, etc.
|
| 14 |
+
|
| 15 |
+
// If it's Sunday or we don't have a current week, start a new week
|
| 16 |
+
if (dayOfWeek === 0 || currentWeekStart === null) {
|
| 17 |
+
// Save the previous week if it exists
|
| 18 |
+
if (currentWeekStart !== null) {
|
| 19 |
+
weeklyData.push({
|
| 20 |
+
date: currentWeekStart.toISOString().split('T')[0],
|
| 21 |
+
count: currentWeekCount,
|
| 22 |
+
level: currentWeekLevel,
|
| 23 |
+
});
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// Start new week
|
| 27 |
+
currentWeekStart = new Date(date);
|
| 28 |
+
currentWeekCount = dayActivity.count;
|
| 29 |
+
currentWeekLevel = dayActivity.level;
|
| 30 |
+
} else {
|
| 31 |
+
// Add to current week
|
| 32 |
+
currentWeekCount += dayActivity.count;
|
| 33 |
+
// Use the maximum level for the week
|
| 34 |
+
currentWeekLevel = Math.max(currentWeekLevel, dayActivity.level);
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
// Don't forget the last week
|
| 39 |
+
if (currentWeekStart !== null) {
|
| 40 |
+
weeklyData.push({
|
| 41 |
+
date: currentWeekStart.toISOString().split('T')[0],
|
| 42 |
+
count: currentWeekCount,
|
| 43 |
+
level: currentWeekLevel,
|
| 44 |
+
});
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
return weeklyData;
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
export const getWeekDateRange = (weekStartDate: string): string => {
|
| 51 |
+
const startDate = new Date(weekStartDate);
|
| 52 |
+
const endDate = new Date(startDate);
|
| 53 |
+
endDate.setDate(startDate.getDate() + 6);
|
| 54 |
+
|
| 55 |
+
const formatDate = (date: Date) => {
|
| 56 |
+
return date.toLocaleDateString('en-US', {
|
| 57 |
+
month: 'short',
|
| 58 |
+
day: 'numeric'
|
| 59 |
+
});
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
|
| 63 |
+
};
|