From b130cc77a5894247e5bde93c9ec9075990d3630b Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:33:30 +0000 Subject: [PATCH 1/3] feat(dashboards): average days to offer, stamped on ats_application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #55. The fourth tile of ats_employer_hiring, and the column it needs. - ats_application.days_to_offer (number, min 0): whole days from applied_at to the FIRST offer's created_at. A dataset measure aggregates one column of one object, so a duration spanning ats_application and ats_offer has to be a column before the semantic layer can average it. - OfferTimeToOfferHook: afterInsert on ats_offer, runAs system, writes the column by id. It subscribes to no *Update event at all, so the #43 shape (claimSeedOwnership's predicate update smearing one row's value across the batch) has no surface here; the guard that fits an insert-only stamp is days_to_offer == null on the target row, which also makes a re-boot on an existing database and a re-issued offer both no-ops. - ats_application_metrics gains avg_days_to_offer (aggregate: avg). No measure-scoped filter: that compiles to a conditional aggregate the memory driver answers 501 to. The tile filters at the widget instead. - The dashboard comment block explaining why the tile was absent is replaced with what the tile is; the pipeline bar moves down one row. - en + zh-CN labels for the field, the measure and the widget (lint runs --i18n-strict). - docs/backlog/13-dashboards.md said "median days" under a heading that restates DESIGN.md §04. §04 itself has read 平均到 Offer 天数 since the bootstrap commit, so the card was the half that diverged; it now reads "average days". DESIGN.md is unchanged. Measured, memory and sqlite, after [Seeder] Seed loading complete: the seed loader DOES fire afterInsert, so all 23 offer-bearing applications (9 hired among them) are stamped at boot with no seed change. Quillstone's tile reads 30 over 1 hired application, Harborline's 58 over 2, each matching an independent recomputation from applied_at and the first offer's created_at that never reads the column. A no-op PATCH leaves all nine values (five distinct) unchanged, and a second boot on the same sqlite database reproduces them exactly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG --- docs/backlog/13-dashboards.md | 2 +- src/dashboards/employer-hiring.dashboard.ts | 51 ++++++++---- src/datasets/application.dataset.ts | 14 +++- src/hooks/index.ts | 3 + src/hooks/stamp.hook.ts | 86 +++++++++++++++++++++ src/objects/application.object.ts | 17 ++++ src/translations/en.ts | 15 +++- src/translations/zh-CN.ts | 15 +++- 8 files changed, 180 insertions(+), 23 deletions(-) diff --git a/docs/backlog/13-dashboards.md b/docs/backlog/13-dashboards.md index 2e0f7ef..082d97b 100644 --- a/docs/backlog/13-dashboards.md +++ b/docs/backlog/13-dashboards.md @@ -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. diff --git a/src/dashboards/employer-hiring.dashboard.ts b/src/dashboards/employer-hiring.dashboard.ts index 40ebf79..a314eed 100644 --- a/src/dashboards/employer-hiring.dashboard.ts +++ b/src/dashboards/employer-hiring.dashboard.ts @@ -8,22 +8,27 @@ 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. 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 @@ -32,7 +37,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 }, @@ -71,6 +76,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', @@ -86,7 +103,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 }, }, ], }; diff --git a/src/datasets/application.dataset.ts b/src/datasets/application.dataset.ts index 5ab1528..4ffa054 100644 --- a/src/datasets/application.dataset.ts +++ b/src/datasets/application.dataset.ts @@ -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' }, @@ -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' }, ], }); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 0b79733..401a069 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,6 +4,7 @@ import { ApplicationStampHook, InterviewStampHook, OfferStampHook, + OfferTimeToOfferHook, CandidateCredentialStampHook, } from './stamp.hook.js'; import { InquiryStampHook, InquiryConvertHook } from './inquiry.hook.js'; @@ -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). diff --git a/src/hooks/stamp.hook.ts b/src/hooks/stamp.hook.ts index c284dfc..5d4288f 100644 --- a/src/hooks/stamp.hook.ts +++ b/src/hooks/stamp.hook.ts @@ -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', diff --git a/src/objects/application.object.ts b/src/objects/application.object.ts index f8f5b11..bfe8ade 100644 --- a/src/objects/application.object.ts +++ b/src/objects/application.object.ts @@ -95,6 +95,23 @@ 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, + 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', diff --git a/src/translations/en.ts b/src/translations/en.ts index c66e1cf..f3573d6 100644 --- a/src/translations/en.ts +++ b/src/translations/en.ts @@ -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', }, @@ -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', @@ -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.', @@ -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', @@ -1202,6 +1210,9 @@ export const en: TranslationData = { application_count: { label: 'Applications', }, + avg_days_to_offer: { + label: 'Avg Days to Offer', + }, }, }, ats_candidate_metrics: { diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index ae69541..0392e6a 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -103,6 +103,10 @@ export const zhCN: TranslationData = { last_activity_at: { label: '最近活动', }, + days_to_offer: { + label: '到 Offer 天数', + help: '从投递到本投递第一份 Offer 的整天数。该 Offer 写入时打戳;不可编辑,也不再重算。', + }, interview_count: { label: '面试次数', }, @@ -1106,7 +1110,7 @@ export const zhCN: TranslationData = { dashboards: { ats_employer_hiring: { label: '雇主招聘看板', - description: '本机构的在招岗位、待处理投递、本周面试,以及按阶段划分的招聘流程。', + description: '本机构的在招岗位、待处理投递、本周面试、平均到 Offer 天数,以及按阶段划分的招聘流程。', widgets: { open_jobs: { title: '在招岗位', @@ -1120,6 +1124,10 @@ export const zhCN: TranslationData = { title: '本周面试', description: '本周一至周日排期的面试轮次,不含已取消。', }, + avg_days_to_offer: { + title: '平均到 Offer 天数', + description: '本机构已录用投递从投递到第一份 Offer 的平均天数。', + }, pipeline_by_stage: { title: '各阶段投递', description: '本机构处于各阶段的投递,含已退出流程的。', @@ -1185,7 +1193,7 @@ export const zhCN: TranslationData = { datasets: { ats_application_metrics: { label: '投递指标', - description: '按阶段、来源与投递周统计的投递。用组件筛选切片;一个计数度量。', + description: '按阶段、来源与投递周统计的投递。用组件筛选切片;一个计数度量与平均到 Offer 天数。', dimensions: { stage: { label: '阶段', @@ -1201,6 +1209,9 @@ export const zhCN: TranslationData = { application_count: { label: '投递数', }, + avg_days_to_offer: { + label: '平均到 Offer 天数', + }, }, }, ats_candidate_metrics: { From b892bd839fd8379e9ed21802f78484f43cbe2ebd Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:41:36 +0000 Subject: [PATCH 2/3] fix(objects): make days_to_offer actually not editable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The field's own description says "not editable and not recomputed". The second half was enforced by the hook; the first half was not — `readonly` defaulted to false, and an ordinary employer administrator could PATCH `days_to_offer` on their own hired application and move the Average Days to Offer tile with it. Measured before the fix: 30 -> 1, HTTP 200, value persisted. `readonly: true` closes it, and costs nothing: the engine's strip reads hook-write provenance, so the `afterInsert` stamp still writes the column while a caller's value is dropped. Measured after the fix, on a seeded memory boot: all nine hired applications stamped [30,30,36,49,58,58,58,66,66] exactly as before, the same PATCH now leaves the row at 30, and the tile still reads 30 over 1. A declared-but-unenforced claim is the defect class this repository keeps closing (#18, #13, #32, #56, and #63's whole premise); it should not ship a new one in a field description. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG --- src/objects/application.object.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/objects/application.object.ts b/src/objects/application.object.ts index bfe8ade..f620bd7 100644 --- a/src/objects/application.object.ts +++ b/src/objects/application.object.ts @@ -109,6 +109,7 @@ export const Application = ObjectSchema.create({ 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.', }), From 81bf2719959c3329e40786154a78eb67d7993d7f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:42:36 +0000 Subject: [PATCH 3/3] docs(dashboards): say what the demo seed feeds the new tile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A seed cannot set `created_at`, so every seeded offer is created at boot and `days_to_offer` equals the application's AGE — verified against `appliedDaysAgo` in the seed skeleton, an exact match on all nine hired rows and on all 23 stamped ones. Quillstone's 30 and Harborline's 58 read as "took 30 / 58 days to decide" and actually mean "filed 30 / 58 days ago". The metric is right and the tile computes it correctly; the demo has no real elapsed time in it to compute over (#65). Recording that where someone reading the tile will find it, rather than letting the number be quoted as product history. Comment only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PbJ5Cy9KDAzeQHo8bsMadG --- src/dashboards/employer-hiring.dashboard.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/dashboards/employer-hiring.dashboard.ts b/src/dashboards/employer-hiring.dashboard.ts index a314eed..92dbe87 100644 --- a/src/dashboards/employer-hiring.dashboard.ts +++ b/src/dashboards/employer-hiring.dashboard.ts @@ -26,7 +26,18 @@ import type { Dashboard } from '@objectstack/spec/ui'; * - 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. The stage filter is a + * 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. *