Skip to main content
Glama
northernvariables

FedMCP - Federal Parliamentary Information

BillGanttWidget.tsx12.1 kB
/** * BillGanttWidget Component * Interactive GANTT-style visualization of Order Paper bills * Shows bills progressing through House, Committee, and Senate swimlanes */ 'use client'; import { useState, useMemo } from 'react'; import { useQuery } from '@apollo/client'; import { useTranslations, useLocale } from 'next-intl'; import { Card } from '@canadagpt/design-system'; import { Loading } from '@/components/Loading'; import { ChevronDown, ChevronUp } from 'lucide-react'; import { GET_ORDER_PAPER_GANTT } from '@/lib/ganttQueries'; import { filterOrderPaperBills, groupBillsBySwimlane, calculateStagePosition, type BillGanttData, } from '@/lib/billGanttUtils'; import { BillSquare } from './BillSquare'; // Map bills to stage columns and tile them in a 3-row grid interface BillPosition { column: number; // Stage column index row: number; // Row within column (0-2) col: number; // Column within stage (for multiple bills at same stage) } function assignBillsToGrid( bills: BillGanttData[], swimlaneType: 'house' | 'committee' | 'senate' ): Map<string, BillPosition> { const positions = new Map<string, BillPosition>(); // Define stage columns for each swimlane const stageColumns: { [key: string]: number[] } = { house: [10, 33, 66, 95], // Intro, 1st, 2nd, Royal Assent committee: [50, 75, 95], // Committee, Report, Royal Assent senate: [25, 50, 75, 95], // 1st, 2nd, 3rd, Royal Assent }; const columns = stageColumns[swimlaneType]; // Group bills by their stage column const billsByColumn = new Map<number, BillGanttData[]>(); columns.forEach((col) => billsByColumn.set(col, [])); // Assign each bill to the nearest column for (const bill of bills) { const stagePos = calculateStagePosition(bill); // Find closest column let closestColumn = columns[0]; let minDist = Math.abs(stagePos - columns[0]); for (const col of columns) { const dist = Math.abs(stagePos - col); if (dist < minDist) { minDist = dist; closestColumn = col; } } billsByColumn.get(closestColumn)?.push(bill); } // Tile bills within each column (3 rows × N columns) billsByColumn.forEach((columnBills, columnIndex) => { columnBills.forEach((bill, index) => { const row = index % 3; // 0, 1, or 2 const col = Math.floor(index / 3); // Column offset within stage positions.set(`${bill.session}-${bill.number}`, { column: columnIndex, row, col, }); }); }); return positions; } interface BillGanttWidgetProps { currentSession: string; } export function BillGanttWidget({ currentSession }: BillGanttWidgetProps) { const t = useTranslations('bills.gantt'); const locale = useLocale(); const [billLimit, setBillLimit] = useState(25); const [isCollapsed, setIsCollapsed] = useState(false); const { data, loading, error } = useQuery(GET_ORDER_PAPER_GANTT, { variables: { session: currentSession, limit: 100, }, }); // Process and filter bills const displayBills = useMemo(() => { if (!data?.searchBills) return []; return filterOrderPaperBills(data.searchBills, billLimit); }, [data, billLimit]); const groupedBills = useMemo(() => { return groupBillsBySwimlane(displayBills); }, [displayBills]); if (error) { return null; // Silently fail if widget can't load } if (isCollapsed) { return ( <Card className="mb-6 cursor-pointer" onClick={() => setIsCollapsed(false)}> <div className="p-4 flex items-center justify-between"> <div className="flex items-center gap-2"> <h2 className="text-lg font-semibold text-primary"> {t('title')} </h2> <span className="text-sm text-secondary"> ({displayBills.length} {t('activeBills')}) </span> </div> <ChevronDown className="w-5 h-5 text-secondary" /> </div> </Card> ); } return ( <Card className="mb-4"> <div className="flex items-center justify-between mb-1.5 p-4 pb-0"> <div className="flex items-center gap-2"> <h2 className="text-xl font-semibold text-primary"> {t('title')} </h2> <span className="text-sm text-secondary"> ({displayBills.length} {t('activeBills')}) </span> </div> <div className="flex items-center gap-2"> <a href="#search-legislation" className="text-xs px-3 py-1.5 rounded-md border border-subtle hover:border-primary text-secondary hover:text-primary transition-colors flex items-center gap-1.5" > <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <circle cx="11" cy="11" r="8"></circle> <path d="m21 21-4.35-4.35"></path> </svg> Search </a> <button onClick={() => setIsCollapsed(true)} className="text-secondary hover:text-primary transition-colors" aria-label={t('collapse')} > <ChevronUp className="w-4 h-4" /> </button> </div> </div> {loading ? ( <div className="p-8 flex justify-center"> <Loading /> </div> ) : ( <> <div className="px-4"> {displayBills.length === 0 ? ( <div className="text-center text-secondary py-4"> {t('noBills')} </div> ) : ( <div className="space-y-2"> {/* Senate Swimlane - Red Chamber */} <Swimlane name={t('senate')} color="red" bills={groupedBills.senate} swimlaneType="senate" stages={[ { label: t('stages.senateFirst'), position: 25 }, { label: t('stages.senateSecond'), position: 50 }, { label: t('stages.senateThird'), position: 75 }, { label: t('stages.royalAssent'), position: 95 }, ]} /> {/* House Procedure Swimlane */} <Swimlane name={t('houseProcedure')} color="gray" bills={groupedBills.committee} swimlaneType="committee" stages={[ { label: t('stages.committeeReview'), position: 50 }, { label: t('stages.reportStage'), position: 75 }, { label: t('stages.royalAssent'), position: 95 }, ]} /> {/* House of Commons Swimlane - Green Chamber */} <Swimlane name={t('house')} color="green" bills={groupedBills.house} swimlaneType="house" stages={[ { label: t('stages.introduced'), position: 10 }, { label: t('stages.houseFirst'), position: 33 }, { label: t('stages.houseSecond'), position: 66 }, { label: t('stages.royalAssent'), position: 95 }, ]} /> </div> )} </div> {/* Controls at bottom */} <div className="px-4 pb-4 pt-3 border-t border-subtle"> {/* Activity slider control */} <div className="flex items-center gap-3 mb-2"> <label htmlFor="bill-limit" className="text-xs text-secondary whitespace-nowrap"> Show Top {billLimit} Most Active </label> <input id="bill-limit" type="range" min="3" max="50" step="1" value={billLimit} onChange={(e) => setBillLimit(Number(e.target.value))} className="flex-1 h-1.5 bg-secondary rounded-lg appearance-none cursor-pointer accent-accent-red" /> </div> {/* Color legend */} <div className="flex flex-wrap gap-2 text-xs"> <div className="flex items-center gap-1"> <div className="w-2.5 h-2.5 rounded-sm bg-green-500 border border-green-400" /> <span className="text-secondary text-xs">{t('legend.government')}</span> </div> <div className="flex items-center gap-1"> <div className="w-2.5 h-2.5 rounded-sm bg-amber-500 border border-amber-400" /> <span className="text-secondary text-xs">{t('legend.privateMember')}</span> </div> <div className="flex items-center gap-1"> <div className="w-2.5 h-2.5 rounded-sm bg-red-500 border border-red-400" /> <span className="text-secondary text-xs">{t('legend.senateGov')}</span> </div> <div className="flex items-center gap-1"> <div className="w-2.5 h-2.5 rounded-sm bg-orange-500 border border-orange-400" /> <span className="text-secondary text-xs">{t('legend.senatePublic')}</span> </div> </div> </div> </> )} </Card> ); } interface SwimlaneProps { name: string; color: 'red' | 'green' | 'gray'; bills: BillGanttData[]; swimlaneType: 'house' | 'committee' | 'senate'; stages: Array<{ label: string; position: number }>; } function Swimlane({ name, color, bills, swimlaneType, stages }: SwimlaneProps) { const bgColor = color === 'red' ? 'bg-red-500/10 border-red-500/30' : color === 'green' ? 'bg-green-500/10 border-green-500/30' : 'bg-secondary/5 border-secondary/30'; const textColor = color === 'red' ? 'text-red-400' : color === 'green' ? 'text-green-400' : 'text-secondary'; // Calculate grid positions for bills const billPositions = useMemo(() => assignBillsToGrid(bills, swimlaneType), [bills, swimlaneType]); return ( <div className={`border rounded-md ${bgColor}`}> {/* Swimlane header */} <div className="px-2 py-1 flex items-center justify-between"> <h3 className={`text-xs font-semibold uppercase tracking-wide ${textColor}`}> {name} </h3> <span className="text-xs text-secondary"> {bills.length} </span> </div> {/* Swimlane track with columns */} <div className="relative h-32 pb-8 overflow-visible"> {/* Stage column dividers and labels */} {stages.map((stage, index) => ( <div key={stage.label} className="absolute top-0 bottom-0" style={{ left: `${stage.position}%` }} > {/* Vertical divider */} <div className="absolute top-0 bottom-8 w-px bg-secondary/50 -translate-x-1/2" /> {/* Stage label at bottom */} <div className="absolute bottom-0 left-1/2 -translate-x-1/2"> <span className="text-[10px] font-semibold text-primary whitespace-nowrap"> {stage.label} </span> </div> </div> ))} {/* Bill squares in grid */} {bills.map((bill) => { const pos = billPositions.get(`${bill.session}-${bill.number}`); if (!pos) return null; return ( <BillSquare key={`${bill.session}-${bill.number}`} bill={bill} gridPosition={pos} swimlane={swimlaneType} /> ); })} {bills.length === 0 && ( <div className="absolute inset-0 flex items-center justify-center"> <span className="text-xs text-tertiary italic">No bills in this stage</span> </div> )} </div> </div> ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/northernvariables/FedMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server