fix error line

This commit is contained in:
2026-08-17 17:33:56 +05:30
parent 69b38abced
commit 7fa21a4513
105 changed files with 1296 additions and 323 deletions

View File

@@ -11,8 +11,9 @@ import { base44 } from '@/api/base44Client';
* consumer stay unchanged.
*/
const AuthContext = createContext();
const AuthContext = createContext(/** @type {any} */ (null));
/** @param {any} props */
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [isAuthenticated, setIsAuthenticated] = useState(false);

View File

@@ -31,7 +31,7 @@ export function buildHires({ staff = [], applications = [], postings = [] }) {
const app = applications.find((a) => a.id === s.application_id);
const posting = postings.find((p) => p.id === s.job_posting_id);
const days = app
? Math.max(1, Math.round((new Date(app.updated_date) - new Date(app.created_date)) / 86400000))
? Math.max(1, Math.round((new Date(app.updated_date).getTime() - new Date(app.created_date).getTime()) / 86400000))
: null;
return {

View File

@@ -23,7 +23,7 @@ export function useCurrentUser() {
export function useUpdateProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (patch) => base44.auth.updateMe(patch),
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updateMe(patch),
onSuccess: (user) => {
queryClient.setQueryData(['user'], user);
queryClient.invalidateQueries({ queryKey: ['user'] });
@@ -46,7 +46,7 @@ export function usePreferences() {
export function useUpdatePreferences() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (patch) => base44.auth.updatePreferences(patch),
mutationFn: /** @param {any} patch */ (patch) => base44.auth.updatePreferences(patch),
onSuccess: (user) => {
queryClient.setQueryData(['user'], user);
queryClient.invalidateQueries({ queryKey: ['user'] });
@@ -96,7 +96,7 @@ export function useStaff() {
export function useUpdateStaff() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }) => base44.entities.Staff.update(id, data),
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Staff.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['staff'] });
},
@@ -120,7 +120,7 @@ export function useCertifications() {
export function useCreateCertification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => base44.entities.Certification.create(data),
mutationFn: /** @param {any} data */ (data) => base44.entities.Certification.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certifications'] });
},
@@ -130,7 +130,7 @@ export function useCreateCertification() {
export function useDeleteCertification() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id) => base44.entities.Certification.delete(id),
mutationFn: /** @param {any} id */ (id) => base44.entities.Certification.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certifications'] });
},
@@ -140,7 +140,7 @@ export function useDeleteCertification() {
export function useCreateRoleCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => base44.entities.RoleCategory.create(data),
mutationFn: /** @param {any} data */ (data) => base44.entities.RoleCategory.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roleCategories'] });
},
@@ -157,7 +157,7 @@ export function useUserActivity() {
export function useCreateJobPosting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => base44.entities.JobPosting.create(data),
mutationFn: /** @param {any} data */ (data) => base44.entities.JobPosting.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
logActivity('create_position');
@@ -168,7 +168,7 @@ export function useCreateJobPosting() {
export function useUpdateJobPosting() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }) => base44.entities.JobPosting.update(id, data),
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobPosting.update(id, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
queryClient.invalidateQueries({ queryKey: ['jobPosting', variables.id] });
@@ -179,7 +179,7 @@ export function useUpdateJobPosting() {
export function useCreateApplication() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => base44.entities.JobApplication.create(data),
mutationFn: /** @param {any} data */ (data) => base44.entities.JobApplication.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
logActivity('apply_job');
@@ -190,7 +190,7 @@ export function useCreateApplication() {
export function useUpdateApplication() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }) => base44.entities.JobApplication.update(id, data),
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobApplication.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
},
@@ -200,7 +200,7 @@ export function useUpdateApplication() {
export function useScreenCandidate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ application, job }) => {
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
const result = await screenCandidate(application, job);
return base44.entities.JobApplication.update(application.id, {
status: 'ai_screened',
@@ -223,7 +223,7 @@ export function useScreenCandidate() {
export function useScreenAllCandidates() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ applications, job }) => {
mutationFn: /** @param {any} vars */ async ({ applications, job }) => {
const results = [];
for (const app of applications) {
if (app.status === 'applied' || app.status === 'ai_screened') {
@@ -253,7 +253,7 @@ export function useScreenAllCandidates() {
export function useGenerateJobDescription() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ data, draftId }) => {
mutationFn: /** @param {any} vars */ async ({ data, draftId }) => {
const result = await generateJobDescription(data);
if (draftId) {
await base44.entities.JobPosting.update(draftId, {
@@ -274,7 +274,7 @@ export function useGenerateJobDescription() {
export function useHireCandidate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ application, job }) => {
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
const tier = application.ai_score >= 80 ? 'Skilled' : application.ai_score >= 60 ? 'Cross-Trained' : 'Beginner';
const updated = await base44.entities.JobApplication.update(application.id, { status: 'hired' });
await base44.entities.Staff.create({
@@ -301,14 +301,14 @@ export function useHireCandidate() {
export function useMatchTalentForJob() {
return useMutation({
mutationFn: async ({ job, profiles }) => matchTalentForJob(job, profiles),
mutationFn: /** @param {any} vars */ async ({ job, profiles }) => matchTalentForJob(job, profiles),
});
}
export function useCreateInterview() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => base44.entities.AIInterview.create(data),
mutationFn: /** @param {any} data */ (data) => base44.entities.AIInterview.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['interviews'] });
queryClient.invalidateQueries({ queryKey: ['applications'] });
@@ -339,7 +339,7 @@ export function useCourse(id) {
export function useCreateCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data) => base44.entities.Course.create(data),
mutationFn: /** @param {any} data */ (data) => base44.entities.Course.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['courses'] });
logActivity('create_skill_training');
@@ -351,7 +351,7 @@ export function useCreateCourse() {
export function useUpdateCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }) => base44.entities.Course.update(id, data),
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.Course.update(id, data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['courses'] });
queryClient.invalidateQueries({ queryKey: ['course', variables.id] });
@@ -385,7 +385,7 @@ export function useAssignments() {
export function useAssignWorkers() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ position, workers = [], applications = [] }) => {
mutationFn: /** @param {any} vars */ async ({ position, workers = [], applications = [] }) => {
const startsAt = position.start_date
? new Date(position.start_date).toISOString()
: new Date().toISOString();
@@ -484,7 +484,7 @@ export function useAssignWorkers() {
export function useMarkInterviewReady() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ application, position }) => {
mutationFn: /** @param {any} vars */ async ({ application, position }) => {
if (!application?.id) throw new Error('No application to schedule against');
const updated = await base44.entities.JobApplication.update(application.id, {
@@ -564,7 +564,7 @@ export function useWorkerProfiles() {
export function useUpdateWorkerProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }) => base44.entities.WorkerProfile.update(id, data),
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.WorkerProfile.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['workerProfile'] });
queryClient.invalidateQueries({ queryKey: ['workerProfiles'] });
@@ -575,7 +575,7 @@ export function useUpdateWorkerProfile() {
export function useCompleteCourse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ profile, course, quizScore }) => {
mutationFn: /** @param {any} vars */ async ({ profile, course, quizScore }) => {
const already = (profile.completed_courses || []).some(c => c.course_id === course.id);
const completed_courses = already ? profile.completed_courses : [
...profile.completed_courses,
@@ -616,7 +616,7 @@ export function useEvidenceList(workerEmail) {
export function useSubmitChallenge() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ profile, course, type, mediaUrl, transcript, identified }) => {
mutationFn: /** @param {any} vars */ async ({ profile, course, type, mediaUrl, transcript, identified }) => {
const result = await evaluateChallenge(course, { type, mediaUrl, transcript, identified, workerName: profile?.full_name });
const verdict = result.verdict || 'needs_work';
const score = Math.round(Number(result.score) || 0);
@@ -674,7 +674,7 @@ export function useSubmitChallenge() {
export function useVerifyEvidence() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ evidenceId }) =>
mutationFn: /** @param {any} vars */ async ({ evidenceId }) =>
base44.entities.Evidence.update(evidenceId, {
supervisor_verified: true,
supervisor_name: 'Supervisor',

View File

@@ -397,7 +397,7 @@ const RESOLVERS = {
/** Everyone hired, most recent first — the record rather than the analysis. */
'hires.recent': ({ staff = [], applications = [], positions = [] }, section) => {
const items = [...staff]
.sort((a, b) => new Date(b.hire_date || b.created_date || 0) - new Date(a.hire_date || a.created_date || 0))
.sort((a, b) => new Date(b.hire_date || b.created_date || 0).getTime() - new Date(a.hire_date || a.created_date || 0).getTime())
.slice(0, section.limit || 10)
.map((s) => {
const app = applications.find((a) => a.id === s.application_id);
@@ -432,7 +432,7 @@ const RESOLVERS = {
const hired = applications.filter((a) => a.status === 'hired');
const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
const days = hired
.map((a) => Math.round((new Date(a.updated_date) - new Date(a.created_date)) / 86400000))
.map((a) => Math.round((new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / 86400000))
.filter((n) => Number.isFinite(n) && n >= 0);
const mean = (xs) => (xs.length ? Math.round(xs.reduce((a, b) => a + b, 0) / xs.length) : 0);
@@ -466,7 +466,7 @@ const RESOLVERS = {
/** The workspace audit trail, most recent first. */
'activity.events': ({ activity = [] }, section) => {
const items = [...activity]
.sort((a, b) => new Date(b.created_date || 0) - new Date(a.created_date || 0))
.sort((a, b) => new Date(b.created_date || 0).getTime() - new Date(a.created_date || 0).getTime())
.slice(0, section.limit || 10)
.map((event) => ({
id: event.id,

View File

@@ -41,14 +41,68 @@ function parseFrontmatter(raw) {
};
}
/** Bullets under a `## Heading`, for the capability list shown in Settings. */
/**
* The text under a `## Heading`, up to the next one.
*
* The heading is escaped before it becomes a pattern. It used to be
* interpolated raw, so a heading containing regular-expression punctuation —
* `## Capabilities (v2)` is the obvious one — compiled to a pattern that could
* not match the words it was built from, and the whole section silently read as
* absent. A section that is present and unreadable is the failure this parser
* must never have: it looks exactly like a section the author did not write.
*/
function sectionSource(body, heading) {
const escaped = String(heading).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const section = new RegExp(`##\\s+${escaped}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
return section ? section[1] : null;
}
/**
* List items under a `## Heading`, for the capability list shown in Settings.
*
* Two kinds of content were being dropped without a word, and both are the same
* mistake — treating "a line I do not recognise" as "a line that is not there":
*
* - **Wrapped items.** A bullet long enough to run onto a second line kept
* only its first line. `create-position.md` documented reading a request
* "out of a single sentence" and the registry held the sentence without its
* last three words. An item is now everything up to the next item or the
* blank line that ends the list.
* - **Numbered items.** Only `-` counted, so a `1.` list — the natural way to
* write ordered instructions — parsed as an empty section. Both markers are
* list items in Markdown and both are read as one here.
*
* Neither change alters any definition currently on disk: every one of them
* uses single-line `-` bullets, so this widens what can be written without
* moving what already was.
*/
const LIST_ITEM = /^\s*(?:-|\*|\d+[.)])\s+(.*)$/;
function sectionBullets(body, heading) {
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
if (!section) return [];
return section[1]
.split(/\r?\n/)
.map((l) => /^\s*-\s+(.*)$/.exec(l)?.[1]?.trim())
.filter(Boolean);
const source = sectionSource(body, heading);
if (source == null) return [];
const items = [];
for (const line of source.split(/\r?\n/)) {
const item = LIST_ITEM.exec(line);
if (item) {
items.push(item[1].trim());
continue;
}
/* A blank line closes the current item; anything else indented under one is
its continuation and belongs to it. Prose before the first item — the
explanatory paragraph `## Conversation` opens with — matches neither and
is ignored, exactly as before. */
if (!line.trim()) {
if (items.length) items.push('');
continue;
}
if (items.length && items[items.length - 1] !== '' && /^\s+/.test(line)) {
items[items.length - 1] += ` ${line.trim()}`;
}
}
return items.map((i) => i.trim()).filter(Boolean);
}
/**
@@ -83,11 +137,11 @@ function sectionSteps(body, heading) {
* rather than a list.
*/
function sectionText(body, heading) {
const section = new RegExp(`##\\s+${heading}\\s*\\n([\\s\\S]*?)(?=\\n##\\s|$)`, 'i').exec(body);
if (!section) return '';
return section[1]
const source = sectionSource(body, heading);
if (source == null) return '';
return source
.split(/\r?\n/)
.map((l) => l.replace(/^\s*[-*]\s+/, '').trim())
.map((l) => l.replace(/^\s*(?:[-*]|\d+[.)])\s+/, '').trim())
.filter(Boolean)
.join(' ')
.trim();
@@ -323,6 +377,139 @@ export const SKILLS = Object.entries(FILES)
.map(([path, raw]) => parseSkill(raw, { path }))
.sort((a, b) => a.name.localeCompare(b.name));
/**
* The registry as actually assembled, with everything that went wrong assembling
* it.
*
* This function exists because the three ways a definition could fail to arrive
* intact were all *silent*, and between them they are the whole of the
* "sometimes a skill only half works" report:
*
* 1. **A stored definition that will not parse was dropped.** The `catch`
* here returned `null` and the entry vanished. Nothing anywhere said a
* skill had been discarded, so the skill simply stopped answering and the
* registry looked healthy.
* 2. **A definition that parses can still register incomplete.**
* `normalizeSkillOwliver` keeps the capabilities that resolve and reports
* the rest as errors — correct, and the reason a single bad response does
* not cost an author their whole file. But only `validateSkillSource`
* reads those errors, and that runs in the authoring dialog. A definition
* arriving from storage was registered with its broken capability quietly
* missing, which is exactly "some checks work and others do not".
* 3. **A custom definition silently replaces a built-in of the same id.**
* Intended — it is how a shipped skill is overridden — but indistinguishable
* from the built-in having broken, because nothing reported the override.
*
* Why deployment exposed all three: custom definitions live in per-account
* `preferences`, not in the repository. The bundled `.md` files are identical
* everywhere — verified — so a definition that behaves differently in a
* deployed workspace differs because of what that *account* has stored, and
* because stored Markdown is validated when it is written and never again.
* Ship a change to the supported vocabulary and yesterday's valid definition
* silently loses a capability on next load.
*
* Nothing is dropped that used to register, and nothing new registers. The only
* change is that the failures now have somewhere to be read from.
*/
export function readSkillRegistry(customSources = []) {
const diagnostics = [];
const custom = [];
customSources.forEach((entry, i) => {
const path = entry?.path || `custom/${i}.md`;
let skill = null;
try {
skill = parseSkill(entry?.raw ?? entry, { path, custom: true });
} catch (error) {
diagnostics.push({
level: 'error',
kind: 'unreadable',
path,
skillId: null,
message: `A stored skill could not be read and is not registered. ${error?.message || ''}`.trim(),
});
return;
}
if (!skill?.id) {
diagnostics.push({
level: 'error',
kind: 'unreadable',
path,
skillId: null,
message: 'A stored skill has no `id` and is not registered.',
});
return;
}
custom.push(skill);
});
const byId = new Map(SKILLS.map((s) => [s.id, s]));
for (const skill of custom) {
if (byId.has(skill.id) && !byId.get(skill.id).custom) {
diagnostics.push({
level: 'warning',
kind: 'shadowed',
path: skill.path,
skillId: skill.id,
message: `\`${skill.id}\` replaces the built-in skill of the same id. The built-in definition is not registered.`,
});
}
byId.set(skill.id, skill);
}
const skills = [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
/* A registered definition that lost part of itself on the way in. Reported
for every skill, not only custom ones, so a file on disk that stops
resolving after a vocabulary change is just as visible. */
for (const skill of skills) {
for (const message of [...(skill.owliverErrors || []), ...(skill.uiErrors || [])]) {
diagnostics.push({
level: 'error',
kind: 'incomplete',
path: skill.path,
skillId: skill.id,
message,
});
}
}
/**
* Two definitions claiming one phrase on one page.
*
* Only the first is ever consulted, and "first" means alphabetically by name —
* so the losing definition answers nothing, with no way to tell that from it
* being switched off. Reported per page, because a shared trigger on two
* different pages is not a contest.
*/
const claims = new Map();
for (const skill of skills) {
if (skill.status !== 'active' || !skill.declaredTriggers) continue;
for (const page of skill.pages) {
const key = canonicalPage(page) || page;
for (const trigger of new Set(skill.triggers)) {
const at = `${key}::${trigger}`;
if (!claims.has(at)) claims.set(at, []);
claims.get(at).push(skill);
}
}
}
for (const [at, claimants] of claims) {
if (claimants.length < 2) continue;
const [page, trigger] = at.split('::');
diagnostics.push({
level: 'warning',
kind: 'trigger-collision',
path: claimants[0].path,
skillId: claimants[0].id,
message: `On ${page}, "${trigger}" is claimed by ${claimants.map((s) => s.id).join(' and ')}. `
+ `Only ${claimants[0].id} is consulted.`,
});
}
return { skills, diagnostics };
}
/**
* The built-in skills plus any the account has added.
*
@@ -330,21 +517,17 @@ export const SKILLS = Object.entries(FILES)
* is the same artefact a file on disk would be — nothing is half-parsed into a
* bespoke record. A custom skill sharing an id with a built-in replaces it,
* which is how one would be overridden without editing the repository.
*
* Unchanged in what it returns; `readSkillRegistry` is where the same work is
* done with its failures kept.
*/
export function allSkills(customSources = []) {
const custom = customSources
.map((entry, i) => {
try {
return parseSkill(entry.raw ?? entry, { path: entry.path || `custom/${i}.md`, custom: true });
} catch {
return null;
}
})
.filter((s) => s && s.id);
return readSkillRegistry(customSources).skills;
}
const byId = new Map(SKILLS.map((s) => [s.id, s]));
custom.forEach((s) => byId.set(s.id, s));
return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name));
/** Everything that went wrong assembling the registry, for the Skills page. */
export function skillDiagnostics(customSources = []) {
return readSkillRegistry(customSources).diagnostics;
}
/**
@@ -472,11 +655,27 @@ function triggerMatches(trigger, question) {
*/
const addressable = (skill) => skill.declaredTriggers || skill.facets?.includes('owliver');
export function matchSkill(question, contextId, disabled = [], customSources = []) {
/**
* Every skill on this page whose triggers match — in registry order.
*
* `matchSkill` answers with the first and is what routing uses, because one
* question gets one answer. The full list is what makes the choice *auditable*:
* when two definitions claim the same phrase, the loser was previously
* invisible, and the winner was decided by `allSkills`'s alphabetical sort —
* a definition renamed from "Hiring…" to "Activity…" could take over a phrase
* without either file's triggers changing. Nothing here changes which skill
* answers; it makes the fact that there was a contest something a caller can
* see and report.
*/
export function matchSkills(question, contextId, disabled = [], customSources = []) {
const q = String(question).toLowerCase();
return skillsForContext(contextId, disabled, customSources).find(
return skillsForContext(contextId, disabled, customSources).filter(
(s) => addressable(s) && s.triggers.length > 0 && s.triggers.some((t) => triggerMatches(t, q))
) ?? null;
);
}
export function matchSkill(question, contextId, disabled = [], customSources = []) {
return matchSkills(question, contextId, disabled, customSources)[0] ?? null;
}
/**
@@ -489,3 +688,28 @@ export function matchSkill(question, contextId, disabled = [], customSources = [
*/
export const skillsWithFacet = (skills = [], facet) =>
skills.filter((s) => s.facets?.includes(facet));
/**
* AI agent skills — the ones Workspace & Skills governs.
*
* Two different things live in this registry, and they belong to two different
* products:
*
* - **AI agent skills** (`kind: 'assistant'`) are capabilities Owliver gains.
* An Owliver skill teaches it what it can be asked; a Board skill draws a
* section on a page. Both are governed in Workspace & Skills.
* - **Workforce training** (`kind: 'workforce'`) is what a *person* learns and
* proves — Bartending, Food Safety, Customer Service. That belongs to KROW
* Forge, and is read through `workforceSkillStates`.
*
* This exists so no surface has to reach for `allSkills()` and hope. A page that
* calls `allSkills()` gets both kinds, which is how five training paths ended up
* counted as Owliver capabilities on the Workspace landing page — the registry
* was right and the reading was not. Asking for agent skills by name means a
* training path added tomorrow cannot appear there by default.
*/
export const aiAgentSkills = (skills = []) => skills.filter((s) => s.kind === 'assistant');
/** Workforce training definitions — KROW Forge's half of the same registry. */
export const workforceTrainingSkills = (skills = []) =>
skills.filter((s) => s.kind === 'workforce');

View File

@@ -47,7 +47,7 @@ export function endsAt(position, today = new Date()) {
/** Days from now until the work starts. Negative or zero means it is live. */
export const daysUntilStart = (position, today = new Date()) =>
Math.round((startsAt(position, today) - today) / DAY_MS);
Math.round((startsAt(position, today).getTime() - today.getTime()) / DAY_MS);
/** "Immediately", "in 6 days", "in 3 weeks" — the phrasing answers use. */
export function startLabel(position, today = new Date()) {
@@ -160,7 +160,7 @@ export function durationFitOf(profile, position, { today = new Date() } = {}) {
partial fit for it, and anyone without is a full one. */
const free = profile?.available_until ? new Date(profile.available_until) : null;
if (!free) return { covers: true, ratio: 1, note: null };
const months = Math.max(0, (free - startsAt(position, today)) / MONTH_MS);
const months = Math.max(0, (free.getTime() - startsAt(position, today).getTime()) / MONTH_MS);
return {
covers: false,
ratio: Math.min(1, months / 12),
@@ -171,8 +171,8 @@ export function durationFitOf(profile, position, { today = new Date() } = {}) {
const free = profile?.available_until ? new Date(profile.available_until) : null;
if (!free || free >= end) return { covers: true, ratio: 1, note: null };
const needed = end - startsAt(position, today);
const have = Math.max(0, free - startsAt(position, today));
const needed = end.getTime() - startsAt(position, today).getTime();
const have = Math.max(0, free.getTime() - startsAt(position, today).getTime());
const ratio = needed ? Math.max(0, Math.min(1, have / needed)) : 1;
return {
@@ -545,7 +545,9 @@ export function prioritise(positions = [], context = {}) {
* function existing separately from the mutation is what makes "show me first"
* the only possible path rather than a convention someone has to remember.
*/
export function prepareAssignment(position, { count, ...context } = {}) {
/** @param {any} position @param {any} [options] */
export function prepareAssignment(position, options = {}) {
const { count, ...context } = options || {};
const status = workforceStatusFor(position, context);
const take = Math.min(
count ?? status.demand.remaining,