chore(ts-migration): establish TypeScript migration checkpoint
Phases 0-3 of the JS/JSX -> TS/TSX migration. No runtime behaviour changes:
every converted file emits byte-identical JavaScript, verified file by file.
Phase 0 - harness hardening, before any rename:
- scripts/ssr-resolve.mjs wraps `ssrLoadModule` so the ~170 literal module
paths in the check scripts resolve .js/.jsx/.ts/.tsx. Without it the first
rename would have silently destroyed the 1642-check suite that guards the
Owliver flow.
- eslint.config.js gains a TypeScript block. Its `files` globs listed only
{js,mjs,cjs,jsx}, so a renamed file would have dropped out of the run while
`eslint .` went on exiting 0 - the quietest failure mode available.
- MIGRATION_BASELINE.md records the measured starting point, including the
pre-existing seed-fixture failure and the already-broken standalone
owliver-baseline.mjs, so neither is later mistaken for migration damage.
Phase 1 - tsconfig.json succeeds jsconfig.json, carrying every option across at
its old value. `types` moves from [] to ["vite/client"], which fixes the eight
import.meta errors; @types/node is deliberately excluded so setTimeout stays a
number in browser code. allowJs and checkJs stay on, strict stays off.
Phase 2 - src/types/{api,entities,user}.ts. Transport envelope, error shape,
the entity-name union (the same 18 names are written down twice today, in
httpClient and base44Client, with nothing checking they agree), and the user
record. Every field transcribed from the API contract, the migrations and the
/me projection in me.go - not inferred. Entity record shapes are deliberately
absent: derivable, but nothing consumes them yet.
Phase 3 - nine leaf utilities renamed to .ts with annotations added only where
they could be established from existing usage. Entity records are typed `any`
with a comment naming what they are, rather than a guessed interface.
Verified: tsc 64 errors (65 before; one pre-existing TS2559 genuinely fixed,
none introduced), lint unchanged at 0 errors, skill-check 1641/1642 with only
the known failure, Owliver baseline section 59/59 green, production build
succeeds with the API origin inlined.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
@@ -8,6 +8,18 @@
|
||||
* worker WANTS rather than what a posting OFFERS.
|
||||
*/
|
||||
|
||||
/**
|
||||
* `draft` is a EmployeeRole record, typed `any` deliberately.
|
||||
*
|
||||
* The shared types in `src/types/` describe the transport and the signed-in
|
||||
* user; they do not describe entity records yet. That shape is derivable — the
|
||||
* backend generates a column registry from `information_schema` — but it
|
||||
* belongs with the phase that converts the transport layer. A partial interface
|
||||
* written here would be a guess that every later reader treats as settled.
|
||||
* `any` states what is actually known today and is one annotation to replace.
|
||||
*/
|
||||
type EmployeeRoleDraft = any;
|
||||
|
||||
/** English levels, in the order the schema declares them. */
|
||||
export const EMPLOYEE_ROLE_STATUSES = ['seeking', 'placed', 'inactive'];
|
||||
|
||||
@@ -35,7 +47,7 @@ export const defaultEmployeeRole = () => ({
|
||||
* A maximum of zero means "no ceiling stated" rather than "free", which is why
|
||||
* it is not rendered as a range ending at nothing.
|
||||
*/
|
||||
export function desiredPayLabel(record = {}) {
|
||||
export function desiredPayLabel(record: EmployeeRoleDraft = {}): string | null {
|
||||
const min = Number(record.desired_pay_min) || 0;
|
||||
const max = Number(record.desired_pay_max) || 0;
|
||||
if (!min && !max) return null;
|
||||
@@ -56,7 +68,7 @@ export function desiredPayLabel(record = {}) {
|
||||
* defaults one, or falls back to another record's; an absent email reaches the
|
||||
* server absent, and the server refuses it.
|
||||
*/
|
||||
export function toNewWorkerWithRolePayload(draft = {}) {
|
||||
export function toNewWorkerWithRolePayload(draft: EmployeeRoleDraft = {}) {
|
||||
const role = toEmployeeRolePayload(draft);
|
||||
/* The identity fields belong to the worker. The server copies them onto the
|
||||
role from the row it just created, so sending them twice would let the two
|
||||
@@ -82,7 +94,7 @@ export function toNewWorkerWithRolePayload(draft = {}) {
|
||||
* deliberately: both are the server's, derived from the session, and a value
|
||||
* sent for either is dropped before the insert.
|
||||
*/
|
||||
export function toEmployeeRolePayload(draft = {}) {
|
||||
export function toEmployeeRolePayload(draft: EmployeeRoleDraft = {}) {
|
||||
const record = { ...defaultEmployeeRole(), ...draft };
|
||||
return {
|
||||
...record,
|
||||
@@ -1,10 +1,47 @@
|
||||
/**
|
||||
* The records this engine reads — WorkerProfile, JobPosting, Course and
|
||||
* LearningPath — are typed `any` deliberately.
|
||||
*
|
||||
* `src/types/` describes the transport and the signed-in user; it does not
|
||||
* describe entity records yet. Those shapes are derivable from the backend's
|
||||
* generated column registry, but they belong with the phase that converts the
|
||||
* transport layer. Written here they would be a guess, and this is the file
|
||||
* where a wrong guess is most expensive: these weights decide what a worker's
|
||||
* score is and which jobs they are shown.
|
||||
*
|
||||
* `computeProfileCompletion` additionally indexes the profile by a field name
|
||||
* held in a list (`profile[f]`), which no partial interface would permit
|
||||
* without either widening it back to `any` or rewriting the loop — and
|
||||
* rewriting it would be a behaviour change in a scoring path.
|
||||
*/
|
||||
type ProfileRecord = any;
|
||||
type JobPostingRecord = any;
|
||||
type CourseRecord = any;
|
||||
type LearningPathRecord = any;
|
||||
|
||||
/** The score, its headline inputs, and the per-dimension breakdown a reader sees. */
|
||||
export interface KrowScoreResult {
|
||||
krow_score: number;
|
||||
reliability: number;
|
||||
breakdown: {
|
||||
/** `null`, not 0, where there is no record — an absent dimension renders "—". */
|
||||
attendance: number | null;
|
||||
performance: number;
|
||||
education: number;
|
||||
clientReviews: number;
|
||||
supervisorReviews: number;
|
||||
growth: number;
|
||||
experience: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Engine 6 — KROW Score Engine (formula-based, auto-recalculated)
|
||||
// Reliability = Attendance×.30 + Performance×.25 + Education×.10 + ClientReviews×.15 + SupervisorReviews×.10 + Growth×.05 + Experience×.05
|
||||
// KROW Score = Reliability×.40 + AI Interview×.25 + Education×.15 + Growth×.10 + Experience×.10
|
||||
|
||||
const clamp = (n) => Math.max(0, Math.min(100, n));
|
||||
const clamp = (n: number) => Math.max(0, Math.min(100, n));
|
||||
|
||||
export function computeKrowScore(profile) {
|
||||
export function computeKrowScore(profile: ProfileRecord): KrowScoreResult {
|
||||
/**
|
||||
* Attendance defaults to 100 for *display*: an unrated worker shows a full bar
|
||||
* rather than an accusatory 0. That default must not become a scoring input.
|
||||
@@ -64,7 +101,7 @@ export function computeKrowScore(profile) {
|
||||
|
||||
const CORE_FIELDS = ['full_name', 'email', 'phone', 'address', 'current_position', 'desired_position', 'career_goals', 'transportation', 'resume_url'];
|
||||
|
||||
export function computeProfileCompletion(profile) {
|
||||
export function computeProfileCompletion(profile: ProfileRecord): number {
|
||||
let filled = 0;
|
||||
for (const f of CORE_FIELDS) if (profile[f]) filled++;
|
||||
if (profile.languages?.length) filled++;
|
||||
@@ -83,7 +120,7 @@ export function computeProfileCompletion(profile) {
|
||||
}
|
||||
|
||||
/** Returns only the recomputed fields — merge into an update patch */
|
||||
export function recalcProfilePatch(profile) {
|
||||
export function recalcProfilePatch(profile: ProfileRecord) {
|
||||
const { krow_score, reliability, breakdown } = computeKrowScore(profile);
|
||||
return {
|
||||
krow_score,
|
||||
@@ -95,7 +132,7 @@ export function recalcProfilePatch(profile) {
|
||||
|
||||
// Engine 11 (light) — recommendation helpers
|
||||
|
||||
export function recommendNextCourse(profile, learningPaths = [], courses = []) {
|
||||
export function recommendNextCourse(profile: ProfileRecord, learningPaths: LearningPathRecord[] = [], courses: CourseRecord[] = []) {
|
||||
const p = profile || {};
|
||||
const completedIds = new Set((p.completed_courses || []).map(c => c.course_id));
|
||||
const desired = (p.desired_position || '').toLowerCase();
|
||||
@@ -116,14 +153,17 @@ export function recommendNextCourse(profile, learningPaths = [], courses = []) {
|
||||
return uncompleted[0] ? { course: uncompleted[0], path: null } : { course: null, path };
|
||||
}
|
||||
|
||||
export function computeJobMatch(profile, job) {
|
||||
export function computeJobMatch(profile: ProfileRecord, job: JobPostingRecord): number {
|
||||
const p = profile || {};
|
||||
let score = 0;
|
||||
const desired = (p.desired_position || '').toLowerCase();
|
||||
if (desired && (job.title?.toLowerCase().includes(desired) || job.role_category?.toLowerCase().includes(desired))) score += 35;
|
||||
|
||||
const profileSkills = new Set((p.skills || []).map(s => s.toLowerCase()));
|
||||
const profileCerts = new Set((p.earned_badges || []).map(b => b.name?.toLowerCase()).filter(Boolean));
|
||||
/* `new Set(x)` where x is `any` infers `Set<unknown>`, and the loops below
|
||||
call `.includes` on the members. Naming the element type is the whole
|
||||
fix; the values put into the set are unchanged. */
|
||||
const profileSkills = new Set<string>((p.skills || []).map(s => s.toLowerCase()));
|
||||
const profileCerts = new Set<string>((p.earned_badges || []).map(b => b.name?.toLowerCase()).filter(Boolean));
|
||||
const reqSkills = (job.qualifications || []).map(q => q.toLowerCase());
|
||||
const reqCerts = (job.certifications_required || []).map(c => c.toLowerCase());
|
||||
|
||||
@@ -144,7 +184,7 @@ export function computeJobMatch(profile, job) {
|
||||
return Math.min(100, Math.round(score));
|
||||
}
|
||||
|
||||
export function recommendJobs(profile, jobPostings = [], limit = 3) {
|
||||
export function recommendJobs(profile: ProfileRecord, jobPostings: JobPostingRecord[] = [], limit = 3) {
|
||||
return jobPostings
|
||||
.filter(j => j.status === 'active')
|
||||
.map(j => ({ job: j, match: computeJobMatch(profile, j) }))
|
||||
@@ -1,5 +1,57 @@
|
||||
import { base44 } from '@/api/base44Client';
|
||||
|
||||
/**
|
||||
* Course, WorkerProfile and the conversation turns are typed `any`
|
||||
* deliberately — `src/types/` does not describe entity records yet, and a
|
||||
* partial interface written here would be a guess later readers treat as
|
||||
* settled. See the same note in `krowScore.ts`.
|
||||
*/
|
||||
type CourseRecord = any;
|
||||
type ProfileRecord = any;
|
||||
|
||||
/** One turn of the roleplay transcript, as the challenge runner stores it. */
|
||||
interface ChallengeTurn {
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body sent to `InvokeLLM`, built up across branches.
|
||||
*
|
||||
* Annotated rather than inferred because the code assigns `file_urls` and
|
||||
* `prompt` onto the object AFTER creating it. TypeScript permits that in a
|
||||
* `.js` file — it treats a JavaScript object literal as expandable — and
|
||||
* refuses it in a `.ts` file, where the literal's type is fixed at creation.
|
||||
* That difference is the only thing this interface exists to absorb: the
|
||||
* assignments, their order and their values are untouched, and the emitted
|
||||
* JavaScript is the same either way.
|
||||
*/
|
||||
interface ChallengeEvalCall {
|
||||
response_json_schema: unknown;
|
||||
model: string;
|
||||
file_urls?: string[];
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
/** What the evaluator is asked to return; the schema below is its contract. */
|
||||
export interface ChallengeEvaluation {
|
||||
verdict: 'verified' | 'needs_work' | 'failed';
|
||||
score: number;
|
||||
rubric: Record<string, number>;
|
||||
feedback: string;
|
||||
strengths: string[];
|
||||
concerns: string[];
|
||||
}
|
||||
|
||||
/** The inputs a challenge submission carries, by challenge type. */
|
||||
export interface ChallengeSubmission {
|
||||
type: string;
|
||||
mediaUrl?: string;
|
||||
transcript?: string;
|
||||
identified?: { label: string; x: number; y: number }[];
|
||||
workerName?: string;
|
||||
}
|
||||
|
||||
const CHALLENGE_EVAL_SCHEMA = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -13,7 +65,7 @@ const CHALLENGE_EVAL_SCHEMA = {
|
||||
};
|
||||
|
||||
/** Real-work unlock gate — shifts, reliability, and badges must be earned first. */
|
||||
export function isUnlocked(course, profile = {}) {
|
||||
export function isUnlocked(course: CourseRecord, profile: ProfileRecord = {}) {
|
||||
const req = course?.unlock_requirements;
|
||||
if (!req) return { unlocked: true, reasons: [] };
|
||||
const reasons = [];
|
||||
@@ -27,7 +79,7 @@ export function isUnlocked(course, profile = {}) {
|
||||
}
|
||||
|
||||
/** One sharp follow-up question during a roleplay challenge. */
|
||||
export async function challengeFollowUp(course, history) {
|
||||
export async function challengeFollowUp(course: CourseRecord, history: ChallengeTurn[]) {
|
||||
const convo = history.map((m) => `${m.role === 'user' ? 'Worker' : 'KROW'}: ${m.content}`).join('\n');
|
||||
const prompt = `You are "KROW", running a short proving-ground challenge for the skill "${course?.proof_skill || course?.title}".
|
||||
Challenge scenario: ${course?.challenge?.prompt || course?.description}
|
||||
@@ -41,7 +93,7 @@ Ask your follow-up now. Respond with only the question.`;
|
||||
|
||||
/** Evaluate a worker's proof — transcript for roleplay, attached media for photo/video,
|
||||
* identified hazards for photo_identify (with the scene image attached). */
|
||||
export async function evaluateChallenge(course, { type, mediaUrl, transcript, identified, workerName }) {
|
||||
export async function evaluateChallenge(course: CourseRecord, { type, mediaUrl, transcript, identified, workerName }: ChallengeSubmission) {
|
||||
const ch = course?.challenge || {};
|
||||
const skill = course?.proof_skill || course?.title;
|
||||
const criteria = (ch.rubric || []).map((r) => r.criterion).join(', ') || 'overall_performance';
|
||||
@@ -50,7 +102,7 @@ export async function evaluateChallenge(course, { type, mediaUrl, transcript, id
|
||||
: 'Score "overall_performance" 0-100 in the rubric object.';
|
||||
|
||||
let mediaPart;
|
||||
const call = { response_json_schema: CHALLENGE_EVAL_SCHEMA, model: 'claude_sonnet_4_6' };
|
||||
const call: ChallengeEvalCall = { response_json_schema: CHALLENGE_EVAL_SCHEMA, model: 'claude_sonnet_4_6' };
|
||||
|
||||
if (type === 'photo_identify') {
|
||||
const list = (identified || []).map((h) => `- "${h.label}" at (${Math.round(h.x * 100)}%, ${Math.round(h.y * 100)}%)`).join('\n') || '(no hazards marked)';
|
||||
@@ -1,15 +1,56 @@
|
||||
// Derives the Talent "credit score" dimensions and motivational metrics
|
||||
// from a WorkerProfile. Keeps the UI honest by mapping to real fields only.
|
||||
const clamp = (n, min = 0, max = 100) => Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||
|
||||
export function getTalentScore(profile = {}) {
|
||||
/**
|
||||
* `profile` is a WorkerProfile record, typed `any` deliberately.
|
||||
*
|
||||
* The shared types do not describe entity records yet — that shape is derivable
|
||||
* from the backend's generated column registry and belongs with the phase that
|
||||
* converts the transport layer, not with this one. Writing a partial
|
||||
* WorkerProfile here would be a guess wearing an interface, and every reader
|
||||
* after it would treat the guess as settled. `any` says what is actually known
|
||||
* today, which is nothing, and it is one annotation to replace when the real
|
||||
* record type exists.
|
||||
*/
|
||||
type WorkerProfileRecord = any;
|
||||
|
||||
/**
|
||||
* One row of the reputation breakdown.
|
||||
*
|
||||
* `big` marks the headline dimension — Talent Score — which renders larger and
|
||||
* is the one `getFactors` filters out. It is set on exactly one entry and
|
||||
* absent from the other six, so it is optional rather than `boolean`: writing
|
||||
* it as required would describe an object this module never builds.
|
||||
*/
|
||||
export interface ReputationDimension {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
big?: boolean;
|
||||
}
|
||||
|
||||
/** A dimension carrying the weight it contributes, as `getFactors` returns it. */
|
||||
export interface ReputationFactor extends ReputationDimension {
|
||||
weight: number;
|
||||
}
|
||||
|
||||
/** The credit-rating tier for a score, with the colour it renders in. */
|
||||
export interface ScoreBand {
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const clamp = (n: unknown, min = 0, max = 100) =>
|
||||
Math.max(min, Math.min(max, Math.round(Number(n) || 0)));
|
||||
|
||||
export function getTalentScore(profile: WorkerProfileRecord = {}) {
|
||||
return clamp(profile.krow_score || 0);
|
||||
}
|
||||
|
||||
// Career Score — FICO-style 300–850 scale, the number shown on the KROW ID.
|
||||
export const toFICO = (s) => 300 + Math.round((clamp(s) / 100) * 550);
|
||||
export const toFICO = (s: number) => 300 + Math.round((clamp(s) / 100) * 550);
|
||||
|
||||
export function getStars(profile = {}) {
|
||||
export function getStars(profile: WorkerProfileRecord = {}) {
|
||||
const s = getTalentScore(profile);
|
||||
if (s >= 90) return 5;
|
||||
if (s >= 75) return 4;
|
||||
@@ -20,18 +61,18 @@ export function getStars(profile = {}) {
|
||||
}
|
||||
|
||||
// Market value uplift ($/hr) derived from Talent Score
|
||||
export function getMarketValue(profile = {}) {
|
||||
export function getMarketValue(profile: WorkerProfileRecord = {}) {
|
||||
const s = getTalentScore(profile);
|
||||
return Math.max(0, Math.round((s - 70) / 5));
|
||||
}
|
||||
|
||||
// Approximate growth this month (motivational, derived from XP)
|
||||
export function getGrowth(profile = {}) {
|
||||
export function getGrowth(profile: WorkerProfileRecord = {}) {
|
||||
const xp = Number(profile.xp) || 0;
|
||||
return clamp(Math.round(xp / 25), 0, 99);
|
||||
}
|
||||
|
||||
export function getReputation(profile = {}) {
|
||||
export function getReputation(profile: WorkerProfileRecord = {}): ReputationDimension[] {
|
||||
const s = profile || {};
|
||||
const courses = s.completed_courses || [];
|
||||
const learning = clamp(courses.length * 8 + (Number(s.xp) || 0) / 10);
|
||||
@@ -47,7 +88,7 @@ export function getReputation(profile = {}) {
|
||||
}
|
||||
|
||||
// Weight each reputation factor like a credit score (FICO-style, sums to 100)
|
||||
export const FACTOR_WEIGHTS = {
|
||||
export const FACTOR_WEIGHTS: Record<string, number> = {
|
||||
reliability: 25,
|
||||
attendance: 20,
|
||||
communication: 15,
|
||||
@@ -57,7 +98,7 @@ export const FACTOR_WEIGHTS = {
|
||||
};
|
||||
|
||||
// Score band — the "credit rating" tier for the talent
|
||||
export function getScoreBand(score = 0) {
|
||||
export function getScoreBand(score = 0): ScoreBand {
|
||||
if (score >= 90) return { label: 'Elite', color: '#0838E0' };
|
||||
if (score >= 75) return { label: 'Excellent', color: '#16A34A' };
|
||||
if (score >= 60) return { label: 'Solid', color: '#2563EB' };
|
||||
@@ -66,7 +107,7 @@ export function getScoreBand(score = 0) {
|
||||
return { label: 'No score yet', color: '#D1D5DB' };
|
||||
}
|
||||
|
||||
export function getFactors(profile = {}) {
|
||||
export function getFactors(profile: WorkerProfileRecord = {}): ReputationFactor[] {
|
||||
return getReputation(profile)
|
||||
.filter((d) => !d.big)
|
||||
.map((d) => ({ ...d, weight: FACTOR_WEIGHTS[d.key] || 0 }));
|
||||
@@ -2,8 +2,27 @@
|
||||
// client endorsements. Used by both the talent card and the talent detail
|
||||
// modal so the two stay in sync.
|
||||
|
||||
/**
|
||||
* `profile` is a WorkerProfile record, typed `any` deliberately.
|
||||
*
|
||||
* The shared types in `src/types/` describe the transport and the signed-in
|
||||
* user; they do not describe entity records yet. That shape is derivable — the
|
||||
* backend generates a column registry from `information_schema` — but it
|
||||
* belongs with the phase that converts the transport layer. A partial interface
|
||||
* written here would be a guess that every later reader treats as settled.
|
||||
* `any` states what is actually known today and is one annotation to replace.
|
||||
*/
|
||||
type WorkerProfileRecord = any;
|
||||
|
||||
/** One derived endorsement row, as the talent card and detail modal render it. */
|
||||
export interface ClientEndorsement {
|
||||
company: string;
|
||||
role: string;
|
||||
stars: number;
|
||||
}
|
||||
|
||||
// ESAT — Employee Skills Assessment Transcript; derived from AI interview score
|
||||
export function esatTag(profile) {
|
||||
export function esatTag(profile: WorkerProfileRecord) {
|
||||
const s = Math.round(profile?.ai_interview_score || 0);
|
||||
if (s > 0) return { label: `${s}/100`, cls: 'bg-[#FFF7ED] text-[#B45309]' };
|
||||
return { label: 'Pending', cls: 'bg-[#F3F4F6] text-[#6B7280]' };
|
||||
@@ -11,13 +30,13 @@ export function esatTag(profile) {
|
||||
|
||||
// Recent job history (last 3 years). experience entries carry a duration
|
||||
// (`years`) rather than dates, so we surface the most recent up to 3.
|
||||
export function recentJobs(profile) {
|
||||
export function recentJobs(profile: WorkerProfileRecord) {
|
||||
return (profile?.experience || []).slice(0, 3).filter((e) => e && (e.role || e.company));
|
||||
}
|
||||
|
||||
// Up to 3 client endorsements, deterministically derived from companies the
|
||||
// worker has been placed at, rated off their aggregate client rating.
|
||||
export function clientEndorsements(profile) {
|
||||
export function clientEndorsements(profile: WorkerProfileRecord): ClientEndorsement[] {
|
||||
const companies = (profile?.experience || [])
|
||||
.map((e) => e?.company)
|
||||
.filter(Boolean);
|
||||
@@ -1,6 +1,10 @@
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import type { User } from '@/types/user';
|
||||
|
||||
let _cachedUser = null;
|
||||
/* The signed-in user, or null when nobody is or the lookup failed. Fetched
|
||||
once per page load: this is best-effort logging, and a tracking call must
|
||||
never become a second round trip on a user action. */
|
||||
let _cachedUser: User | null = null;
|
||||
let _userFetched = false;
|
||||
|
||||
async function getCurrentUser() {
|
||||
@@ -14,8 +18,30 @@ async function getCurrentUser() {
|
||||
return _cachedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional context an event may carry.
|
||||
*
|
||||
* Every field is optional because every call site supplies a different subset —
|
||||
* the shape is "whatever this particular event knows". The id fields are the
|
||||
* records the event is about; they are written onto the record only when
|
||||
* supplied, so an event with no application does not get an
|
||||
* `application_id: null` that later reads as a missing link.
|
||||
*/
|
||||
export interface ActivityExtra {
|
||||
email?: string;
|
||||
name?: string;
|
||||
account_type?: string;
|
||||
details?: string;
|
||||
position_id?: string;
|
||||
application_id?: string;
|
||||
candidate_id?: string;
|
||||
interview_id?: string;
|
||||
worker_email?: string;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
/** Best-effort activity logging — never throws, never blocks the main flow. */
|
||||
export async function logActivity(eventType, extra = {}) {
|
||||
export async function logActivity(eventType: string, extra: ActivityExtra = {}) {
|
||||
try {
|
||||
const me = await getCurrentUser();
|
||||
const record = {
|
||||
@@ -1,4 +1,4 @@
|
||||
import { clsx } from "clsx"
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { extendTailwindMerge } from "tailwind-merge"
|
||||
|
||||
/**
|
||||
@@ -27,7 +27,18 @@ const twMerge = extendTailwindMerge({
|
||||
},
|
||||
})
|
||||
|
||||
export function cn(...inputs) {
|
||||
/**
|
||||
* `ClassValue` is clsx's own type, so this accepts exactly what clsx accepts
|
||||
* and nothing has to be kept in step by hand: strings, numbers, `null`,
|
||||
* `undefined`, `false`, arrays of any of those, and `{ 'class-name': boolean }`
|
||||
* objects, nested to any depth. That breadth is deliberate — the conditional
|
||||
* forms are most of why callers reach for `cn` at all.
|
||||
*
|
||||
* The body is untouched. `clsx(inputs)` is passed the rest array rather than
|
||||
* spread into it, which is not the usual shape but is what this has always
|
||||
* done, and clsx flattens an array argument to the same string either way.
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user