Add First Time Guest Date and Salvation Date fields

- Add firstTimeGuestDate and salvationDate (DateTime?) to Prisma schema
- Auto-compute dates during OCR: find previous Sunday from card createdAt
  when visitType indicates first/second time guest or nextStep includes
  Baptism
- Add editable date inputs on card detail page in Workflow section
- Add to Monday.com mappable fields in settings for column mapping
- Add table columns (hidden by default) for both date fields
- Handle full ISO datetime strings in Monday.com date parser
- Apply same logic during reprocessing

Made-with: Cursor
This commit is contained in:
Randall Stillwell 2026-04-09 17:20:24 -05:00
parent d6d209cf9d
commit b2c29120cd
8 changed files with 99 additions and 5 deletions

View file

@ -49,6 +49,8 @@ model ResponseCard {
planningCenter String?
iSaidYesBookSent Boolean @default(false)
ftGuestLetterSent Boolean @default(false)
firstTimeGuestDate DateTime?
salvationDate DateTime?
// Meta
sourceFile String?

View file

@ -93,6 +93,12 @@ export async function PUT(
if (body.prayerConfidential != null) data.prayerConfidential = Boolean(body.prayerConfidential);
if (body.iSaidYesBookSent != null) data.iSaidYesBookSent = Boolean(body.iSaidYesBookSent);
if (body.ftGuestLetterSent != null) data.ftGuestLetterSent = Boolean(body.ftGuestLetterSent);
for (const dateField of ["firstTimeGuestDate", "salvationDate"] as const) {
if (body[dateField] !== undefined) {
data[dateField] = body[dateField] ? new Date(body[dateField]) : null;
}
}
if (body.ocrConfidence != null) data.ocrConfidence = Number(body.ocrConfidence);
if (body.messageTopics != null) data.messageTopics = body.messageTopics;
if (body.nextStep != null) data.nextStep = body.nextStep;

View file

@ -97,6 +97,8 @@ type CardData = {
planningCenter: string | null;
iSaidYesBookSent: boolean;
ftGuestLetterSent: boolean;
firstTimeGuestDate: string | null;
salvationDate: string | null;
ocrStatus: string;
reviewStatus: string;
ocrConfidence: number | null;
@ -189,6 +191,17 @@ export default function CardDetailPage() {
return Boolean(card?.[field]);
};
const getDateValue = (field: keyof CardData): string => {
if (field in edits) {
const v = edits[field];
if (!v) return "";
return String(v).slice(0, 10);
}
const val = card?.[field];
if (!val) return "";
return String(val).slice(0, 10);
};
const setField = (field: string, value: unknown) => {
setEdits((prev) => ({ ...prev, [field]: value }));
};
@ -505,6 +518,8 @@ export default function CardDetailPage() {
<Field label="Follow-Up" value={getValue("followUp")} onChange={(v) => setField("followUp", v)} />
<Field label="Service Time" value={getValue("serviceTime")} onChange={(v) => setField("serviceTime", v)} />
<Field label="Planning Center" value={getValue("planningCenter")} onChange={(v) => setField("planningCenter", v)} />
<DateField label="First Time Guest Date" value={getDateValue("firstTimeGuestDate")} onChange={(v) => setField("firstTimeGuestDate", v || null)} />
<DateField label="Salvation Date" value={getDateValue("salvationDate")} onChange={(v) => setField("salvationDate", v || null)} />
<BooleanField label="I Said Yes Book Sent" value={getBoolValue("iSaidYesBookSent")} onChange={(v) => setField("iSaidYesBookSent", v)} />
<BooleanField label="FT Guest Letter Sent" value={getBoolValue("ftGuestLetterSent")} onChange={(v) => setField("ftGuestLetterSent", v)} />
</div>
@ -800,6 +815,15 @@ function MultiSelectField({
);
}
function DateField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<div>
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
<Input type="date" value={value || ""} onChange={(e) => onChange(e.target.value)} />
</div>
);
}
function BooleanField({
label,
value,

View file

@ -91,6 +91,8 @@ const MONDAY_MAPPABLE_FIELDS = [
{ field: "planningCenter", label: "Planning Center" },
{ field: "iSaidYesBookSent", label: "I Said Yes Book Sent" },
{ field: "ftGuestLetterSent", label: "FT Guest Letter Sent" },
{ field: "firstTimeGuestDate", label: "First Time Guest Date" },
{ field: "salvationDate", label: "Salvation Date" },
{ field: "reviewStatus", label: "Review Status" },
] as const;

View file

@ -110,6 +110,8 @@ export type ResponseCard = {
planningCenter: string | null;
iSaidYesBookSent: boolean;
ftGuestLetterSent: boolean;
firstTimeGuestDate: string | null;
salvationDate: string | null;
mondayItemId: string | null;
ocrStatus: string;
reviewStatus: string;
@ -447,6 +449,22 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
header: "FT Guest Letter",
cell: ({ row }) => <BoolCell value={row.original.ftGuestLetterSent} />,
},
{
accessorKey: "firstTimeGuestDate",
header: "FT Guest Date",
cell: ({ row }) => {
const v = row.original.firstTimeGuestDate;
return <span className="truncate text-sm">{v ? new Date(v).toLocaleDateString() : "—"}</span>;
},
},
{
accessorKey: "salvationDate",
header: "Salvation Date",
cell: ({ row }) => {
const v = row.original.salvationDate;
return <span className="truncate text-sm">{v ? new Date(v).toLocaleDateString() : "—"}</span>;
},
},
{
id: "mondayLinked",
header: "Monday.com",

View file

@ -90,6 +90,8 @@ const COLUMN_GROUPS: { label: string; columns: { id: string; label: string }[] }
{ id: "planningCenter", label: "Planning Center" },
{ id: "iSaidYesBookSent", label: "I Said Yes Book" },
{ id: "ftGuestLetterSent", label: "FT Guest Letter" },
{ id: "firstTimeGuestDate", label: "FT Guest Date" },
{ id: "salvationDate", label: "Salvation Date" },
{ id: "mondayLinked", label: "Monday.com" },
],
},
@ -102,7 +104,7 @@ const DEFAULT_HIDDEN: string[] = [
"prayerRequests", "prayerForTeam", "prayerConfidential",
"messageTopics", "nextStep", "campusPreference", "howHeard",
"followUp", "notes", "serviceTime", "planningCenter",
"iSaidYesBookSent", "ftGuestLetterSent", "mondayLinked",
"iSaidYesBookSent", "ftGuestLetterSent", "firstTimeGuestDate", "salvationDate", "mondayLinked",
];
export function getDefaultColumnVisibility(): VisibilityState {

View file

@ -249,6 +249,10 @@ function normalizeYear(y: string): string {
function parseToISO(dateStr: string): string | null {
const s = dateStr.replace(/\s+/g, " ").trim();
// Full ISO datetime e.g. "2026-04-06T00:00:00.000Z"
const isoFull = s.match(/^(\d{4})-(\d{2})-(\d{2})T/);
if (isoFull) return `${isoFull[1]}-${isoFull[2]}-${isoFull[3]}`;
// MM/DD/YYYY or DD/MM/YYYY (all-numeric)
const numParts = s.match(/^(\d{1,2})\s*[/\-.]\s*(\d{1,2})\s*[/\-.]\s*(\d{2,4})$/);
if (numParts) {

View file

@ -114,6 +114,10 @@ export async function processFile(
const avgConfidence = confidenceCount > 0 ? totalConfidence / confidenceCount : 0;
const visitType = asString(responseData.visitType);
const nextStepArr = surveyData.nextStep ?? [];
const sunday = getPreviousSunday(card.createdAt);
await prisma.responseCard.update({
where: { id: card.id },
data: {
@ -122,7 +126,7 @@ export async function processFile(
dateOfBirth: asString(responseData.dateOfBirth),
maritalStatus: asString(responseData.maritalStatus),
maritalStatusOther: asString(responseData.maritalStatusOther),
visitType: asString(responseData.visitType),
visitType,
cellPhone: asString(responseData.cellPhone),
homePhone: asString(responseData.homePhone),
email: asString(responseData.email),
@ -136,13 +140,15 @@ export async function processFile(
prayerConfidential: asBool(responseData.prayerConfidential),
messageTopics: surveyData.messageTopics ?? [],
messageTopicsOther: asString(surveyData.messageTopicsOther),
nextStep: surveyData.nextStep ?? [],
nextStep: nextStepArr,
attendanceDuration: asString(surveyData.attendanceDuration),
campusPreference: surveyData.campusPreference ?? [],
campusPreferenceOther: asString(surveyData.campusPreferenceOther),
howHeard: surveyData.howHeard ?? [],
howHeardOther: asString(surveyData.howHeardOther),
serviceAttended: asString(surveyData.serviceAttended),
firstTimeGuestDate: isFirstTimeGuest(visitType) ? sunday : null,
salvationDate: hasBaptism(nextStepArr) ? sunday : null,
ocrStatus: "complete",
ocrConfidence: Math.round(avgConfidence),
rawOcrResponse: JSON.parse(JSON.stringify({ response: responseData, survey: surveyData })),
@ -221,6 +227,10 @@ export async function reprocessCard(cardId: string): Promise<void> {
const avgConfidence = confidenceCount > 0 ? totalConfidence / confidenceCount : 0;
const visitType = asString(responseData.visitType);
const nextStepArr = surveyData.nextStep ?? [];
const sunday = getPreviousSunday(card.createdAt);
await prisma.responseCard.update({
where: { id: cardId },
data: {
@ -229,7 +239,7 @@ export async function reprocessCard(cardId: string): Promise<void> {
dateOfBirth: asString(responseData.dateOfBirth),
maritalStatus: asString(responseData.maritalStatus),
maritalStatusOther: asString(responseData.maritalStatusOther),
visitType: asString(responseData.visitType),
visitType,
cellPhone: asString(responseData.cellPhone),
homePhone: asString(responseData.homePhone),
email: asString(responseData.email),
@ -243,13 +253,15 @@ export async function reprocessCard(cardId: string): Promise<void> {
prayerConfidential: asBool(responseData.prayerConfidential),
messageTopics: surveyData.messageTopics ?? [],
messageTopicsOther: asString(surveyData.messageTopicsOther),
nextStep: surveyData.nextStep ?? [],
nextStep: nextStepArr,
attendanceDuration: asString(surveyData.attendanceDuration),
campusPreference: surveyData.campusPreference ?? [],
campusPreferenceOther: asString(surveyData.campusPreferenceOther),
howHeard: surveyData.howHeard ?? [],
howHeardOther: asString(surveyData.howHeardOther),
serviceAttended: asString(surveyData.serviceAttended),
firstTimeGuestDate: isFirstTimeGuest(visitType) ? sunday : null,
salvationDate: hasBaptism(nextStepArr) ? sunday : null,
ocrStatus: "complete",
ocrConfidence: Math.round(avgConfidence),
ocrError: null,
@ -301,3 +313,27 @@ function asString(v: unknown): string | null {
function asBool(v: unknown): boolean {
return v === true;
}
function getPreviousSunday(from: Date): Date {
const d = new Date(from);
const day = d.getDay();
const diff = day === 0 ? 0 : day;
d.setDate(d.getDate() - diff);
d.setHours(0, 0, 0, 0);
return d;
}
function isFirstTimeGuest(visitType: string | null): boolean {
if (!visitType) return false;
const lower = visitType.toLowerCase();
return lower.includes("first") || lower.includes("second") || lower.includes("guest");
}
function hasBaptism(nextStep: unknown): boolean {
if (!Array.isArray(nextStep)) return false;
return nextStep.some((s) => {
if (typeof s !== "string") return false;
const lower = s.toLowerCase();
return lower.includes("baptism") || lower.includes("expressing my faith");
});
}