feat(uat-batch): Groups J + K — activity feed + onboarding resolver-chain

J38, J39, K40 (core) from the 2026-05-21 plan.

Shipped:
  J38  EntityActivityFeed sentence rendering surfaces the new value
       inline. Was "<actor> updated the X"; now "<actor> set X to
       <value>" when the audit row carries `newValue`. Field-level
       diff line underneath keeps showing the old → new strikethrough
       for context. Truncates inline value at 60 chars to keep long
       notes / descriptions from blowing out the row.
  J39  Client → Companies tab CTA. Empty state gains a "Link to a
       company" action; populated state grows a top-right "Link to
       company" button. New <LinkCompanyDialog> wraps the existing
       <CompanyPicker> + a membership-role select + an "is primary"
       checkbox, then POSTs to /api/v1/companies/[id]/members.
       Empty-state copy dropped "Add a membership from a company's
       detail page" — the rep can act inline now.
  K40  OnboardingChecklist resolver-chain. The auto-check no longer
       reads raw `/admin/settings` rows (which miss env fallbacks).
       Resolved endpoint widened to accept `?keys=k1,k2,...` so the
       checklist can batch-resolve any heterogenous set of registry
       keys through port → global → env → default in one round-trip.
       Checklist captures the dominant source per step ("env fallback",
       "global default", "built-in default") and surfaces it inline
       under the green tick so super-admins see when a step is
       relying on env rather than a per-port override. Compound-key
       gates report the weakest sub-key's source so a partially-env
       config still flags clearly.
       Topbar banner / dashboard tile / weekly nudge / celebration
       sub-items remain queued — the core resolver-chain gap was
       the actual cause of the "step never ticks" UAT complaint.

Verified: tsc clean, vitest 1454/1454.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 23:02:33 +02:00
parent 989cc4d72b
commit 03a7521729
4 changed files with 293 additions and 29 deletions

View File

@@ -65,15 +65,36 @@ function formatValueForField(field: string | null, value: unknown): string {
return JSON.stringify(value);
}
/** Natural-sentence rendering of one audit row. Falls back to terse when
* there's no field to talk about. */
/** Natural-sentence rendering of one audit row.
*
* Behaviour ladder:
* 1. Field changed with both old + new values → "set <field> to <new>"
* (the inline diff below already shows the old-value strikethrough,
* so we don't restate it here; result reads like a sentence rather
* than a diff label).
* 2. Field changed with only a new value → "set <field> to <new>"
* 3. Field changed with neither value (rare; legacy rows) → "<verb>
* the <field>"
* 4. No field → "<verb> this record"
*
* Truncation at 60 chars on the inline value keeps long body fields
* (notes, descriptions) from blowing out the row — the diff line
* below still renders the full value if the rep clicks through.
*/
function sentence(row: AuditRow, actor: string): string {
const verb = actionVerb(row.action);
const field = formatField(row.fieldChanged);
if (field) {
return `${actor} ${verb} the ${field}`;
if (!field) return `${actor} ${verb} this record`;
const newFormatted =
row.newValue !== null && row.newValue !== undefined
? formatValueForField(row.fieldChanged, row.newValue)
: null;
if (newFormatted) {
const truncated = newFormatted.length > 60 ? newFormatted.slice(0, 60) + '…' : newFormatted;
return `${actor} set ${field} to "${truncated}"`;
}
return `${actor} ${verb} this record`;
return `${actor} ${verb} the ${field}`;
}
interface Props {