mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
feat(applications): enhance application stage tracking and improve form handling
This commit is contained in:
@@ -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();
|
||||
}, []);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user