Job-Scorer / components /ScoringControlForm.tsx
zimejin's picture
Cursor
Remove em dashes from customer-facing copy.
875f022
Raw History Blame Contribute Delete
4.38 kB
"use client";
import {
ALL_DEALBREAKERS,
dealbreakerLabel,
type Dealbreaker,
type ScoringWeights,
} from "@/lib/job-search-preferences";
import { MATCH_SCORE_WEIGHTS } from "@/lib/score-display";
export type ScoringControlValues = {
scoringWeights: ScoringWeights;
dealbreakers: Dealbreaker[];
};
export function scoringControlValuesFromPrefs(opts?: {
scoringWeights?: ScoringWeights;
dealbreakers?: Dealbreaker[];
}): ScoringControlValues {
return {
scoringWeights: {
role_fit: opts?.scoringWeights?.role_fit ?? MATCH_SCORE_WEIGHTS.role_fit * 10,
global_remote: opts?.scoringWeights?.global_remote ?? MATCH_SCORE_WEIGHTS.global_remote * 10,
culture_async: opts?.scoringWeights?.culture_async ?? MATCH_SCORE_WEIGHTS.culture_async * 10,
contractor_friendly:
opts?.scoringWeights?.contractor_friendly ?? MATCH_SCORE_WEIGHTS.contractor_friendly * 10,
},
dealbreakers: opts?.dealbreakers ?? [],
};
}
const WEIGHT_FIELDS: Array<{ key: keyof ScoringWeights; label: string }> = [
{ key: "role_fit", label: "Role & skill fit" },
{ key: "global_remote", label: "Worldwide remote" },
{ key: "culture_async", label: "Async culture" },
{ key: "contractor_friendly", label: "Contractor-friendly" },
];
type Props = {
values: ScoringControlValues;
onChange: (next: ScoringControlValues) => void;
disabled?: boolean;
};
export function ScoringControlForm({ values, onChange, disabled = false }: Props) {
const setWeight = (key: keyof ScoringWeights, raw: string) => {
const n = Number(raw);
onChange({
...values,
scoringWeights: {
...values.scoringWeights,
[key]: Number.isFinite(n) ? Math.max(0, Math.min(10, n)) : 0,
},
});
};
const toggleDealbreaker = (d: Dealbreaker) => {
const has = values.dealbreakers.includes(d);
onChange({
...values,
dealbreakers: has ?
values.dealbreakers.filter((x) => x !== d)
: [...values.dealbreakers, d],
});
};
return (
<>
<div className="app-shell__section" style={{ marginBottom: 12 }}>
<span className="app-shell__section-label">What should weigh most in the match %</span>
<p className="app-shell__field-hint" style={{ margin: "4px 0 8px", fontSize: 12 }}>
Defaults favour role fit and worldwide remote. Values are relative. They do not have to
add to 10.
</p>
{WEIGHT_FIELDS.map(({ key, label }) => (
<label
key={key}
style={{
display: "grid",
gridTemplateColumns: "1fr auto",
gap: 8,
alignItems: "center",
marginBottom: 6,
fontSize: 12,
}}
>
<span>
{label}
<input
type="range"
min={0}
max={10}
step={0.5}
disabled={disabled}
value={values.scoringWeights[key] ?? 0}
onChange={(e) => setWeight(key, e.target.value)}
style={{ width: "100%", marginTop: 4 }}
/>
</span>
<span style={{ fontVariantNumeric: "tabular-nums", minWidth: "3ch" }}>
{Number(values.scoringWeights[key] ?? 0).toFixed(1)}
</span>
</label>
))}
</div>
<div className="app-shell__section" style={{ marginBottom: 12 }}>
<span className="app-shell__section-label">Dealbreakers</span>
<p className="app-shell__field-hint" style={{ margin: "4px 0 8px", fontSize: 12 }}>
Hard rules. A listing that breaks any of these is scored Skip, with an explicit callout.
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{ALL_DEALBREAKERS.map((d) => (
<label
key={d}
style={{ display: "flex", gap: 8, alignItems: "flex-start", fontSize: 12 }}
>
<input
type="checkbox"
disabled={disabled}
checked={values.dealbreakers.includes(d)}
onChange={() => toggleDealbreaker(d)}
style={{ marginTop: 2 }}
/>
<span>{dealbreakerLabel(d)}</span>
</label>
))}
</div>
</div>
</>
);
}