@propriety/court-calendar
v1.0.124
Published
A React component library for managing court dates, cases, and evidence. Built with FullCalendar, MUI, and Dexie (IndexedDB caching).
Keywords
Readme
@propriety/court-calendar
A React component library for managing court dates, cases, and evidence. Built with FullCalendar, MUI, and Dexie (IndexedDB caching).
Installation
npm install @propriety/court-calendarPeer dependencies:
npm install react react-dom @mui/material @mui/icons-material @mui/x-data-grid @mui/x-date-pickers @emotion/react @emotion/styledUsage
import { CCalendar } from '@propriety/court-calendar';
import '@propriety/court-calendar/styles.css';
function App() {
return <CCalendar apiKey="your-api-key" activeUser={1} />;
}CSS import
You must import the stylesheet separately:
import '@propriety/court-calendar/styles.css';Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| apiKey | string | required | API key for authenticating with the Aventine API |
| activeUser | number | required | The current user's ID (used for chair assignments and edit tracking) |
| showLegend | boolean | true | Whether to render the hearing-type legend below the calendar |
| mode | 'light' \| 'dark' | 'light' | Color scheme |
| themeOverrides | ThemeOptions | — | MUI theme overrides applied on top of the default theme |
| routeParams | CalendarRouteParams | — | Current URL state, parsed and passed in by the host app's router. See URL routing. |
| onRouteChange | (params: CalendarRouteParams) => void | — | Called whenever navigable state changes. The host app should write these params to the URL. See URL routing. |
URL routing / deep linking
By default the calendar is stateless from a URL perspective — navigating to a court date or applying a filter leaves no trace in the browser address bar. The routeParams / onRouteChange props opt into URL sync without coupling the library to any specific router.
How it works
The library never reads or writes window.location directly when onRouteChange is provided. Instead:
- Outbound: whenever navigable state changes (view, date, open modal, active filters),
onRouteChangeis called with aCalendarRouteParamsobject describing the full current state. - Inbound: on mount, the library reads
routeParamsonce and restores the saved state — including re-opening a court date modal or drilling straight into a case.
The host app owns the URL. It parses params out of its router, passes them as routeParams, and writes new params back in onRouteChange. Two utility functions handle the encoding:
import {
CCalendar,
parseCalendarSearchParams,
serializeCalendarParams,
} from '@propriety/court-calendar';Integration examples
React Router v7
import { useSearchParams } from 'react-router';
import { CCalendar, parseCalendarSearchParams, serializeCalendarParams } from '@propriety/court-calendar';
function CalendarPage() {
const [searchParams, setSearchParams] = useSearchParams();
return (
<CCalendar
apiKey={API_KEY}
activeUser={userId}
routeParams={parseCalendarSearchParams(searchParams)}
onRouteChange={(params) =>
setSearchParams(serializeCalendarParams(params), { replace: true })
}
/>
);
}useSearchParams returns a standard URLSearchParams object which parseCalendarSearchParams accepts directly. { replace: true } keeps the history stack clean so back/forward skip filter changes.
React Router v6
import { useSearchParams } from 'react-router-dom';
// rest is identical to v7React Router v6/v7 with useLocation
import { useLocation, useNavigate } from 'react-router';
import { CCalendar, parseCalendarSearchParams, serializeCalendarParams } from '@propriety/court-calendar';
function CalendarPage() {
const location = useLocation();
const navigate = useNavigate();
return (
<CCalendar
apiKey={API_KEY}
activeUser={userId}
routeParams={parseCalendarSearchParams(new URLSearchParams(location.search))}
onRouteChange={(params) => {
const qs = serializeCalendarParams(params).toString();
navigate(qs ? `${location.pathname}?${qs}` : location.pathname, { replace: true });
}}
/>
);
}React Router v5
import { useLocation, useHistory } from 'react-router-dom';
import { CCalendar, parseCalendarSearchParams, serializeCalendarParams } from '@propriety/court-calendar';
function CalendarPage() {
const location = useLocation();
const history = useHistory();
return (
<CCalendar
apiKey={API_KEY}
activeUser={userId}
routeParams={parseCalendarSearchParams(new URLSearchParams(location.search))}
onRouteChange={(params) => {
const qs = serializeCalendarParams(params).toString();
history.replace(qs ? `${location.pathname}?${qs}` : location.pathname);
}}
/>
);
}TanStack Router
import { useSearch, useNavigate } from '@tanstack/react-router';
import { CCalendar, parseCalendarSearchParams, serializeCalendarParams } from '@propriety/court-calendar';
function CalendarPage() {
const search = useSearch({ from: '/calendar' });
const navigate = useNavigate();
return (
<CCalendar
apiKey={API_KEY}
activeUser={userId}
routeParams={parseCalendarSearchParams(search as Record<string, string>)}
onRouteChange={(params) =>
navigate({ search: Object.fromEntries(serializeCalendarParams(params)), replace: true })
}
/>
);
}Next.js App Router
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { CCalendar, parseCalendarSearchParams, serializeCalendarParams } from '@propriety/court-calendar';
export default function CalendarPage() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
return (
<CCalendar
apiKey={API_KEY}
activeUser={userId}
routeParams={parseCalendarSearchParams(searchParams)}
onRouteChange={(params) => {
const qs = serializeCalendarParams(params).toString();
router.replace(qs ? `${pathname}?${qs}` : pathname);
}}
/>
);
}Standalone mode (no router)
When neither routeParams nor onRouteChange is provided, the library manages the URL itself using window.history.replaceState. This is suitable for single-page apps without a router or for standalone embedding. The URL updates in place without adding history entries.
URL parameter reference
Parameters use short keys to keep the address bar readable. Only non-default values appear in the URL — a fully default calendar state produces a clean URL with no search params.
| URL key | CalendarRouteParams field | Default (omitted when) | Notes |
|---------|----------------------------|------------------------|-------|
| view | view | 'dayGridMonth' | One of dayGridMonth, timeGridWeek, timeGridDay, listDay, tableView |
| date | date | today | ISO YYYY-MM-DD |
| cdId | courtDateId | absent | Integer court date ID — opens the details modal on load |
| ci | caseIdx | absent | Case.SCARIndexNumber — opens a specific case (requires cdId) |
| ht | showVirtual / showInPerson / showUnknown | all visible | Comma-separated list of hidden hearing types, e.g. ht=virtual,inPerson |
| user | showOnlyAssignedToUser | null | UserID integer, or -1 for "Unassigned" |
| dl | showUploadDeadlines | false | dl=1 when true |
| adj | showAdjournmentDates | true | adj=0 when false |
| dt | dateTypeFilter | 'all' | scar or negotiations |
| muni | municode | null | Municipality code string |
| settled | showOnlyUnsettled | true | settled=1 when the filter is off (inverted because default is on) |
| noEvid | showOnlyWithoutEvidence | false | noEvid=1 when true |
| unrev | showOnlyUnreviewed | false | unrev=1 when true |
| q | searchTerm | '' | Free-text search |
Example URL after opening a case with a filter applied:
/calendar?cdId=1234&ci=2024-98765&view=listDay&date=2025-06-15&dt=scarWhat is and isn't bookmarkable
The URL captures:
- Current calendar view and visible date
- Which court date modal is open (
cdId) - Which case is open within that modal (
ci) - All active filter state
The URL does not capture:
- Edit mode or Create mode (transient form state — these are suppressed from
onRouteChange)
Bootstrap sequence
When the page loads with params in the URL, the library restores state in phases to handle async data loading:
- Phase 1 (synchronous, on mount): View, date, and filter state are applied immediately from
routeParams. - Phase 1b: If
user=Nis in the URL, the User object is looked up from the reference data once it loads and merged into filter context.user=-1(Unassigned) is handled as a special case — no lookup is needed. - Phase 2: Once court date data finishes loading, the target court date is found and its details modal opened. If the court date has been filtered out of view (e.g. all its cases are settled and the unsettled filter is on), a minimal stub is built from the raw court date data so the modal still opens correctly.
- Phase 3: Once cases for the opened court date finish loading, the target case is found and opened. This works even for settled cases that are hidden from the calendar view —
selectedCasesalways contains all cases regardless of active filters.
If a cdId or ci value in the URL doesn't match any loaded data, a brief error snackbar is shown.
Architecture
Components
| Component | Description |
|-----------|-------------|
| CCalendar | Root component. Manages state, events, filtering, and renders the calendar + modal. |
| Toolbar | Navigation and filter controls (date type, hearing type, case filters, user filter, view switcher, municipality dropdown, search). |
| CalendarList | MUI DataGrid table view of court dates with color-coded status, county, and location columns. |
| Modal | Dialog with 4 modes: Details, Edit, Create, Case Details. |
| CreateEditCase | Form for court date fields (dates, municipality, hearing details, chair assignments, case IDs). |
| CaseViewer | DataGrid listing cases for a selected court date. |
| CaseDetails | Read-only view of a single case with evidence and SCAR data. |
Hooks
| Hook | Description |
|------|-------------|
| useCourtDates | Fetches and caches court dates and negotiations. Handles polling and optimistic in-memory updates. |
| useCaseData | Fetches and caches cases for all court dates. Maintains a Record<caseKey, Case[]> memory store. |
| useCalendarEvents | Derives FullCalendar event objects and CalEvent buckets from court date + case data. Applies filter context. |
| useModalSelection | Tracks which court date event is selected and manages openDetailsModal / openCreateModal. |
| useModalState | Manages form state for Edit/Create modes and clickedCase for Case Details mode. |
| useModalActions | Handles save, delete, and chair-update API calls. |
| useRouteSync | Syncs navigable state to/from the URL. See URL routing. |
| useScrollbarClickDetection | Prevents date-click from firing when the user clicks a scrollbar. |
Helpers
| Helper | Description |
|--------|-------------|
| helpers/routing.ts | parseCalendarSearchParams, serializeCalendarParams, buildCalendarRouteParams, extractFilterFields. Pure functions for URL encoding/decoding. |
| helpers/CalEvent.ts | buildCalEvents — derives main, extra-day, and adjournment CalEvent buckets from court date + case data. |
| helpers/cases.ts | Case fetching (by court date, paginated, search). Settlement and evidence checks. |
| helpers/courtDates.ts | caseKey (memory-map key), isVillageDate (municipality code check). |
| helpers/documentActions.ts | Open, download, and print document helpers (used by DocCard). |
| helpers/formatter.ts | Date formatting for API (YYYY-MM-DD or YYYY-MM-DD HH:mm:00). Evidence string formatting. |
| helpers/cache.ts | Dexie-based IndexedDB caching with TTL. |
| helpers/api/ | Fetch wrappers for each API endpoint group (court dates, cases, munis, people). |
CalEvent
CalEvent is the internal representation of a single calendar entry. One CourtDate can produce multiple CalEvent objects:
| Type | isAdjourned | isExtraDay | Description |
|------|---------------|--------------|-------------|
| Main | false | false | The primary entry on the original court date. Holds all non-adjourned cases. |
| Extra-day | false | true | Additional days from CourtDate.MultipleDates. Same cases as the main event. |
| Adjournment | true | false | One entry per unique adjourned date, holding the cases adjourned to that date. |
URL routing always targets the main CalEvent when restoring a modal — adjournment and extra-day entries are never used as the bootstrap target.
Key types
interface CalendarRouteParams {
view?: 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay' | 'listDay' | 'tableView';
date?: string; // ISO: "YYYY-MM-DD"
courtDateId?: number;
caseIdx?: string; // Case.SCARIndexNumber
showVirtual?: boolean;
showInPerson?: boolean;
showUnknown?: boolean;
showOnlyAssignedToUser?: number | null;
showUploadDeadlines?: boolean;
showAdjournmentDates?: boolean;
dateTypeFilter?: 'all' | 'scar' | 'negotiations';
municode?: string | null;
showOnlyUnsettled?: boolean;
showOnlyWithoutEvidence?: boolean;
showOnlyUnreviewed?: boolean;
searchTerm?: string;
}
interface CourtDate {
CourtDateID: number;
CourtDate: Date;
MuniCode: string;
UploadDeadline: Date | null;
CourtCases: number;
Lifecycle: 'Scheduled' | 'Assigned' | 'Uploaded' | 'Adjourned' | null;
DateType: '' | 'Negotiations' | null; // '' = SCAR
HearingTime: string;
HearingLink: string | null;
Source: 'manual' | 'email' | 'etrack' | null;
Type: 'Other' | 'Virtual' | 'InPerson' | null;
FirstChair: number | null;
SecondChair: number | null;
HearingOfficer: number | null;
Notes: Notes[] | null;
NoticeFile: string | null;
MultipleDates: Date[] | null;
Address: string | null;
Room: string | null;
SkipMotion: boolean | null;
}
interface Case {
ParcelID: string;
Municipality: string;
SCARIndexNumber: string;
SCARDeterminationAction: string;
property_data: { Address: string; PropertyOwnerFull: string };
evidence: Evidence | null;
AdjournedDate: Date | null;
// ... plus Village SCAR fields and negotiation/stip tracking
}Development
Prerequisites
- Node.js 20+
- npm
Setup
git clone [email protected]:Aventine-Git/court-calendar.git
cd court-calendar
npm installDev server
npm run devStarts the Vite dev server using dev/App.tsx as the entry point. The API only accepts requests from localhost:8000 — the dev server must run on port 8000 or API calls will fail with CORS errors.
Environment variables
Create a .env file in the project root:
VITE_CALENDAR_API_KEY=your-api-key-hereBuild
npm run buildRuns tsc -b then the Vite library build. Output in dist/:
dist/index.mjs— ES module bundledist/index.d.ts— TypeScript declarationsdist/court-calendar.css— compiled styles
React, React-DOM, MUI, and Emotion are externalized (peer dependencies, not bundled).
Lint
npm run lintESLint with TypeScript plugin. Biome is configured for formatting (4-space indent, 120-char line width, single quotes).
Testing
npm run testVitest + @testing-library/react + jsdom. Test files live in src/__tests__/ mirroring the source tree:
| Test file | Covers |
|-----------|--------|
| helpers/CalEvent.test.ts | buildCalEvents, parseLocalDate |
| helpers/routing.test.ts | serializeCalendarParams, parseCalendarSearchParams, buildCalendarRouteParams, extractFilterFields |
| hooks/UseModalState.test.ts | useModalState — edit/create init, modal close effects, clickedCase transitions |
| hooks/UseRouteSync.test.ts | useRouteSync — outbound sync, replaceState fallback, bootstrap Phases 1–3, bootstrap errors |
API
All data is fetched from https://utils.aventine.ai. Every request requires the x-api-key header.
Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| /court-dates/all | GET | Fetch all court dates |
| /court-dates/create | POST | Create a new court date |
| /court-dates/{id}/update | PUT | Update a court date |
| /court-dates/{id} | DELETE | Delete a court date |
| /court-dates/{id}/cases | GET | Fetch cases for a court date |
| /court-cases/search?term=...&page=... | GET | Search cases (paginated) |
| /court-cases/filtering?page=...&page_size=... | GET | List all cases (paginated) |
| /court-cases/snooze/upload/{id} | GET | Snooze upload deadline by 1 business day |
| /users/all | GET | Fetch all users |
| /users/hearing-officers | GET | Fetch hearing officers |
| /utils/get-muni-names | GET | Fetch municipality names |
CI/CD
Publishing is automated via GitHub Actions (.github/workflows/publish.yml).
Trigger: every merged PR to main.
Steps:
- Checkout code
- Install dependencies (
npm ci) - Build (
npm run build) - Set version to
0.0.{run_number}(auto-incrementing, no commit needed) - Publish to npm with provenance
Required secrets:
NPM_TOKEN— npm access token with publish rights for the@proprietyscope
Known limitations
- Port 8000 only: The backend API enforces CORS that only allows requests from
localhost:8000. Applies to the dev server and any locally running consumer. - Cache is not invalidated on external changes: If another user modifies a court date, the change won't appear until the local cache expires (1 minute for dates, 2 minutes for cases).
- No pagination for court dates:
getAllDatesfetches every court date in a single request. - Single API key: No built-in token refresh or rotation.
- No error UI for API failures: Network errors are logged to the console but not surfaced to the user (except for bootstrap errors, which show a brief snackbar).
