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
29 changes: 29 additions & 0 deletions .changeset/stale-articles-review-queue-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"hotcrm": patch
---

Knowledge base: the Review Queue tab no longer claims a 180-day window it never applied.

In English, Chinese, Spanish and Japanese the knowledge-article review tab was named
"Stale (>180d)" (过期 (>180 天) / Obsoletos (>180d) / 古い (>180日)), but its filter
selected only `status = published`. The tab therefore returned **every** published
article — including one reviewed minutes ago, merely sorted to the bottom — under a
heading that promised a six-month cut. Anyone who read the tab as a worklist was
reading a list of the whole knowledge base.

The four names now match the view's own metadata label, "Review Queue · Oldest First",
which is what the view actually does: every published article, least-recently-reviewed
first. Which rows the tab returns is unchanged.

The 180-day window was measured before being ruled out rather than assumed impossible.
The date macro does resolve on the read path now, and lands on the start of the calendar
day 180 days back — but a comparison against it does not match articles that have never
been reviewed at all, and those are exactly the rows a review queue most needs to show.
Saying "older than 180 days **or** never reviewed" needs a disjunction, and a view filter
is a flat list of conditions combined with AND, so the honest window cannot currently be
written. Whether this queue should become a cut is now a separate, open product question.

The house-rule guard that catches this defect class (a view name promising a time scope
its filter does not express) previously recognised only current-calendar-period phrases
like "This Quarter", so it could not see "> 180d". It now also reads parameterised day
windows in all four locales.
2 changes: 1 addition & 1 deletion src/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ export const en: TranslationData = {
all_articles: { label: 'All Articles' },
published_articles: { label: 'Published' },
my_drafts: { label: 'My Drafts' },
stale_articles: { label: 'Stale (>180d)' },
stale_articles: { label: 'Review Queue · Oldest First' },
},
_sections: {
basic: { label: 'Article Information' },
Expand Down
2 changes: 1 addition & 1 deletion src/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ export const esES: TranslationData = {
all_articles: { label: 'Todos los artículos' },
published_articles: { label: 'Publicados' },
my_drafts: { label: 'Mis borradores' },
stale_articles: { label: 'Obsoletos (>180d)' },
stale_articles: { label: 'Cola de revisión · Más antiguos primero' },
},
_sections: {
basic: { label: 'Información del Artículo' },
Expand Down
2 changes: 1 addition & 1 deletion src/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export const jaJP: TranslationData = {
all_articles: { label: 'すべての記事' },
published_articles: { label: '公開済み' },
my_drafts: { label: '自分の下書き' },
stale_articles: { label: '古い (>180日)' },
stale_articles: { label: 'レビューキュー · 古い順' },
},
_sections: {
basic: { label: '記事情報' },
Expand Down
2 changes: 1 addition & 1 deletion src/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export const zhCN: TranslationData = {
all_articles: { label: '全部文章' },
published_articles: { label: '已发布' },
my_drafts: { label: '我的草稿' },
stale_articles: { label: '过期 (>180 天)' },
stale_articles: { label: '复核队列 · 最久未复核在前' },
},
_sections: {
basic: { label: '文章信息' },
Expand Down
89 changes: 63 additions & 26 deletions src/views/knowledge_article.view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@ import { defineView } from '@objectstack/spec/ui';
/**
* Knowledge Article Views
*
* • grid — agent-facing article queue (status / category / audience)
* • grid — agent-facing article queue (status / category / audience)
* • published — public articles only
* • stale — published > 180 days without review
* • stale — every published article, least-recently-reviewed first
*
* The third one is a RANKING, not a cut: `stale_articles` is the metadata
* item's name, not a claim about which rows come back. See its own note below
* for why it stays a ranking (#769).
*/
export const KnowledgeArticleViews = defineView({
list: {
Expand Down Expand Up @@ -73,32 +77,65 @@ export const KnowledgeArticleViews = defineView({
stale_articles: {
name: 'stale_articles',
type: 'grid',
// Operator-only filter + oldest-review-first sort, and the reason
// recorded here has expired (#744).
// Operator-only filter + least-recently-reviewed-first sort. This view
// is a RANKING and the label says so; it deliberately applies no
// 180-day cut. Both halves of that sentence were settled by measurement
// (#769), so the reasoning is recorded here rather than re-derived.
//
// 1. The old note's premise expired (#744, #773). It read: the list data
// path does NOT resolve date macros, so `last_reviewed_at <
// '{180_days_ago}'` compared the literal string, and since '2026-…' <
// '{…' is lexicographically true it matched EVERY published article.
// True on @objectstack 16.1.0; false from 17.0.0-rc.0, when
// `resolveFilterTokens()` was wired into the ObjectQL read path
// (objectql #3582) ahead of the middleware chain —
// `find`/`findOne`/`count`/`aggregate`, saved-view filters included.
// Measured on the pinned 17.0.0-rc.2: `{180_days_ago}` resolves to
// the START of the calendar day 180 days ago (00:00:00.000Z), so as
// an EXCLUSIVE upper bound under `less_than` it excludes that whole
// day — the #3777 day-boundary convention, and the right sense for a
// label reading "> 180d".
//
// 2. Expressible is not the same as expressible WITHOUT LOSS. A review
// queue's most overdue population is the never-reviewed one, and
// `last_reviewed_at` is nullable in exactly the case that matters.
// On the SINGLE-record path `knowledge_article_publish_timestamps`
// keeps it stamped — the engine's `sys_fetch_previous_update` builtin
// hands the hook a `previous`, so publishing and every later edit
// re-stamp. On the BULK path (`multi: true`) there is no
// `input.id`, that builtin cannot fetch anything, the hook sees no
// status at all and stamps nothing — so a bulk load or mass edit,
// which is how an org imports an existing knowledge base, leaves
// published articles with no review timestamp (measured; filed
// separately). `$lt` matches neither null nor an absent key, so a
// bare window deletes precisely those rows — the imported ones, the
// least reviewed of all. The honest condition is "earlier than
// {180_days_ago} OR empty" — and a view `filter` cannot say that. It
// is a FLAT, strict array of `{field, operator, value}` rules
// (`ViewFilterRuleSchema`) combined with AND; there is no `or`, no
// nesting, no logic key. Spelled as the two rules the grammar does
// allow, the window and the emptiness test AND together and the view
// returns ZERO rows. (The engine itself has `$or` and answers the
// question correctly — this is a view-authoring limit, not an engine
// limit.)
//
// It read: the list data path does NOT resolve date macros, so
// `last_reviewed_at < '{180_days_ago}'` compared the literal string, and
// since '2026-…' < '{…' is lexicographically true it matched EVERY
// published article — including ones reviewed minutes ago (verified
// against the running console). That was measured on @objectstack 16.1.0
// and stopped being true at 17.0.0-rc.0, when `resolveFilterTokens()` was
// wired into the ObjectQL read path (objectql #3582) ahead of the
// middleware chain — `find`/`findOne`/`count`/`aggregate`, saved-view
// filters included. `{180_days_ago}` matches the parameterised token
// grammar `DATE_MACRO_PARAM_RE`, so on the pinned 17.0.0-rc.2 it reaches
// the driver as a resolved instant and the lexicographic inversion above
// is unreachable on this path. `test/forecast-current-quarter-view.test.ts`
// pins that seam against a real engine for the view filters that use it.
// So the label is what changed in #769, not the filter: the four locale
// packs had translated this view as 'Stale (>180d)' / '过期 (>180 天)' /
// 'Obsoletos (>180d)' / '古い (>180日)', promising a window the filter
// never expressed — the #730 defect class, living (as it did there) only
// in the translated half. They now render the metadata label faithfully,
// and every user-visible name for this view again promises exactly the
// ordering it delivers. No `emptyState` is authored because the change
// introduces no new empty state: "no published articles" is the same
// zero this tab has always had, unlike `closing_this_quarter` (#746),
// where scoping made "empty" newly reachable.
//
// So the window is expressible now, and this view still does not express
// it — which matters more here than it does for `stale_opportunities`,
// because the four locale labels DO promise one ('Stale (>180d)' and its
// translations) while the metadata label promises only an ordering. That
// gap is #769: adding the bound is a behaviour change with two things to
// measure first (`last_reviewed_at` is `Field.datetime`, so a bare
// calendar-day comparand carries the #3777 upper-bound convention, and
// never-reviewed articles have no value at all), so it is filed rather
// than done here. Nothing below was touched.
// `test/forecast-current-quarter-view.test.ts` owns the house rule and
// pins all of the above against a real engine, including the day-window
// claim vocabulary that would fail this view the day a label reclaims a
// window. Whether the queue SHOULD become a 180-day cut is a product
// question that needs the null population handled first — filed
// separately, not decided by a translator.
label: 'Review Queue · Oldest First',
data: { provider: 'object', object: 'crm_knowledge_article' },
columns: ['article_number', 'title', 'category', 'owner_id', 'last_reviewed_at'],
Expand Down
Loading
Loading