Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/backlog/13-dashboards.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ the apps from card 09. Load `objectstack-ui` (dashboards, charts) and `objectsta
- **`ats_hiring_funnel`**: funnel over `ats_application.stage` counts applied→screening→interview→offer→hired
(exclude rejected/withdrawn from the funnel; show them as a separate tile); conversion % between stages.
- **`ats_employer_hiring`** (scoped by RLS to the viewer's employer automatically): open jobs, applications
awaiting action (`stage in ['applied','screening']`), interviews this week, median days from
awaiting action (`stage in ['applied','screening']`), interviews this week, average days from
`applied_at` to offer `created_at` for hired applications.
Every widget binds to a dataset field that exists — `pnpm validate` checks widget bindings.

Expand Down
62 changes: 45 additions & 17 deletions src/dashboards/employer-hiring.dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,38 @@ import type { Dashboard } from '@objectstack/spec/ui';
* filter: the analytics runtime applies the caller's read scope per object,
* and every employer-side policy keys on the stamped `employer_org` (#44,
* #46). Quillstone's administrator and Harborline's read different numbers
* from the same three widgets, and platform staff read the whole marketplace.
* from the same widgets, and platform staff read the whole marketplace.
*
* Three of the card's four tiles are here. The fourth — "median days from
* `applied_at` to the offer's `created_at` for hired applications" — is not
* expressible as a dataset measure and is deliberately NOT approximated:
* - the semantic layer has no median (`count/sum/avg/min/max/count_distinct`),
* and the only computed form combines OTHER MEASURES, never two columns;
* - the duration lives across two objects and needs a stored column
* (`ats_application.days_to_offer`, stamped when the offer is written),
* which is an object + hook change outside this card;
* - the demo seed does now carry one `accepted` offer per hired application
* (#53 — before it, all 14 offers sat on `offer`-stage applications and the
* tile would have read empty), so what keeps the tile out is the two
* reasons above, not the data.
* The pipeline-by-stage bar takes its place so the surface shows the same
* scoping at a glance; the tile returns with a card that adds the column.
* All four of the card's tiles are here. The fourth — "Average Days to
* Offer" — reads `avg(ats_application.days_to_offer)` over this employer's
* HIRED applications, and it took three things that did not exist when the
* dashboard was first built:
* - a STORED column. `days_to_offer` is a duration between `applied_at` on
* the application and `created_at` on the offer; a dataset measure
* aggregates one column of one object and its only computed form combines
* OTHER MEASURES by name, so the duration had to become a column before
* the semantic layer could touch it. It is written once, by the
* `afterInsert` hook on `ats_offer` (`src/hooks/stamp.hook.ts`).
* - `avg`, not median. The aggregate set is
* `count/sum/avg/min/max/count_distinct`; DESIGN.md §04 asks for the
* average (「平均到 Offer 天数」) and that is what this reports.
* - the seed. Nine `accepted` offers, one per hired application (#53/#64) —
* before them the tile would have been an empty average over zero rows.
* `AVG` ignores NULLs, so the denominator is the hired applications that
* actually reached an offer, not every hired row.
*
* ⚠️ On the DEMO SEED this tile's number is not the history it looks like.
* `created_at` is the platform's own stamp, so every seeded offer is created
* at boot, and `days_to_offer` therefore equals the application's AGE for all
* 23 stamped rows — measured against `appliedDaysAgo` in the seed skeleton,
* exact match on all nine hired. Quillstone's 30 and Harborline's 58 are
* "filed 30 / 58 days ago", not "took 30 / 58 days to decide". The metric
* itself is right; it is the seed that has no real elapsed time in it (#65).
* Nothing here can fix that — a seed cannot set `created_at` — so the tile is
* honest about what it computes and this note is honest about what the demo
* feeds it. The stage filter is a
* WIDGET filter (the query's WHERE), never a measure-scoped one: the memory
* driver answers `501 NOT_IMPLEMENTED` to a conditional aggregate.
*
* "This week" is Monday 00:00 (`{current_week_start}`) up to but excluding
* next Monday (`{next_week_start}`): `*_end` macros are calendar days, and
Expand All @@ -32,7 +48,7 @@ import type { Dashboard } from '@objectstack/spec/ui';
export const EmployerHiringDashboard: Dashboard = {
name: 'ats_employer_hiring',
label: 'Hiring Overview',
description: 'Your open jobs, applications awaiting action, interviews this week and the pipeline by stage.',
description: 'Your open jobs, applications awaiting action, interviews this week, average days to offer and the pipeline by stage.',
columns: 12,
gap: 4,
header: { showTitle: true, showDescription: true },
Expand Down Expand Up @@ -71,6 +87,18 @@ export const EmployerHiringDashboard: Dashboard = {
layout: { x: 8, y: 0, w: 4, h: 2 },
options: { icon: 'calendar-clock' },
},
{
id: 'avg_days_to_offer',
type: 'kpi',
title: 'Average Days to Offer',
description: 'Applied to first offer, over your hired applications.',
dataset: 'ats_application_metrics',
values: ['avg_days_to_offer'],
filter: { stage: 'hired' },
colorVariant: 'success',
layout: { x: 0, y: 2, w: 4, h: 2 },
options: { icon: 'timer' },
},
{
id: 'pipeline_by_stage',
type: 'bar',
Expand All @@ -86,7 +114,7 @@ export const EmployerHiringDashboard: Dashboard = {
series: [{ name: 'application_count', label: 'Applications' }],
showLegend: false,
},
layout: { x: 0, y: 2, w: 12, h: 5 },
layout: { x: 0, y: 4, w: 12, h: 5 },
},
],
};
14 changes: 13 additions & 1 deletion src/datasets/application.dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { defineDataset } from '@objectstack/spec/ui';
export const ApplicationMetrics = defineDataset({
name: 'ats_application_metrics',
label: 'Application Metrics',
description: 'Applications by stage, source and week applied. Slice with a widget filter; one count measure.',
description: 'Applications by stage, source and week applied. Slice with a widget filter; a count and the average days to offer.',
object: 'ats_application',
dimensions: [
{ name: 'stage', field: 'stage', type: 'string', label: 'Stage' },
Expand All @@ -36,5 +36,17 @@ export const ApplicationMetrics = defineDataset({
],
measures: [
{ name: 'application_count', aggregate: 'count', label: 'Applications' },
// The duration tile. `avg`, not median: the aggregate set is
// count/sum/avg/min/max/count_distinct and there is no median in it —
// DESIGN.md §04 asks for the average and this is it. It averages a STORED
// column (`ats_application.days_to_offer`, written once by the
// `afterInsert` hook on `ats_offer`) because a measure aggregates one
// column of one object, and the duration it reports spans two.
// `AVG` ignores NULLs, so applications that never reached an offer are
// absent from the denominator rather than counted as zero — which is the
// reading the tile wants. Slice it with the WIDGET's filter, like the
// count above; a measure-scoped `filter` is the shape the memory driver
// answers 501 to (see the header).
{ name: 'avg_days_to_offer', aggregate: 'avg', field: 'days_to_offer', label: 'Avg Days to Offer', format: '0.0' },
],
});
3 changes: 3 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ApplicationStampHook,
InterviewStampHook,
OfferStampHook,
OfferTimeToOfferHook,
CandidateCredentialStampHook,
} from './stamp.hook.js';
import { InquiryStampHook, InquiryConvertHook } from './inquiry.hook.js';
Expand All @@ -14,6 +15,8 @@ export const allHooks = [
ApplicationStampHook,
InterviewStampHook,
OfferStampHook,
// Writes ats_application.days_to_offer once, on the first offer (stamp.hook.ts).
OfferTimeToOfferHook,
CandidateCredentialStampHook,
// The public application entry: stamps, then the inquiry → candidate +
// application conversion (inquiry.hook.ts).
Expand Down
86 changes: 86 additions & 0 deletions src/hooks/stamp.hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,92 @@ export const OfferStampHook = defineHook({
},
});

/**
* `ats_offer` — stamp `ats_application.days_to_offer` when the FIRST offer on
* that application is written.
*
* ## Why a stored column at all
*
* "Average days to offer" (DESIGN.md §04) is a duration between `applied_at`
* on one object and `created_at` on another. A dataset measure aggregates ONE
* column of ONE object, and the only computed form combines OTHER MEASURES of
* the same dataset by name — there is no cross-object arithmetic to reach for.
* So the duration has to exist as a column before the semantic layer can
* average it, and this is where it gets written.
*
* ## Why `afterInsert`, and why it is immune to the #43 defect by construction
*
* The value is stamped exactly ONCE per application, on the insert of its
* first offer, and is never recomputed. That is a metric decision first —
* "time to offer" means time to the FIRST offer, so a re-issued offer must not
* move it — and it also removes the whole surface #43 was about: this hook
* subscribes to no `*Update` event, so `claimSeedOwnership`'s boot-time
* `update(ats_offer, { owner_id }, { where: { owner_id: null }, multi: true })`
* dispatches nothing here. There is no payload to inspect and no per-row
* recompute that a batch-scoped `SET` clause could smear across every matched
* row. The sibling handlers above, which DO subscribe to `beforeUpdate`, carry
* the `inserting || touched([...])` guard for exactly that reason; the guard
* that fits this one is `days_to_offer == null` on the target row.
*
* That guard is also what makes a re-boot on an existing database correct: the
* seed loader upserts, an offer that already exists is an UPDATE rather than an
* insert, and even a genuine re-insert finds the column already set and leaves
* it alone.
*
* ## The write, and what else it touches
*
* The update is by id (`update({ id, ... })` — the repository reads the key out
* of the payload), never a predicate write. It goes down the engine's normal
* path, so `ats_application`'s own `beforeUpdate` stamps run on it:
* `ApplicationStampHook` sees a payload naming neither a source field nor a
* derived one and re-derives nothing, but it does refresh `last_activity_at`,
* as it does for every write to an application. That costs nothing here —
* measured on a seeded sqlite boot, all 200 applications already carry a
* `last_activity_at` inside the same second, because that assignment is
* unconditional on `beforeInsert` too — and writing an offer IS activity on the
* application, so it is the right answer rather than a side effect to suppress.
*
* `runAs: 'system'`: a recruiter extending an offer is not necessarily allowed
* to edit that application row, and a cross-object write through `ctx.api` is
* gated by the TARGET object's rules. Same elevation the stamps above need,
* for the same reason.
*/
export const OfferTimeToOfferHook = defineHook({
name: 'ats_offer_time_to_offer',
object: 'ats_offer',
events: ['afterInsert'],
priority: 100,
runAs: 'system',
description: "Stamps days_to_offer on the offer's application, once, from applied_at to this offer's created_at.",
handler: async (ctx: HookContext) => {
const api = ctx.api;
if (!api) throw new Error('ats_offer_time_to_offer: ctx.api is unavailable, the application cannot be stamped');
const input = ctx.input as Row;
const written = (ctx.result != null && typeof ctx.result === 'object' && !Array.isArray(ctx.result) ? ctx.result : {}) as Row;

const applicationId = written.application ?? input.application;
if (typeof applicationId !== 'string' || applicationId === '') return;

const application = (await api.object('ats_application').findOne({ where: { id: applicationId } })) as Row | null;
if (!application) return;
// First offer wins — see the header. Also the re-boot / re-insert guard.
if (application.days_to_offer != null) return;

const msOf = (value: unknown): number => (value == null ? Number.NaN : new Date(value as string).getTime());
const appliedMs = msOf(application.applied_at);
// `created_at` is the platform's own audit stamp on the row just written;
// an `after*` handler runs close enough to it that "now" is the honest
// fallback when the driver did not echo it back.
const offerMs = msOf(written.created_at ?? new Date().toISOString());
if (!Number.isFinite(appliedMs) || !Number.isFinite(offerMs)) return;

// Whole elapsed days, floored: "it has been N days". Never negative — an
// offer dated before its application is bad data, not a negative duration.
const days = Math.max(0, Math.floor((offerMs - appliedMs) / 86400000));
await api.object('ats_application').update({ id: applicationId, days_to_offer: days });
},
});

/** `ats_candidate_credential` — title from the credential type and level. */
export const CandidateCredentialStampHook = defineHook({
name: 'ats_candidate_credential_stamp',
Expand Down
18 changes: 18 additions & 0 deletions src/objects/application.object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,24 @@ export const Application = ObjectSchema.create({
}),
last_activity_at: Field.datetime({ label: 'Last Activity' }),

/**
* Days from `applied_at` to the FIRST offer's `created_at` — stamped once,
* by the `afterInsert` hook on `ats_offer`, and never recomputed.
*
* It is a stored column because it has to be: a dataset measure aggregates
* ONE column of ONE object, and this duration spans two objects. Averaging
* it is what the employer dashboard's "Average Days to Offer" tile does.
* A later offer on the same application does not move it — the metric is
* time to FIRST offer, and re-deriving it would make the tile drift every
* time an offer is re-issued.
*/
days_to_offer: Field.number({
label: 'Days to Offer',
min: 0,
readonly: true,
description: 'Whole days from applying to the first offer on this application. Stamped when that offer is inserted; not editable and not recomputed.',
}),

/** Roll-up: how many interview rounds this application has accumulated. */
interview_count: Field.summary({
label: 'Interviews',
Expand Down
15 changes: 13 additions & 2 deletions src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ export const en: TranslationData = {
last_activity_at: {
label: 'Last Activity',
},
days_to_offer: {
label: 'Days to Offer',
help: 'Whole days from applying to the first offer on this application. Stamped when that offer is inserted; not editable and not recomputed.',
},
interview_count: {
label: 'Interviews',
},
Expand Down Expand Up @@ -1107,7 +1111,7 @@ export const en: TranslationData = {
dashboards: {
ats_employer_hiring: {
label: 'Hiring Overview',
description: 'Your open jobs, applications awaiting action, interviews this week and the pipeline by stage.',
description: 'Your open jobs, applications awaiting action, interviews this week, average days to offer and the pipeline by stage.',
widgets: {
open_jobs: {
title: 'Open Jobs',
Expand All @@ -1121,6 +1125,10 @@ export const en: TranslationData = {
title: 'Interviews This Week',
description: 'Rounds scheduled Monday to Sunday, cancelled ones excluded.',
},
avg_days_to_offer: {
title: 'Average Days to Offer',
description: 'Applied to first offer, over your hired applications.',
},
pipeline_by_stage: {
title: 'Pipeline by Stage',
description: 'Your applications in each stage, exits included.',
Expand Down Expand Up @@ -1186,7 +1194,7 @@ export const en: TranslationData = {
datasets: {
ats_application_metrics: {
label: 'Application Metrics',
description: 'Applications by stage, source and week applied. Slice with a widget filter; one count measure.',
description: 'Applications by stage, source and week applied. Slice with a widget filter; a count and the average days to offer.',
dimensions: {
stage: {
label: 'Stage',
Expand All @@ -1202,6 +1210,9 @@ export const en: TranslationData = {
application_count: {
label: 'Applications',
},
avg_days_to_offer: {
label: 'Avg Days to Offer',
},
},
},
ats_candidate_metrics: {
Expand Down
15 changes: 13 additions & 2 deletions src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ export const zhCN: TranslationData = {
last_activity_at: {
label: '最近活动',
},
days_to_offer: {
label: '到 Offer 天数',
help: '从投递到本投递第一份 Offer 的整天数。该 Offer 写入时打戳;不可编辑,也不再重算。',
},
interview_count: {
label: '面试次数',
},
Expand Down Expand Up @@ -1106,7 +1110,7 @@ export const zhCN: TranslationData = {
dashboards: {
ats_employer_hiring: {
label: '雇主招聘看板',
description: '本机构的在招岗位、待处理投递、本周面试,以及按阶段划分的招聘流程。',
description: '本机构的在招岗位、待处理投递、本周面试、平均到 Offer 天数,以及按阶段划分的招聘流程。',
widgets: {
open_jobs: {
title: '在招岗位',
Expand All @@ -1120,6 +1124,10 @@ export const zhCN: TranslationData = {
title: '本周面试',
description: '本周一至周日排期的面试轮次,不含已取消。',
},
avg_days_to_offer: {
title: '平均到 Offer 天数',
description: '本机构已录用投递从投递到第一份 Offer 的平均天数。',
},
pipeline_by_stage: {
title: '各阶段投递',
description: '本机构处于各阶段的投递,含已退出流程的。',
Expand Down Expand Up @@ -1185,7 +1193,7 @@ export const zhCN: TranslationData = {
datasets: {
ats_application_metrics: {
label: '投递指标',
description: '按阶段、来源与投递周统计的投递。用组件筛选切片;一个计数度量。',
description: '按阶段、来源与投递周统计的投递。用组件筛选切片;一个计数度量与平均到 Offer 天数。',
dimensions: {
stage: {
label: '阶段',
Expand All @@ -1201,6 +1209,9 @@ export const zhCN: TranslationData = {
application_count: {
label: '投递数',
},
avg_days_to_offer: {
label: '平均到 Offer 天数',
},
},
},
ats_candidate_metrics: {
Expand Down
Loading