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
369 changes: 369 additions & 0 deletions frontend/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
"core-js": "^3.38.1",
"framer-motion": "^11.16.0",
"fuse": "^0.12.1",

"fuse.js": "^7.1.0",
"mobx": "^6.13.2",
"mobx-react-lite": "^4.1.0",
Expand All @@ -31,6 +30,7 @@
"react-icons": "^5.4.0",
"react-router-dom": "^6.26.2",
"react-transition-group": "^4.4.5",
"recharts": "^3.2.1",
"satcheljs": "^4.3.1"
},
"devDependencies": {
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/external/bcanSatchel/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export const updateEndDateFilter = action (
'updateEndDateFilter',
(endDateFilter: Date | null) => ({endDateFilter})
)
export const updateYearFilter = action (
'updateYearFilter',
(yearFilter: number[] | null) => ({yearFilter})
)

/**
* Append a new grant to the current list of grants.
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/external/bcanSatchel/mutators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
logoutUser,
fetchAllGrants,
updateFilter,
updateStartDateFilter, updateEndDateFilter
updateStartDateFilter, updateEndDateFilter, updateYearFilter
} from './actions';
import { getAppStore } from './store';

Expand Down Expand Up @@ -68,3 +68,8 @@ mutator(updateEndDateFilter, (actionMessage) => {
const store = getAppStore();
store.endDateFilter = actionMessage.endDateFilter;
})

mutator(updateYearFilter, (actionMessage) => {
const store = getAppStore();
store.yearFilter = actionMessage.yearFilter;
})
3 changes: 2 additions & 1 deletion frontend/src/external/bcanSatchel/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface AppState {
// TODO: should this be the ISODate type?
startDateFilter: Date | null;
endDateFilter: Date | null;
}
yearFilter:number[] | null;}

// Define initial state
const initialState: AppState = {
Expand All @@ -23,6 +23,7 @@ const initialState: AppState = {
filterStatus: null,
startDateFilter: null,
endDateFilter: null,
yearFilter: null,
};

const store = createStore<AppState>('appStore', initialState);
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/main-page/MainPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import Header from "./header/Header";
import Users from "./users/Users";

function MainPage() {


return (
<div className="w-full">
<Header />
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/main-page/dashboard/Charts/SampleChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { BarChart, Bar, CartesianGrid, XAxis, YAxis, Tooltip } from "recharts";
import { observer } from "mobx-react-lite";
import { useProcessGrantData } from "../../../main-page/grants/filter-bar/processGrantData";
import { aggregateMoneyGrantsByYear, YearAmount } from "../grantCalculations";

const SampleChart: React.FC = observer(() => {
const { grants } = useProcessGrantData();
const data = aggregateMoneyGrantsByYear(grants, "status").map(
(grant: YearAmount) => ({
name: grant.year.toString(),
active: grant.Active,
inactive: grant.Inactive,
})
);

return (
<div>
<BarChart
width={600}
height={300}
data={data}
margin={{ top: 5, right: 20, bottom: 5, left: 0 }}
>
<CartesianGrid stroke="#aaa" strokeDasharray="5 5" />
<Bar
type="monotone"
stackId="a"
dataKey="active"
fill="#90c4e5"
strokeWidth={2}
name="My data series name"
/>
<Bar
type="monotone"
stackId="a"
dataKey="inactive"
fill="#F58D5C"
strokeWidth={2}
name="My data series name"
/>
<XAxis dataKey="name" />
<YAxis
width="auto"
label={{ value: "UV", position: "insideLeft", angle: -90 }}
/>
<Tooltip />
</BarChart>
</div>
);
});

export default SampleChart;
22 changes: 19 additions & 3 deletions frontend/src/main-page/dashboard/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@

import DateFilter from "./DateFilter";
import "./styles/Dashboard.css";
import { observer } from "mobx-react-lite";
import SampleChart from "./Charts/SampleChart";
import { useEffect } from "react";
import { updateYearFilter, updateFilter, updateEndDateFilter, updateStartDateFilter } from "../../external/bcanSatchel/actions";

const Dashboard = observer(() => {

function Dashboard() {
// reset filters on initial render
useEffect(() => {
updateYearFilter(null);
updateFilter(null);
updateEndDateFilter(null);
updateStartDateFilter(null);
}, []);

return (
<div className="dashboard-page">
<div className="dashboard-page px-12 py-4">
<DateFilter />
<SampleChart/>
</div>
);
}
})

export default Dashboard;
60 changes: 60 additions & 0 deletions frontend/src/main-page/dashboard/DateFilter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { useState, useEffect } from "react";
import { updateYearFilter } from "../../external/bcanSatchel/actions";
import { getAppStore } from "../../external/bcanSatchel/store";
import { observer } from "mobx-react-lite";

const DateFilter: React.FC = observer(() => {
const { allGrants, yearFilter } = getAppStore();

// Generate unique years dynamically from grants
const uniqueYears = Array.from(
new Set(
allGrants.map((g) => new Date(g.application_deadline).getFullYear())
)
).sort((a, b) => a - b);

// Initialize selection from store or fallback to all years
const [selectedYears, setSelectedYears] = useState<number[]>(uniqueYears);

// Keep local selection in sync if store changes
useEffect(() => {
(yearFilter && yearFilter.length) === 0
? setSelectedYears(uniqueYears)
: setSelectedYears(yearFilter ?? uniqueYears);
}, [yearFilter, uniqueYears]);

// Update local store and state on checkbox change
const handleCheckboxChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const year = Number(event.target.value);
const checked = event.target.checked;

let updatedYears;
if (checked) {
updatedYears = [...selectedYears, year];
} else {
updatedYears = selectedYears.filter((y) => y !== year);
}

setSelectedYears(updatedYears);
updateYearFilter(updatedYears);
};

return (
<div className="flex flex-col space-y-2 mb-4">
{uniqueYears.map((year) => (
<label key={year} className="flex items-center space-x-2">
<input
type="checkbox"
value={year}
checked={selectedYears.includes(year)}
onChange={handleCheckboxChange}
defaultChecked={true}
/>
<span>{year}</span>
</label>
))}
</div>
);
});

export default DateFilter;
68 changes: 68 additions & 0 deletions frontend/src/main-page/dashboard/grantCalculations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Grant } from "../../../../middle-layer/types/Grant";

export type YearAmount = {
year: number;
[key: string]: number;
};

/**
* Aggregates total grant amounts by year, optionally grouped by a secondary key.
*
* @param grants - Array of grants.
* @param groupBy - Optional secondary grouping key (e.g., "status" or "organization").
* @returns Array of { year, [groupValue]: amount }.
*/
export function aggregateMoneyGrantsByYear(
grants: Grant[],
groupBy?: keyof Grant
): YearAmount[] {
const grouped: Record<number, Record<string, number>> = {};

for (const grant of grants) {
const year = new Date(grant.application_deadline).getUTCFullYear();
const groupValue = groupBy ? String(grant[groupBy] ?? "Unknown") : "All";

grouped[year] ??= {};
grouped[year][groupValue] = (grouped[year][groupValue] ?? 0) + grant.amount;
}

return Object.entries(grouped)
.map(([year, groups]) => ({
year: Number(year),
...groups,
}))
.sort((a, b) => a.year - b.year);
}

/**
* Aggregates distinct grant counts by year, optionally grouped by a secondary key.
*
* @param grants - Array of grants.
* @param groupBy - Optional secondary grouping key (e.g., "status" or "organization").
* @returns Array of { year, [groupValue]: count }.
*/
export function aggregateCountGrantsByYear(
grants: Grant[],
groupBy?: keyof Grant
): YearAmount[] {
const grouped: Record<number, Record<string, Set<number>>> = {};

for (const grant of grants) {
const year = new Date(grant.application_deadline).getUTCFullYear();
const groupValue = groupBy ? String(grant[groupBy] ?? "Unknown") : "All";

grouped[year] ??= {};
grouped[year][groupValue] ??= new Set<number>();
grouped[year][groupValue].add(grant.grantId);
}

return Object.entries(grouped)
.map(([year, groups]) => {
const counts: Record<string, number> = {};
for (const [key, ids] of Object.entries(groups)) {
counts[key] = ids.size;
}
return { year: Number(year), ...counts };
})
.sort((a, b) => a.year - b.year);
}
11 changes: 10 additions & 1 deletion frontend/src/main-page/grants/GrantPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,23 @@ import GrantList from "./grant-list/index.tsx";
import AddGrantButton from "./new-grant/AddGrant.tsx";
import GrantSearch from "./filter-bar/GrantSearch.tsx";
import NewGrantModal from "./new-grant/NewGrantModal.tsx";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Grant } from "../../../../middle-layer/types/Grant.ts";
import FilterBar from "./filter-bar/FilterBar.tsx";
import { updateEndDateFilter, updateFilter, updateStartDateFilter, updateYearFilter } from "../../external/bcanSatchel/actions.ts";

function GrantPage() {
const [showNewGrantModal, setShowNewGrantModal] = useState(false);
const [selectedGrant, setSelectedGrant] = useState<Grant | null>(null);

// reset filters on initial render
useEffect(() => {
updateYearFilter(null);
updateFilter(null);
updateEndDateFilter(null);
updateStartDateFilter(null);
}, []);

return (
<div className="grant-page px-8">
<div className="top-half">
Expand Down
10 changes: 5 additions & 5 deletions frontend/src/main-page/grants/filter-bar/CalendarDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const CalendarDropdown = observer(() => {

// ex: Apr 14th, 2025
const formatDate = (date: Date) =>
date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
date.toLocaleDateString("en-US", { month: "numeric", day: "numeric", year: "numeric" });

// state variable not needed since will always rerender with satchel changes
let displayText = "Select Date Range";
Expand All @@ -26,10 +26,10 @@ const CalendarDropdown = observer(() => {

return (
<div className="calendar-dropdown ">
<button className="calendar-toggle-button flex w-full justify-between items-center" onClick={toggleDropdown}>
<FaCalendarAlt/>
<span className="text-sm flex-shrink-0">{displayText}</span>
<FaChevronRight />
<button className="calendar-toggle-button flex w-full justify-between" onClick={toggleDropdown}>
{(!startDateFilter || !endDateFilter) && <FaCalendarAlt/>}
<div className="text-sm flex-shrink-0 block">{displayText}</div>
{(!startDateFilter || !endDateFilter) && <FaChevronRight />}
</button>

{isOpen && (
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/main-page/grants/filter-bar/FilterBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const FilterBar: React.FC = observer(() => {
}

return (
<div className="sortbar flex flex-col gap-4 bg-light-gray p-6 rounded-[1.2rem] border">
<div className="sortbar flex flex-col gap-4 bg-light-gray p-6 rounded-[1.2rem] border-[0.1rem]">
<div>
<div className="flex pb-2">{"Filter by Date"}</div>
<CalendarDropdown />
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/main-page/grants/filter-bar/grantFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,10 @@ export const dateRangeFilter = (start: Date | null, end: Date | null) => (grant:
if (start && date < start) return false;
if (end && date > end) return false;
return true;
};
};

export const yearFilterer = (years: number[] | null) => (grant: Grant) => {
if (!years) return true;
const grantYear = new Date(grant.application_deadline).getFullYear();
return years.includes(grantYear);
}
Loading