@firstlovecenter/milestone-grid
v0.8.2
Published
Reusable Subject × Milestone red/green grid: completion gate, polymorphic requirement types (files, yes/no, text, number, date, select, URL), optional nested substages, admin CRUD, and ports for auth + storage. Host injects auth, storage, and subject list
Downloads
4,047
Maintainers
Readme
@firstlovecenter/milestone-grid
Reusable Subject × Milestone red/green grid:
- Generic
Subject × Milestone × Submilestone × Requirement × Submission × Statusdata model — host-injectedsubjectIdis opaque (project, employee, missionary, anything). - Polymorphic requirements (0.6+): MEDIA / DOCUMENT file uploads, plus YES_NO, SHORT_TEXT, LONG_TEXT, NUMBER, DATE, SELECT, MULTI_SELECT, URL. Type-specific config + validation are built in.
- Completion gate that enforces "all required slots filled" across a milestone AND its optional one-level substage.
- Host-side adapters for auth, storage, and subject listing (no R2/S3 / NextAuth / Prisma coupling in the package).
- React UI primitives —
<MilestoneGrid />,<MilestoneCell />,<MilestoneSlots />,<RequirementSlot />,<SubmilestonePage />,<MilestoneAdminPanel />. - Route factories the host re-exports as Next.js API handlers.
Mirrors the patterns established by @firstlovecenter/ai-chat.
Requirement types
| Type | Storage | Notes |
| -------------- | ----------------- | ---------------------------------------------------------------- |
| MEDIA | object storage | presign → PUT → submission; images / video |
| DOCUMENT | object storage | presign → PUT → submission; PDFs |
| YES_NO | JSON value | true satisfies the gate, false does not |
| SHORT_TEXT | JSON value | trimmed string; config.maxLength optional (default 280) |
| LONG_TEXT | JSON value | trimmed string; config.maxLength optional (default 5000) |
| NUMBER | JSON value | finite number; config.{min,max,step} optional |
| DATE | JSON value | ISO YYYY-MM-DD string; config.{min,max} optional |
| SELECT | JSON value | one of config.options[].value |
| MULTI_SELECT | JSON value | non-empty subset of config.options[].value |
| URL | JSON value | RFC 3986 URL |
Install
pnpm add @firstlovecenter/milestone-grid1. Pick an ORM adapter
The package ships two persistence adapters; pick whichever matches your host:
Prisma — paste prisma/milestone-models.prisma into your host's schema.prisma, then pnpm prisma migrate dev.
import { createPrismaPersistence } from '@firstlovecenter/milestone-grid/server/prisma';
const persistence = createPrismaPersistence(prisma);Drizzle (MySQL) — import the canonical tables and re-export from your host's schema, then run a drizzle migration:
// src/db/schema.ts
export {
milestones,
milestoneRequirements,
subjectMilestoneStatus,
milestoneSubmissions
} from '@firstlovecenter/milestone-grid/server/drizzle';import { createDrizzlePersistence } from '@firstlovecenter/milestone-grid/server/drizzle';
const persistence = createDrizzlePersistence(db);Both adapters speak the same PersistencePort, so you can swap between them without changing routes or UI. subjectId is a free String in both — no FK is declared by the package. Your host owns whatever entity it points at.
2. Configure once at boot
// src/lib/milestones/configure.ts
import { configureMilestoneGrid } from '@firstlovecenter/milestone-grid/server';
import { createPrismaPersistence } from '@firstlovecenter/milestone-grid/server/prisma';
import { prisma } from '@/src/lib/db';
import { authPort } from '@/src/lib/milestones/auth';
import { storagePort } from '@/src/lib/milestones/storage';
export const milestoneGrid = configureMilestoneGrid({
persistence: createPrismaPersistence(prisma as unknown as PrismaLike),
auth: authPort,
storage: storagePort,
subjects: {
listSubjects: async () => {
const rows = await prisma.project.findMany({
where: { isActive: true },
select: { id: true, name: true }
});
return rows.map((p) => ({ id: String(p.id), label: p.name }));
}
}
});The three ports:
AuthPort<S>—requireAuth(req),isAdmin(scope),userId(scope). The package never callsnext-authdirectly.StoragePort—presignPut,streamGet,delete,stampKey,verifyKey. Wrap your R2/S3 helpers.SubjectsPort<S>—listSubjects(scope). Drives the grid's leftmost column.
3. Mount routes
Each Next route file is a thin re-export.
// app/api/milestone-grid/milestones/route.ts
import { milestoneGrid } from '@/src/lib/milestones/configure';
export const { GET, POST } = milestoneGrid.routes.milestones.list;Repeat for milestones/[id], milestones/reorder, milestones/[id]/requirements, requirements/[id], subjects/[subjectId]/milestones/[milestoneId], subjects/[subjectId]/submissions, submissions/[id], uploads/presign, uploads/[id], grid.
4. Render the grid
'use client';
import { useEffect, useState } from 'react';
import {
MilestoneGrid,
createMilestoneClient,
type GridSnapshot
} from '@firstlovecenter/milestone-grid/ui';
import '@firstlovecenter/milestone-grid/styles.css';
import '@firstlovecenter/milestone-grid/theme.css';
const client = createMilestoneClient();
export function GridPage() {
const [data, setData] = useState<GridSnapshot | null>(null);
useEffect(() => { void client.getGrid().then(setData); }, []);
if (!data) return null;
return (
<MilestoneGrid
subjects={data.subjects}
milestones={data.milestones}
requirements={data.requirements}
statuses={data.statuses}
filledCounts={data.filledCounts}
submissions={data.submissions}
onCellActivate={({ subjectId, milestoneId }) => {
// open your modal here
}}
/>
);
}5. Substages
A milestone may have at most one substage (Milestone.parentId points at the parent). Substages don't appear as their own columns in the grid; instead, the parent column's cell shows a "has substage" indicator, and clicking it opens the modal where the host renders the parent's slots plus an "Open substage" button that navigates to a dedicated page powered by <SubmilestonePage />.
The completion gate evaluates the parent's own slots and the substage's slots before allowing status=true.
Tailwind preset (optional)
The shipped dist/styles.css is precompiled and ready to import. Hosts that want to tree-shake utilities can extend the included Tailwind preset:
// tailwind.config.js
const milestoneGridPreset = require('@firstlovecenter/milestone-grid/tailwind-preset');
module.exports = { presets: [milestoneGridPreset], content: ['./app/**/*.tsx'] };Override CSS variables in your own stylesheet to retheme:
:root {
--mg-brand: oklch(0.58 0.2 145);
--mg-success-bg: oklch(0.95 0.04 145);
}Robustness note: inline styles on critical visuals
A few elements (the dropzone's dashed border, the modal/grid border, the
frosted-glass background on sticky table cells) are styled via inline
React style props rather than Tailwind classes. This is deliberate.
Many Tailwind v4 hosts ship a universal preflight override like:
@layer base {
*,
*::before,
*::after {
border-color: var(--border-default);
}
}A package that styled those visuals with classes like border-mg-fg-secondary
would land in @layer utilities (correct), but the universal selector in
@layer base still surfaces ahead in some cascade scenarios — the
border-color would silently fall back to the host's --border-default
and the dashed line would disappear. Inline styles win unconditionally,
so the package renders the same in every host regardless of preflight setup.
You can still re-theme via the --mg-* CSS variables (the inline styles
reference them).
