feat(applications): enhance application stage tracking and improve form handling

This commit is contained in:
Amruth Pillai
2026-09-30 01:27:21 +02:00
parent 36ba314e2a
commit 31c58baed4
26 changed files with 51 additions and 246 deletions
@@ -71,7 +71,7 @@ function AddApplicationForm({ onClose, onAdded }: { onClose: () => void; onAdded
const [role, setRole] = useState("");
const [company, setCompany] = useState("");
const [stage, setStage] = useState<Stage>("applied");
const [parsed, setParsed] = useState<Parsed | null>(null);
const [reading, setReading] = useState<{ text: string; result: Parsed } | null>(null);
const { hasUsableProvider } = useHasUsableAiProvider();
const invalidate = useInvalidateApplications();
const openDialog = useDialogStore((state) => state.openDialog);
@@ -84,9 +84,10 @@ function AddApplicationForm({ onClose, onAdded }: { onClose: () => void; onAdded
const link = isLink(text);
// A link can always be read (for the page's own job data); pasted text needs the AI provider.
const readable = text.length > 8 && (link || hasUsableProvider);
// A reading belongs to the text it was made from: editing the text drops it.
const parsed = readable && reading?.text === text ? reading.result : null;
useEffect(() => {
setParsed(null);
resetRead();
if (!readable) return;
@@ -95,7 +96,7 @@ function AddApplicationForm({ onClose, onAdded }: { onClose: () => void; onAdded
{ input: text },
{
onSuccess: (result) => {
setParsed(result);
setReading({ text, result });
setRole((current) => current || result.role);
setCompany((current) => current || result.company);
},
@@ -32,7 +32,7 @@ import { useClosingValue } from "@/hooks/use-closing-value";
import { useConfirm } from "@/hooks/use-confirm";
import { isImeComposing } from "@/libs/keyboard";
import { orpc } from "@/libs/orpc/client";
import { stageSince } from "../next-step";
import { daysInStage } from "../next-step";
import { getClosedReasonLabel, getNextStage, getStageColor, getStageLabel, PIPELINE } from "../stages";
import { useApplicationActions, useInvalidateApplications } from "../use-application-actions";
import { Activity } from "./detail/activity";
@@ -103,7 +103,7 @@ function Detail({ application, onEditDetails, onDeleted }: DetailProps) {
const next = getNextStage(application.status);
const reached = PIPELINE.indexOf(application.status);
const since = Math.max(0, Math.floor((Date.now() - stageSince(application).getTime()) / 86_400_000));
const since = daysInStage(application);
const closed = application.status === "closed";
const onDelete = async () => {
@@ -25,7 +25,7 @@ export type NextStep =
| { kind: "closed" };
/** When the application entered its current stage (its latest entry for that stage), or when it was added. */
export function stageSince(application: Pick<Application, "status" | "activity" | "appliedAt">): Date {
function stageSince(application: Pick<Application, "status" | "activity" | "appliedAt">): Date {
const entries = application.activity
.filter((entry) => entry.type === "stage" && entry.stage === application.status)
.map((entry) => new Date(entry.at).getTime());
@@ -34,6 +34,10 @@ export function stageSince(application: Pick<Application, "status" | "activity"
const daysBetween = (from: Date, to: Date) => Math.max(0, Math.floor((to.getTime() - from.getTime()) / DAY_MS));
/** Whole days the application has spent in its current stage. */
export const daysInStage = (application: Pick<Application, "status" | "activity" | "appliedAt">, now = new Date()) =>
daysBetween(stageSince(application), now);
export function getNextStep(application: NextStepSource, now = new Date()): NextStep {
if (application.status === "closed") return { kind: "closed" };
@@ -51,7 +55,7 @@ export function getNextStep(application: NextStepSource, now = new Date()): Next
if (application.status === "saved") return { kind: "not-applied" };
const days = daysBetween(stageSince(application), now);
const days = daysInStage(application, now);
if (application.status === "applied" && days >= FOLLOW_UP_AFTER_DAYS) return { kind: "no-reply", days };
return { kind: "waiting", days };
}
@@ -106,6 +106,7 @@ export function Conversation(props: ConversationProps) {
),
[messages, statuses, document],
);
// biome-ignore lint/nursery/useReactCompiler: publishes to the editor store, which the page canvas reads
useEffect(() => setAssistantProposals(proposals), [proposals, setAssistantProposals]);
useEffect(() => () => setAssistantProposals([]), [setAssistantProposals]);
@@ -133,6 +134,7 @@ export function Conversation(props: ConversationProps) {
const undone = toProposals(part, statuses, document).filter(
(proposal) => proposal.status === "accepted" && document.stateOf(proposal) === "pending",
);
// biome-ignore lint/nursery/useReactCompiler: an undo happens in the resume store; the new status is also saved to the thread
if (undone.length > 0) recordUndone(message, part, undone, "pending");
}
}
@@ -37,7 +37,7 @@ const PAGE_WIDTH = { a4: 595.28, letter: 612, "free-form": 595.28 } as const;
// Horizontal room the canvas keeps around the page: 40px each side, 16px on phones.
const CANVAS_GUTTER = { wide: 80, narrow: 32 } as const;
export function useCanvasWidth() {
function useCanvasWidth() {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
@@ -428,6 +428,7 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
name: "semantic-css-compiler",
}),
);
// biome-ignore lint/nursery/useReactCompiler: the worker is created here so it can be destroyed on unmount
setCompiler(client);
return () => client.destroy();
}, []);
+15 -25
View File
@@ -11,7 +11,7 @@ interface UseFormBlockerOptions {
export function useFormBlocker(form: Pick<AnyFormApi, "store">, options?: UseFormBlockerOptions) {
const confirm = useConfirm();
const closeDialog = useDialogStore((state) => state.closeDialog);
const onOpenChange = useDialogStore((state) => state.onOpenChange);
const setOnBeforeClose = useDialogStore((state) => state.setOnBeforeClose);
const isDirty = useStore(form.store, (state) => state.isDirty);
@@ -22,32 +22,22 @@ export function useFormBlocker(form: Pick<AnyFormApi, "store">, options?: UseFor
shouldBlockRef.current = options?.shouldBlock;
}, [options?.shouldBlock]);
const shouldBlock = () => {
if (shouldBlockRef.current) return shouldBlockRef.current();
return isDirty && !isSubmitting;
};
const confirmClose = () => {
if (!shouldBlock()) return true;
return confirm(t`Are you sure you want to close this dialog?`, {
description: t`You have unsaved changes that will be lost.`,
confirmText: t`Leave`,
cancelText: t`Stay`,
});
};
const requestClose = async () => {
const confirmed = await confirmClose();
if (!confirmed) return;
closeDialog();
};
useEffect(() => {
setOnBeforeClose(confirmClose);
setOnBeforeClose(() => {
const shouldBlock = shouldBlockRef.current ? shouldBlockRef.current() : isDirty && !isSubmitting;
if (!shouldBlock) return true;
return confirm(t`Are you sure you want to close this dialog?`, {
description: t`You have unsaved changes that will be lost.`,
confirmText: t`Leave`,
cancelText: t`Stay`,
});
});
return () => setOnBeforeClose(null);
}, [confirmClose, setOnBeforeClose]);
}, [isDirty, isSubmitting, confirm, setOnBeforeClose]);
// Closing through the store asks the handler above first.
const requestClose = () => onOpenChange(false);
return { requestClose };
}
@@ -238,6 +238,7 @@ function MobileBody({ mode, onModeChange }: MobileBodyProps) {
// it in view above its sheet.
const pageVisible = view === "page" || view === "design";
useEffect(() => {
// biome-ignore lint/nursery/useReactCompiler: a shared store the preview renderer reads, reset on unmount
setPreviewPaused(!pageVisible);
return () => setPreviewPaused(false);
}, [pageVisible, setPreviewPaused]);
@@ -176,6 +176,7 @@ function MobileBody({ mode, onModeChange }: MobileBodyProps) {
const labels: Record<MobileView, string> = { write: t`Write`, page: t`Page`, design: t`Design` };
useEffect(() => {
// biome-ignore lint/nursery/useReactCompiler: a shared store the preview renderer reads, reset on unmount
setPreviewPaused(view !== "page");
return () => setPreviewPaused(false);
}, [view, setPreviewPaused]);
@@ -86,12 +86,14 @@ function RouteComponent() {
useEffect(() => {
if (!create) return;
// biome-ignore lint/nursery/useReactCompiler: takes the one-shot ?create flag from the address, then clears it
setAdding(true);
void navigate({ replace: true, resetScroll: false, search: (prev: Search) => ({ ...prev, create: false }) });
}, [create, navigate]);
useEffect(() => {
if (!applicationId || !applications) return;
// biome-ignore lint/nursery/useReactCompiler: takes the one-shot ?applicationId from the address, then clears it
setSelectedId(applicationId);
void navigate({
replace: true,
+1
View File
@@ -57,6 +57,7 @@
"useAwait": "error"
},
"nursery": {
"useReactCompiler": "error",
"useSortedClasses": {
"level": "warn",
"fix": "safe",
@@ -251,19 +251,5 @@ const useAzurillTemplate = (): AzurillTemplate => {
icon: createIconSlot({ metadata, accentFor }),
} satisfies AzurillStyles,
};
}, [
metadata,
r.row,
primary,
metrics.page.paddingVertical,
metrics.page.paddingHorizontal,
metrics.gapX,
metrics.headerGap,
metrics.columnGap,
base,
metrics.gapY,
foreground,
background,
r.columns,
]);
}, [metadata, r, primary, metrics, base, foreground, background]);
};
@@ -178,19 +178,5 @@ const useBronzorTemplate = (): BronzorTemplate => {
});
return { colors, styles: baseStyles satisfies BronzorStyles };
}, [
metadata,
r.sectionHeadingTextAlign,
r.row,
primary,
metrics.page.paddingVertical,
metrics.page.paddingHorizontal,
metrics.headerGap,
metrics.columnGap,
metrics.gapY,
foreground,
base,
metrics.gapX,
background,
]);
}, [metadata, r, primary, metrics, foreground, base, background]);
};
@@ -284,5 +284,5 @@ const useChikoritaTemplate = (): ChikoritaTemplate => {
icon: createIconSlot({ metadata, accentFor }),
} satisfies ChikoritaStyles,
};
}, [metadata, r.row, r.headerIdentity, metrics.gapX, primary, foreground, base, metrics.gapY, background, r.columns]);
}, [metadata, r, metrics, primary, foreground, base, background]);
};
@@ -344,18 +344,5 @@ const useDitgarTemplate = (): DitgarTemplate => {
}),
} satisfies DitgarStyles,
};
}, [
metadata,
r.row,
r.headerIdentity,
primary,
metrics.gapY,
metrics.page.paddingVertical,
metrics.gapX,
base,
metrics.page.paddingHorizontal,
foreground,
background,
r.columns,
]);
}, [metadata, r, primary, metrics, base, foreground, background]);
};
+1 -15
View File
@@ -295,19 +295,5 @@ const useDittoTemplate = (): DittoTemplate => {
},
} satisfies DittoStyles,
};
}, [
picture,
metadata,
r.row,
r.headerIdentity,
primary,
metrics.page.paddingVertical,
metrics.gapX,
metrics.page.paddingHorizontal,
foreground,
base,
metrics.gapY,
background,
r.columns,
]);
}, [picture, metadata, r, primary, metrics, foreground, base, background]);
};
@@ -320,19 +320,5 @@ const useGengarTemplate = (): GengarTemplate => {
}),
} satisfies GengarStyles,
};
}, [
metadata,
r.row,
r.headerIdentity,
primary,
metrics.sectionGap,
metrics.gapY,
metrics.page.paddingVertical,
metrics.gapX,
base,
metrics.page.paddingHorizontal,
foreground,
background,
r.columns,
]);
}, [metadata, r, primary, metrics, base, foreground, background]);
};
@@ -244,19 +244,5 @@ const useGlalieTemplate = (): GlalieTemplate => {
icon: createIconSlot({ metadata, accentFor }),
} satisfies GlalieStyles,
};
}, [
metadata,
r.row,
primary,
metrics.sectionGap,
metrics.gapY,
metrics.page.paddingVertical,
metrics.gapX,
base,
metrics.page.paddingHorizontal,
foreground,
background,
r.columns,
r.anchorToColumnStart,
]);
}, [metadata, r, primary, metrics, base, foreground, background]);
};
@@ -215,17 +215,5 @@ const useKakunaTemplate = (): KakunaTemplate => {
icon: createIconSlot({ metadata, accentFor }),
} satisfies KakunaStyles,
};
}, [
metadata,
r.row,
primary,
metrics.sectionGap,
metrics.gapY,
metrics.page.paddingVertical,
metrics.gapX,
base,
metrics.page.paddingHorizontal,
foreground,
background,
]);
}, [metadata, r, primary, metrics, base, foreground, background]);
};
@@ -185,19 +185,5 @@ const useLaprasTemplate = (): LaprasTemplate => {
icon: createIconSlot({ metadata, accentFor }),
} satisfies LaprasStyles,
};
}, [
picture,
metadata,
r.row,
r.headerIdentity,
r.anchorToStart,
primary,
metrics.gapY,
metrics.page.paddingVertical,
metrics.gapX,
base,
metrics.page.paddingHorizontal,
foreground,
background,
]);
}, [picture, metadata, r, primary, metrics, base, foreground, background]);
};
@@ -244,19 +244,5 @@ const useLeafishTemplate = (): LeafishTemplate => {
icon: createIconSlot({ metadata, accentFor }),
} satisfies LeafishStyles,
};
}, [
metadata,
r.row,
r.headerIdentity,
primary,
metrics.gapY,
metrics.page.paddingVertical,
metrics.gapX,
base,
metrics.page.paddingHorizontal,
metrics.columnGap,
foreground,
background,
r.columns,
]);
}, [metadata, r, primary, metrics, base, foreground, background]);
};
@@ -229,19 +229,5 @@ const useMeowthTemplate = (): MeowthTemplate => {
icon: createIconSlot({ metadata, accentFor }),
} satisfies MeowthStyles,
};
}, [
metadata,
r.sectionHeadingTextAlign,
r.row,
r.headerIdentity,
primary,
metrics.sectionGap,
metrics.gapY,
metrics.page.paddingVertical,
metrics.gapX,
base,
metrics.page.paddingHorizontal,
foreground,
background,
]);
}, [metadata, r, primary, metrics, base, foreground, background]);
};
+1 -14
View File
@@ -164,18 +164,5 @@ const useOnyxTemplate = (): OnyxTemplate => {
icon: createIconSlot({ metadata, accentFor }),
} satisfies OnyxStyles,
};
}, [
metadata,
r.row,
r.headerIdentity,
primary,
metrics.sectionGap,
metrics.gapY,
metrics.page.paddingVertical,
metrics.gapX,
base,
metrics.page.paddingHorizontal,
foreground,
background,
]);
}, [metadata, r, primary, metrics, base, foreground, background]);
};
@@ -288,20 +288,5 @@ const usePikachuTemplate = (): PikachuTemplate => {
icon: createIconSlot({ metadata, accentFor }),
} satisfies PikachuStyles,
};
}, [
picture,
metadata,
r.row,
r.headerIdentity,
primary,
metrics.page.paddingVertical,
metrics.page.paddingHorizontal,
metrics.columnGap,
metrics.gapY,
foreground,
base,
metrics.gapX,
background,
r.columns,
]);
}, [picture, metadata, r, primary, metrics, foreground, base, background]);
};
@@ -313,21 +313,5 @@ const useRhyhornTemplate = (): RhyhornTemplate => {
icon: createIconSlot({ metadata, accentFor }),
} satisfies RhyhornStyles,
};
}, [
metadata,
r.sectionHeadingTextAlign,
r.row,
r.headerIdentity,
r.contactSeparatorClear,
r.contactSeparator,
primary,
metrics.sectionGap,
metrics.gapY,
metrics.page.paddingVertical,
metrics.gapX,
base,
metrics.page.paddingHorizontal,
foreground,
background,
]);
}, [metadata, r, primary, metrics, base, foreground, background]);
};
@@ -214,22 +214,5 @@ const useScizorTemplate = (): ScizorTemplate => {
icon: createIconSlot({ metadata, accentFor }),
} satisfies ScizorStyles,
};
}, [
metadata,
r.row,
r.headerIdentity,
primary,
metrics.sectionGap,
metrics.page.paddingVertical,
metrics.page.paddingHorizontal,
metrics.itemGapY,
foreground,
metrics.gapY,
base.page,
background,
base.heading,
metrics.gapX,
base.bold,
base,
]);
}, [metadata, r, primary, metrics, foreground, background, base]);
};