@things-factory/ai-dashboard 10.1.213 → 10.1.215

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/client/components/card-dialogs.ts +24 -1
  2. package/client/components/dashboard-card.ts +24 -70
  3. package/client/index.ts +2 -0
  4. package/client/pages/my-dashboard-page.ts +6 -5
  5. package/client/utils/card-api.ts +3 -2
  6. package/client/utils/card-words.ts +11 -0
  7. package/client/utils/examples.ts +15 -0
  8. package/dist-client/components/card-dialogs.js +22 -1
  9. package/dist-client/components/card-dialogs.js.map +1 -1
  10. package/dist-client/components/dashboard-card.d.ts +6 -5
  11. package/dist-client/components/dashboard-card.js +23 -73
  12. package/dist-client/components/dashboard-card.js.map +1 -1
  13. package/dist-client/index.d.ts +1 -0
  14. package/dist-client/index.js +2 -0
  15. package/dist-client/index.js.map +1 -1
  16. package/dist-client/pages/my-dashboard-page.js +6 -5
  17. package/dist-client/pages/my-dashboard-page.js.map +1 -1
  18. package/dist-client/tsconfig.tsbuildinfo +1 -1
  19. package/dist-client/utils/card-api.d.ts +6 -1
  20. package/dist-client/utils/card-api.js +1 -1
  21. package/dist-client/utils/card-api.js.map +1 -1
  22. package/dist-client/utils/card-words.d.ts +14 -0
  23. package/dist-client/utils/card-words.js +9 -0
  24. package/dist-client/utils/card-words.js.map +1 -1
  25. package/dist-client/utils/examples.d.ts +3 -0
  26. package/dist-client/utils/examples.js +14 -0
  27. package/dist-client/utils/examples.js.map +1 -0
  28. package/dist-server/service/card/card-access.d.ts +8 -2
  29. package/dist-server/service/card/card-access.js +9 -3
  30. package/dist-server/service/card/card-access.js.map +1 -1
  31. package/dist-server/service/card/card-definition.d.ts +42 -6
  32. package/dist-server/service/card/card-definition.js +172 -18
  33. package/dist-server/service/card/card-definition.js.map +1 -1
  34. package/dist-server/service/card/card-names.d.ts +5 -0
  35. package/dist-server/service/card/card-names.js +6 -1
  36. package/dist-server/service/card/card-names.js.map +1 -1
  37. package/dist-server/service/card/card-proposal.d.ts +6 -0
  38. package/dist-server/service/card/card-proposal.js +9 -0
  39. package/dist-server/service/card/card-proposal.js.map +1 -1
  40. package/dist-server/service/card/card-resolver.js +24 -5
  41. package/dist-server/service/card/card-resolver.js.map +1 -1
  42. package/dist-server/service/card/card-run.js +5 -1
  43. package/dist-server/service/card/card-run.js.map +1 -1
  44. package/dist-server/service/card/card-tool.d.ts +66 -8
  45. package/dist-server/service/card/card-tool.js +88 -17
  46. package/dist-server/service/card/card-tool.js.map +1 -1
  47. package/dist-server/service/card/card-types.d.ts +1 -0
  48. package/dist-server/service/card/card-types.js +8 -4
  49. package/dist-server/service/card/card-types.js.map +1 -1
  50. package/dist-server/service/card/dashboard-card.d.ts +2 -0
  51. package/dist-server/service/card/dashboard-card.js +5 -3
  52. package/dist-server/service/card/dashboard-card.js.map +1 -1
  53. package/dist-server/tsconfig.tsbuildinfo +1 -1
  54. package/package.json +5 -5
  55. package/server/service/card/card-access.test.ts +62 -7
  56. package/server/service/card/card-access.ts +12 -5
  57. package/server/service/card/card-definition.ts +182 -18
  58. package/server/service/card/card-intent.test.ts +250 -0
  59. package/server/service/card/card-names.ts +5 -0
  60. package/server/service/card/card-proposal.ts +10 -1
  61. package/server/service/card/card-resolver.ts +26 -8
  62. package/server/service/card/card-run.ts +5 -1
  63. package/server/service/card/card-tool.ts +91 -20
  64. package/server/service/card/card-types.ts +7 -4
  65. package/server/service/card/dashboard-card.ts +5 -3
  66. package/test/card-menu.test.ts +34 -0
  67. package/test/translations.test.ts +1 -1
  68. package/translations/en.json +3 -3
  69. package/translations/es.json +3 -3
  70. package/translations/ja.json +3 -3
  71. package/translations/ko.json +3 -3
  72. package/translations/ms.json +3 -3
  73. package/translations/zh.json +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-dashboard",
3
- "version": "10.1.213",
3
+ "version": "10.1.215",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -33,9 +33,9 @@
33
33
  "@operato/popup": "^10.29.39",
34
34
  "@operato/shell": "^10.29.39",
35
35
  "@operato/styles": "^10.0.0",
36
- "@things-factory/ai-assistant": "^10.1.213",
37
- "@things-factory/ai-client-base": "^10.1.213",
38
- "@things-factory/auth-base": "^10.1.213",
36
+ "@things-factory/ai-assistant": "^10.1.215",
37
+ "@things-factory/ai-client-base": "^10.1.215",
38
+ "@things-factory/auth-base": "^10.1.215",
39
39
  "@things-factory/env": "^10.1.149",
40
40
  "@things-factory/shell": "^10.1.208",
41
41
  "graphql-scalars": "^1.22.0",
@@ -45,5 +45,5 @@
45
45
  "devDependencies": {
46
46
  "copyfiles": "^2.4.1"
47
47
  },
48
- "gitHead": "81f23aa18cb584c7d8185e81cca0d688f509cf8c"
48
+ "gitHead": "7de5e80eb1f4e2e268746aee39f820e7563f454d"
49
49
  }
@@ -5,7 +5,9 @@ import { listReadTarget, type DetailRead } from '@things-factory/ai-assistant'
5
5
 
6
6
  import { cardAccessOf, departmentsOfEmployees, shareRefusal } from './card-access'
7
7
  import { findCardProposal } from './card-proposal'
8
- import { proposeDashboardCard } from './card-tool'
8
+ import { proposeDashboardCard, readDashboardCard } from './card-tool'
9
+ import { CARD_AXIS } from './card-names'
10
+ import { CARD_AXIS as CLIENT_AXIS, cardSelection } from '../../../client/utils/card-words'
9
11
 
10
12
  const viewer = { userId: 'u-me', domainId: 'd1', departmentIds: ['dep-sales'] }
11
13
 
@@ -20,6 +22,16 @@ describe('who may use a card', () => {
20
22
  expect(cardAccessOf({ ownerId: 'u-x', domainId: 'd1' }, viewer, [{ scope: 'DOMAIN', targetId: 'd1' }])).toBe('shared')
21
23
  })
22
24
 
25
+ it('★ a role share reaches whoever holds that role in the domain, and no one else', () => {
26
+ const holder = { ...viewer, roleIds: ['role-hr'] }
27
+ expect(cardAccessOf({ ownerId: 'u-x', domainId: 'd1' }, holder, [{ scope: 'ROLE', targetId: 'role-hr' }])).toBe('shared')
28
+ expect(cardAccessOf({ ownerId: 'u-x', domainId: 'd1' }, holder, [{ scope: 'ROLE', targetId: 'role-finance' }])).toBeUndefined()
29
+ expect(cardAccessOf({ ownerId: 'u-x', domainId: 'd1' }, viewer, [{ scope: 'ROLE', targetId: 'role-hr' }])).toBeUndefined()
30
+ /* A role of the domain is given; one it does not have is refused. */
31
+ expect(shareRefusal({ scope: 'ROLE', targetId: 'role-hr' }, { domainId: 'd1', departmentsAvailable: true, roleIds: ['role-hr'] })).toBeUndefined()
32
+ expect(shareRefusal({ scope: 'ROLE', targetId: 'role-other' }, { domainId: 'd1', departmentsAvailable: true, roleIds: ['role-hr'] })).toBe('unknown-role')
33
+ })
34
+
23
35
  it('★ never across domains - not even its owner in another domain', () => {
24
36
  expect(cardAccessOf({ ownerId: 'u-me', domainId: 'd2' }, viewer, [])).toBeUndefined()
25
37
  expect(cardAccessOf({ ownerId: 'u-x', domainId: 'd2' }, viewer, [{ scope: 'DOMAIN', targetId: 'd2' }])).toBeUndefined()
@@ -105,7 +117,18 @@ describe('the card tool', () => {
105
117
  expect(result).toMatchObject({ rejected: true, error: 'definition-unfit' })
106
118
  expect(result.source.columns.map((one: any) => one.name)).toEqual(['customer', 'currency', 'remaining'])
107
119
  const unknown: any = await proposeDashboardCard({ ...args, source: { read: 'office.nope' } }, context, deps)
108
- expect(unknown).toMatchObject({ rejected: true, error: 'unknown-read', sources: ['office.receivables/by-contract'] })
120
+ expect(unknown).toMatchObject({ rejected: true, error: 'unknown-read', sources: [{ read: 'office.receivables', list: 'by-contract', tool: 'readOfficeReceivables' }] })
121
+ })
122
+
123
+ it('★ a read named by its tool is that read, and a field left empty is no field', async () => {
124
+ const result: any = await proposeDashboardCard(
125
+ { ...args, source: { read: 'readOfficeReceivables', where: { direction: 'SALES', staleDays: null } }, rows: { groupBy: 'customer', sortBy: 'remaining', desc: true, limit: 5, match: null } },
126
+ context,
127
+ deps
128
+ )
129
+ expect(result).toMatchObject({ proposed: true })
130
+ expect(result.card.definition.source).toEqual({ read: 'office.receivables', where: { direction: 'SALES' } })
131
+ expect(result.card.definition.rows).toEqual({ groupBy: 'customer', sortBy: 'remaining', desc: true, limit: 5 })
109
132
  })
110
133
 
111
134
  it('★ bars whose names repeat are sent back to be grouped - one name, one bar', async () => {
@@ -128,11 +151,43 @@ describe('the card tool', () => {
128
151
  expect(result).toMatchObject({ rejected: true, error: 'door-closed' })
129
152
  })
130
153
 
154
+ const C1 = '75e8d067-62e6-47f5-9c2f-bf10b6b97b83'
155
+ const C2 = 'f9e3afc2-2d80-4ce9-99ca-b3e628a66f02'
156
+ const C9 = '00000000-0000-4000-8000-000000000009'
157
+
131
158
  it('★ a change is of one\'s own card, from its current version', async () => {
132
- const cardOf = async (id: string) => (id === 'c1' ? { ownerId: 'u-me', version: 3 } : id === 'c2' ? { ownerId: 'u-x', version: 1 } : undefined)
133
- expect(await proposeDashboardCard({ ...args, cardId: 'c1' }, context, { ...deps, cardOf })).toMatchObject({ proposed: true, card: { cardId: 'c1', baseVersion: 3 } })
134
- expect(await proposeDashboardCard({ ...args, cardId: 'c1', baseVersion: 2 }, context, { ...deps, cardOf })).toMatchObject({ rejected: true, error: 'stale-version', current: 3 })
135
- expect(await proposeDashboardCard({ ...args, cardId: 'c2' }, context, { ...deps, cardOf })).toMatchObject({ rejected: true, error: 'not-your-card' })
136
- expect(await proposeDashboardCard({ ...args, cardId: 'c9' }, context, { ...deps, cardOf })).toMatchObject({ rejected: true, error: 'unknown-card' })
159
+ const cardOf = async (id: string) => (id === C1 ? { ownerId: 'u-me', version: 3 } : id === C2 ? { ownerId: 'u-x', version: 1 } : undefined)
160
+ expect(await proposeDashboardCard({ ...args, cardId: C1 }, context, { ...deps, cardOf })).toMatchObject({ proposed: true, card: { cardId: C1, baseVersion: 3 } })
161
+ expect(await proposeDashboardCard({ ...args, cardId: C1, baseVersion: 2 }, context, { ...deps, cardOf })).toMatchObject({ rejected: true, error: 'stale-version', current: 3 })
162
+ expect(await proposeDashboardCard({ ...args, cardId: C2 }, context, { ...deps, cardOf })).toMatchObject({ rejected: true, error: 'not-your-card' })
163
+ expect(await proposeDashboardCard({ ...args, cardId: C9 }, context, { ...deps, cardOf })).toMatchObject({ rejected: true, error: 'unknown-card' })
164
+ })
165
+
166
+ it('★ a picked card is named by its key dashboard-card:<uuid> - read and changed by it; anything else is never looked up', async () => {
167
+ const looked: string[] = []
168
+ const cardOf = async (id: string) => (looked.push(id), id === C1 ? { ownerId: 'u-me', version: 3 } : undefined)
169
+ /* The key the dashboard hands (card-words cardSelection) is the card. */
170
+ const key = cardSelection({ id: C1, name: '미수금' }, '나의 대시보드').records[0].id
171
+ expect(key).toBe(`dashboard-card:${C1}`)
172
+ expect(await proposeDashboardCard({ ...args, cardId: key }, context, { ...deps, cardOf })).toMatchObject({ proposed: true, card: { cardId: C1, baseVersion: 3 } })
173
+ /* A list name, another record's key or a name is no card - said, never handed to the database (a uuid column refuses it). */
174
+ for (const wrong of [`dashboardCards:${C1}`, `contract:${C1}`, '미수금 상위 5곳']) {
175
+ expect(await proposeDashboardCard({ ...args, cardId: wrong }, context, { ...deps, cardOf })).toMatchObject({ rejected: true, error: 'card-id-unreadable' })
176
+ }
177
+ expect(looked).toEqual([C1])
178
+ const read = async (id: string) => (looked.push(id), id === C1 ? { card: { id: C1, name: '미수금', version: 3, definition: {} }, access: 'owner' } : {})
179
+ expect(await readDashboardCard({ id: key }, context, read)).toMatchObject({ id: C1, version: 3, access: 'owner' })
180
+ expect(await readDashboardCard({ id: 'dashboardCards' }, context, read)).toMatchObject({ rejected: true, error: 'card-id-unreadable' })
181
+ expect(looked).toEqual([C1, C1])
182
+ })
183
+
184
+ it('★ on the dashboard, what the person asks to see ends in a card - not a figure in words', () => {
185
+ const { turnSystemPrompt } = require('@things-factory/ai-assistant')
186
+ expect(turnSystemPrompt('persona', { page: 'my-dashboard' })).toMatch(/end the turn with proposeDashboardCard/)
187
+ expect(turnSystemPrompt('persona', { page: 'receivable-page' })).toBe('persona')
188
+ })
189
+
190
+ it('the client hands a card by the axis the server reads', () => {
191
+ expect(CLIENT_AXIS).toBe(CARD_AXIS)
137
192
  })
138
193
  })
@@ -10,7 +10,7 @@
10
10
  export type CardAccess = 'owner' | 'shared'
11
11
 
12
12
  export interface CardShareRow {
13
- scope: 'USER' | 'DEPARTMENT' | 'DOMAIN' | string
13
+ scope: 'USER' | 'DEPARTMENT' | 'ROLE' | 'DOMAIN' | string
14
14
  targetId: string
15
15
  }
16
16
 
@@ -19,6 +19,8 @@ export interface CardViewer {
19
19
  domainId: string
20
20
  /** The departments of the viewer's active employee records - none where the organization module is not there. */
21
21
  departmentIds: readonly string[]
22
+ /** The roles the viewer holds in the domain, the ones it inherits included - a role share reaches whoever holds it. */
23
+ roleIds?: readonly string[]
22
24
  }
23
25
 
24
26
  export function cardAccessOf(card: { ownerId?: string | null; domainId?: string | null }, viewer: CardViewer, shares: readonly CardShareRow[]): CardAccess | undefined {
@@ -28,7 +30,8 @@ export function cardAccessOf(card: { ownerId?: string | null; domainId?: string
28
30
  share =>
29
31
  (share.scope === 'USER' && share.targetId === viewer.userId) ||
30
32
  (share.scope === 'DOMAIN' && share.targetId === viewer.domainId) ||
31
- (share.scope === 'DEPARTMENT' && viewer.departmentIds.includes(share.targetId))
33
+ (share.scope === 'DEPARTMENT' && viewer.departmentIds.includes(share.targetId)) ||
34
+ (share.scope === 'ROLE' && (viewer.roleIds ?? []).includes(share.targetId))
32
35
  )
33
36
  return shared ? 'shared' : undefined
34
37
  }
@@ -43,11 +46,15 @@ export function departmentsOfEmployees(records: readonly { active?: boolean | nu
43
46
  return [...new Set((records ?? []).filter(one => one?.active === true && one.department?.id).map(one => one.department!.id!))]
44
47
  }
45
48
 
46
- /** A share the owner may give - a known scope with a target; the domain scope names the owner's own domain. */
47
- export function shareRefusal(share: CardShareRow, owner: { domainId: string; departmentsAvailable: boolean }): string | undefined {
48
- if (!['USER', 'DEPARTMENT', 'DOMAIN'].includes(share.scope)) return 'unknown-scope'
49
+ /**
50
+ * A share the owner may give - a known scope with a target; the domain scope names the owner's own domain, a role one
51
+ * of the domain's roles (`roleIds`, the ones it inherits included).
52
+ */
53
+ export function shareRefusal(share: CardShareRow, owner: { domainId: string; departmentsAvailable: boolean; roleIds?: readonly string[] }): string | undefined {
54
+ if (!['USER', 'DEPARTMENT', 'ROLE', 'DOMAIN'].includes(share.scope)) return 'unknown-scope'
49
55
  if (!share.targetId) return 'no-target'
50
56
  if (share.scope === 'DOMAIN' && share.targetId !== owner.domainId) return 'another-domain'
51
57
  if (share.scope === 'DEPARTMENT' && !owner.departmentsAvailable) return 'no-departments'
58
+ if (share.scope === 'ROLE' && !(owner.roleIds ?? []).includes(share.targetId)) return 'unknown-role'
52
59
  return undefined
53
60
  }
@@ -16,7 +16,9 @@ export const CARD_SCHEMA_VERSION = 1
16
16
  export const VIEW_KINDS = ['number', 'table', 'bar', 'line'] as const
17
17
  export type ViewKind = (typeof VIEW_KINDS)[number]
18
18
 
19
- export const MATCH_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'] as const
19
+ export const MATCH_OPS = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'not_in'] as const
20
+ /** The operators whose value is a list - one of these values, none of them. */
21
+ export const LIST_OPS = ['in', 'not_in'] as const
20
22
  export type MatchOp = (typeof MATCH_OPS)[number]
21
23
 
22
24
  /** The rows a table shows, and the points a chart draws, at most (ADR-0160 decision 4). */
@@ -38,7 +40,8 @@ export interface CardSourceRef {
38
40
  export interface CardMatch {
39
41
  column: string
40
42
  op: MatchOp
41
- value: string | number | boolean
43
+ /** A list for `in` and `not_in` (status not_in [APPROVED, REJECTED, WITHDRAWN]); one value otherwise. */
44
+ value: string | number | boolean | (string | number)[]
42
45
  }
43
46
 
44
47
  export interface CardRows {
@@ -82,6 +85,10 @@ export interface SourceCondition {
82
85
  description?: string
83
86
  /** A detail read's argument its query cannot run without. */
84
87
  required?: boolean
88
+ /** A string condition that holds a month as YYYY-MM - it takes a relative month (this-month, -5m). */
89
+ format?: 'month'
90
+ /** It takes several values at once - a list read's statuses (`status: [..]`, `statusNot: [..]`). */
91
+ many?: boolean
85
92
  }
86
93
 
87
94
  /** What a card may read from one declaration - the same whichever kind of read it is. */
@@ -96,6 +103,8 @@ export interface CardSource {
96
103
  target?: ReadTarget
97
104
  detail?: DetailRead
98
105
  list?: DetailList
106
+ /** The rows are the viewer's own, whoever looks (내 지출결의, 나의 전략 업무) - ReadTarget.mine · DetailRead.mine. */
107
+ mine?: boolean
99
108
  }
100
109
 
101
110
  /** The source a reference names - or why there is none. */
@@ -117,10 +126,11 @@ export function sourceOf(
117
126
  query: target.query,
118
127
  columns: target.columns,
119
128
  conditions: [
120
- ...target.criteria.map(one => ({ name: one.name, type: one.type, values: one.values, description: one.description })),
129
+ ...target.criteria.map(one => ({ name: one.name, type: one.type, values: one.values, description: one.description, ...(one.format ? { format: one.format } : {}), ...(one.many ? { many: true } : {}) })),
121
130
  ...(target.search ? [{ name: 'text', type: 'string', description: target.search.description }] : [])
122
131
  ],
123
- target
132
+ target,
133
+ ...(target.mine ? { mine: true } : {})
124
134
  }
125
135
  }
126
136
  }
@@ -139,20 +149,32 @@ export function sourceOf(
139
149
  description: list.description ?? detail.description,
140
150
  query: detail.query,
141
151
  columns: list.columns,
142
- conditions: (detail.args ?? []).map(one => ({ name: one.name, type: one.type, values: one.values, description: one.description, required: !!one.required })),
152
+ conditions: (detail.args ?? []).map(one => ({ name: one.name, type: one.type, values: one.values, description: one.description, required: !!one.required, ...(one.format ? { format: one.format } : {}) })),
143
153
  detail,
144
- list
154
+ list,
155
+ ...(detail.mine ? { mine: true } : {})
145
156
  }
146
157
  }
147
158
  }
148
159
 
149
160
  /* ── Relative dates (ADR-0160 decision 3) ─────────────────────────────────────────────────────────────────── */
150
161
 
151
- const RELATIVE_DATE = /^(today|(this|last|next)-(week|month|quarter|year)-(start|end)|[+-]?\d{1,4}d)$/
152
- const RELATIVE_PERIOD = /^(this|last|next)-(year|month)$/
162
+ /*
163
+ * The words of a period that stays current - the only ones a card keeps:
164
+ * a day today · -30d · this-month-start · last-year-end · -5m-start (the first day of five months ago)
165
+ * a year or a month as an integer this-year · last-month · -5m
166
+ * a month as YYYY-MM this-month · last-month · -5m · this-year-start (its January) · this-year-end
167
+ * "The last six months" is from -5m-start to this-month-end (or a YYYY-MM range from -5m to this-month).
168
+ */
169
+ const RELATIVE_DATE = /^(today|(this|last|next)-(week|month|quarter|year)-(start|end)|[+-]?\d{1,4}d|-\d{1,2}m-(start|end))$/
170
+ const RELATIVE_PERIOD = /^((this|last|next)-(year|month)|-\d{1,2}m)$/
171
+ const RELATIVE_MONTH = /^((this|last|next)-month|-\d{1,2}m|(this|last|next)-year-(start|end))$/
172
+ /** A value that looks meant as a relative one - checked against the words above, never taken for a literal. */
173
+ const LOOKS_RELATIVE = /^((this|last|next)-|today$|-\d)/
153
174
 
154
175
  export const isRelativeDate = (value: unknown) => typeof value === 'string' && RELATIVE_DATE.test(value)
155
176
  export const isRelativePeriod = (value: unknown) => typeof value === 'string' && RELATIVE_PERIOD.test(value)
177
+ export const isRelativeMonth = (value: unknown) => typeof value === 'string' && RELATIVE_MONTH.test(value)
156
178
 
157
179
  const pad = (n: number) => String(n).padStart(2, '0')
158
180
  const ymd = (date: Date) => `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`
@@ -173,6 +195,8 @@ export function resolveRelativeDate(value: string, today: string): string {
173
195
  if (value === 'today') return today
174
196
  const days = /^([+-]?\d{1,4})d$/.exec(value)
175
197
  if (days) return ymd(utc(y, m - 1, d + Number(days[1])))
198
+ const monthsAgo = /^-(\d{1,2})m-(start|end)$/.exec(value)
199
+ if (monthsAgo) return ymd(monthsAgo[2] === 'start' ? utc(y, m - 1 - Number(monthsAgo[1]), 1) : utc(y, m - Number(monthsAgo[1]), 0))
176
200
  const [, which, unit, edge] = /^(this|last|next)-(week|month|quarter|year)-(start|end)$/.exec(value)!
177
201
  const step = which === 'last' ? -1 : which === 'next' ? 1 : 0
178
202
  if (unit === 'week') {
@@ -188,24 +212,39 @@ export function resolveRelativeDate(value: string, today: string): string {
188
212
  return ymd(edge === 'start' ? utc(y + step, 0, 1) : utc(y + step, 11, 31))
189
213
  }
190
214
 
215
+ /** How many months a relative month moves from this one - `last-month` -1, `-5m` -5. */
216
+ const monthStep = (value: string) => (/^-(\d{1,2})m$/.test(value) ? -Number(value.slice(1, -1)) : value.startsWith('last') ? -1 : value.startsWith('next') ? 1 : 0)
217
+
218
+ /** A relative month as YYYY-MM, from `today` - `this-year-start` is this year's January. */
219
+ export function resolveRelativeMonth(value: string, today: string): string {
220
+ const [y, m] = today.split('-').map(Number)
221
+ const year = /^(this|last|next)-year-(start|end)$/.exec(value)
222
+ if (year) return `${y + (year[1] === 'last' ? -1 : year[1] === 'next' ? 1 : 0)}-${year[2] === 'start' ? '01' : '12'}`
223
+ const month = utc(y, m - 1 + monthStep(value), 1)
224
+ return `${month.getUTCFullYear()}-${pad(month.getUTCMonth() + 1)}`
225
+ }
226
+
191
227
  /**
192
- * The relative values of a definition's conditions, as of `today`. A year and a month are read from one month: with a
193
- * month token in the same conditions, `this-year` is that month's year - January's `last-month` with `this-year` is
194
- * last December, never this year's December.
228
+ * The relative values of a definition's conditions, as of `today` - each by what its condition holds (`conditions`):
229
+ * a month as YYYY-MM, a day, an integer year or month. A year and a month are read from one month: with a month token
230
+ * in the same conditions, `this-year` is that month's year - January's `last-month` with `this-year` is last December,
231
+ * never this year's December.
195
232
  */
196
- export function resolveWhere(where: Record<string, unknown> | undefined, today: string): Record<string, unknown> {
233
+ export function resolveWhere(where: Record<string, unknown> | undefined, today: string, conditions: readonly SourceCondition[] = []): Record<string, unknown> {
197
234
  const given = where ?? {}
235
+ const formats = new Map(conditions.map(one => [one.name, one.format]))
198
236
  const [y, m] = today.split('-').map(Number)
199
- const monthToken = Object.values(given).find(value => typeof value === 'string' && /^(this|last|next)-month$/.test(value)) as string | undefined
200
- const base = monthToken ? utc(y, m - 1 + (monthToken.startsWith('last') ? -1 : monthToken.startsWith('next') ? 1 : 0), 1) : utc(y, m - 1, 1)
237
+ const monthToken = Object.entries(given).find(([name, value]) => formats.get(name) !== 'month' && typeof value === 'string' && /^((this|last|next)-month|-\d{1,2}m)$/.test(value))?.[1] as string | undefined
238
+ const base = monthToken ? utc(y, m - 1 + monthStep(monthToken), 1) : utc(y, m - 1, 1)
201
239
  const baseYear = base.getUTCFullYear()
202
240
  const out: Record<string, unknown> = {}
203
241
  for (const [name, value] of Object.entries(given)) {
204
- if (isRelativeDate(value)) out[name] = resolveRelativeDate(value as string, today)
242
+ if (formats.get(name) === 'month' && isRelativeMonth(value)) out[name] = resolveRelativeMonth(value as string, today)
243
+ else if (isRelativeDate(value)) out[name] = resolveRelativeDate(value as string, today)
205
244
  else if (isRelativePeriod(value)) {
206
245
  const [which, unit] = (value as string).split('-')
207
246
  const step = which === 'last' ? -1 : which === 'next' ? 1 : 0
208
- out[name] = unit === 'month' ? base.getUTCMonth() + 1 : baseYear + step
247
+ out[name] = unit === 'year' ? baseYear + step : base.getUTCMonth() + 1
209
248
  } else out[name] = value
210
249
  }
211
250
  return out
@@ -245,7 +284,11 @@ export function checkDefinition(definition: CardDefinition | undefined, source:
245
284
  issues.push({ code: 'unknown-condition', params: { name, conditions: [...conditions.keys()], ...(column ? { isColumn: true, use: `rows.match: [{ column: '${name}', op: 'eq', value: ${JSON.stringify(value)} }]` } : {}) } })
246
285
  continue
247
286
  }
248
- if (typeof value === 'string' && /^(this|last|next)-/.test(value) && !isRelativeDate(value) && !isRelativePeriod(value)) issues.push({ code: 'unknown-relative-value', params: { name, value } })
287
+ if (condition?.format === 'month') {
288
+ if (typeof value === 'string' && LOOKS_RELATIVE.test(value) && !isRelativeMonth(value)) issues.push({ code: 'relative-value-unfit', params: { name, value, takes: 'a month as YYYY-MM, or this-month, last-month, -5m, this-year-start, this-year-end' } })
289
+ continue
290
+ }
291
+ if (typeof value === 'string' && LOOKS_RELATIVE.test(value) && !isRelativeDate(value) && !isRelativePeriod(value)) issues.push({ code: 'unknown-relative-value', params: { name, value } })
249
292
  if (isRelativePeriod(value) && condition?.type !== 'integer') issues.push({ code: 'relative-period-on-non-integer', params: { name, value } })
250
293
  if (isRelativeDate(value) && condition?.type !== 'date') issues.push({ code: 'relative-date-on-non-date', params: { name, value } })
251
294
  }
@@ -259,10 +302,41 @@ export function checkDefinition(definition: CardDefinition | undefined, source:
259
302
  const columns = new Map(source.columns.map(one => [one.name, one]))
260
303
  const columnNames = [...columns.keys()]
261
304
  const rows = definition.rows ?? {}
305
+ const scalar = (value: unknown) => typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
262
306
  for (const match of rows.match ?? []) {
263
307
  if (!columns.has(match?.column)) issues.push({ code: 'unknown-column', params: { where: 'match', column: match?.column ?? null, columns: columnNames } })
264
308
  if (!known(MATCH_OPS, match?.op)) issues.push({ code: 'unknown-operator', params: { op: match?.op ?? null, ops: MATCH_OPS } })
265
- if (match?.value === undefined || match?.value === null || typeof match.value === 'object') issues.push({ code: 'match-without-value', params: { column: match?.column ?? null } })
309
+ else if (known(LIST_OPS, match.op)) {
310
+ if (!Array.isArray(match.value) || !match.value.length || !match.value.every(scalar)) issues.push({ code: 'match-without-values', params: { column: match.column, op: match.op } })
311
+ } else if (match?.value === undefined || match?.value === null || !scalar(match.value)) issues.push({ code: 'match-without-value', params: { column: match?.column ?? null } })
312
+ /*
313
+ * A relative value is read on a date column only - on another it is the literal text 「this-month」, which no row
314
+ * holds: 「이번 달 지출결의 합계」 matched month eq this-month and drew 0 (2026-10-25). A period is the source's
315
+ * condition (a YYYY-MM one takes this-month).
316
+ */
317
+ const column = columns.get(match?.column)
318
+ const relative = (Array.isArray(match?.value) ? match.value : [match?.value]).filter(one => typeof one === 'string' && LOOKS_RELATIVE.test(one))
319
+ if (column && relative.length) {
320
+ if (column.type !== 'date') issues.push({ code: 'relative-value-on-non-date-column', params: { column: column.name, value: relative[0], periodConditions: source.conditions.filter(one => one.type === 'date' || one.format === 'month' || (one.type === 'integer' && /year|month/i.test(one.name))).map(one => one.name) } })
321
+ else if (relative.some(one => !isRelativeDate(one))) issues.push({ code: 'unknown-relative-value', params: { name: column.name, value: relative.find(one => !isRelativeDate(one)) } })
322
+ }
323
+ }
324
+ /*
325
+ * Conditions that no row can meet - two `eq` of one column (status 작성중 AND 상신), or an `eq` outside its own `in`.
326
+ * Every match must hold, so these draw nothing, or a number of nothing that looks true (2026-10-25).
327
+ */
328
+ const equals = new Map<string, Set<string>>()
329
+ for (const match of rows.match ?? []) {
330
+ if (!match || typeof match.column !== 'string') continue
331
+ const values = match.op === 'eq' && scalar(match.value) ? [String(match.value)] : match.op === 'in' && Array.isArray(match.value) ? match.value.map(String) : undefined
332
+ if (!values) continue
333
+ const before = equals.get(match.column)
334
+ const kept = before ? values.filter(one => before.has(one)) : values
335
+ if (before && !kept.length) {
336
+ issues.push({ code: 'match-never-true', params: { column: match.column, use: `one match of ${match.column} with op in and every value it may hold` } })
337
+ continue
338
+ }
339
+ equals.set(match.column, new Set(kept))
266
340
  }
267
341
  if (rows.groupBy !== undefined) {
268
342
  const by = columns.get(rows.groupBy)
@@ -296,3 +370,93 @@ export function checkDefinition(definition: CardDefinition | undefined, source:
296
370
  if (view.unit !== undefined && (typeof view.unit !== 'string' || view.unit.length > 20)) issues.push({ code: 'unit-too-long' })
297
371
  return issues
298
372
  }
373
+
374
+ /* ── What the card says it shows, against what it reads ───────────────────────────────────────────────────── */
375
+
376
+ export type PeriodUnit = 'day' | 'week' | 'month' | 'months' | 'year'
377
+
378
+ /*
379
+ * The periods a card's name or line may say, in the languages the module ships the most - a name the dashboard shows
380
+ * every day. 「이번 달 합계」 with no condition of the month summed all time (2026-10-25).
381
+ */
382
+ const NAMED_PERIODS: [PeriodUnit, RegExp][] = [
383
+ ['months', /(최근|지난)\s*\d+\s*개월|\d+\s*개월\s*(간|동안)|\b(last|past)\s+\d+\s+months?\b|過去\s*\d+\s*(ヶ|か|カ)月|近\s*\d+\s*个月/i],
384
+ ['month', /이번\s*달|이달|금월|당월|지난\s*달|저번\s*달|전월|다음\s*달|\b(this|last|next)\s+month\b|今月|先月|本月|上个?月/i],
385
+ ['year', /올해|금년|당해|작년|지난\s*해|전년도|내년|\b(this|last|next)\s+year\b|今年|昨年|去年/i],
386
+ ['week', /이번\s*주|금주|지난\s*주|저번\s*주|다음\s*주|\b(this|last|next)\s+week\b|今週|先週|本周|上周/i],
387
+ ['day', /오늘|금일|\btoday\b|今日|今天/i]
388
+ ]
389
+
390
+ /** The periods a text says - none for a text without one. */
391
+ export function periodsNamed(text: string | undefined): PeriodUnit[] {
392
+ const given = String(text ?? '')
393
+ return NAMED_PERIODS.filter(([, words]) => words.test(given)).map(([unit]) => unit)
394
+ }
395
+
396
+ /** The units a relative value reads - `this-month-start` a month, `-5m` months back, `-30d` days. */
397
+ function unitsOf(value: unknown): PeriodUnit[] {
398
+ if (typeof value !== 'string') return []
399
+ if (/^-\d{1,2}m(-(start|end))?$/.test(value)) return ['months']
400
+ if (/^today$|^[+-]?\d{1,4}d$/.test(value)) return /^[+-]?([7-9]|1[0-4])d$/.test(value) ? ['day', 'week'] : ['day']
401
+ const unit = /^(this|last|next)-(week|month|quarter|year)/.exec(value)?.[2]
402
+ return unit === 'quarter' ? ['months'] : unit ? [unit as PeriodUnit] : []
403
+ }
404
+
405
+ const FIXED_DAY = /^\d{4}(-\d{2}(-\d{2})?)?$/
406
+ /** A bound held to one day, month or year - `2026-10-01`, `2026-10`, or an integer year or month (`year: 2026, month: 10`). */
407
+ const isFixed = (value: unknown) => typeof value === 'number' || (typeof value === 'string' && FIXED_DAY.test(value))
408
+
409
+ /** The viewer as the definition may name them by mistake - their user id, name and e-mail. */
410
+ export interface ViewerNames {
411
+ id?: string
412
+ name?: string
413
+ email?: string
414
+ }
415
+
416
+ /**
417
+ * Whether a card means what its name says, and reads as whoever looks at it. Issues, never repairs:
418
+ * - a period in the name or line (이번 달, 최근 6개월) with no condition of that period - it would sum all time;
419
+ * - a period said relatively but held to fixed days - it would stop being "this month" next month;
420
+ * - a filter on the viewer's own name or id: a read of the viewer's own rows needs none (it is theirs whoever looks),
421
+ * and another read filtered by one person shows that person to everyone the card is shared with - the viewer's own
422
+ * read (`mine`) is the source instead.
423
+ * `mineSources` are the reads of one's own rows the model may use instead (cardSources).
424
+ */
425
+ export function checkCardIntent(
426
+ card: { name?: string; description?: string; definition: CardDefinition },
427
+ source: CardSource,
428
+ viewer: ViewerNames = {},
429
+ mineSources: string[] = []
430
+ ): CardIssue[] {
431
+ const issues: CardIssue[] = []
432
+ const { definition } = card
433
+ const where = definition.source?.where ?? {}
434
+ const matches = definition.rows?.match ?? []
435
+ const dateColumns = new Set(source.columns.filter(one => one.type === 'date').map(one => one.name))
436
+ const periodConditions = new Set(source.conditions.filter(one => one.type === 'date' || one.format === 'month' || (one.type === 'integer' && /year|month/i.test(one.name))).map(one => one.name))
437
+
438
+ /* The values that bound a period - a period condition's, a date column's match. */
439
+ const bounds = [
440
+ ...Object.entries(where).filter(([name]) => periodConditions.has(name)).map(([, value]) => value),
441
+ ...matches.filter(one => dateColumns.has(one?.column)).map(one => one.value)
442
+ ]
443
+ const named = [...new Set([...periodsNamed(card.name), ...periodsNamed(card.description)])]
444
+ if (named.length) {
445
+ if (!bounds.length) issues.push({ code: 'period-without-condition', params: { said: named, conditions: [...periodConditions], dateColumns: [...dateColumns] } })
446
+ else if (bounds.some(isFixed)) issues.push({ code: 'period-held-to-fixed-days', params: { said: named, given: bounds.filter(isFixed) } })
447
+ else {
448
+ const read = new Set(bounds.flatMap(unitsOf))
449
+ const missing = named.filter(unit => !read.has(unit))
450
+ if (read.size && missing.length) issues.push({ code: 'period-unlike-condition', params: { said: missing, given: bounds } })
451
+ }
452
+ }
453
+
454
+ const own = [viewer.id, viewer.name, viewer.email].filter((one): one is string => !!one && one.trim().length > 1).map(one => one.trim().toLowerCase())
455
+ const isOwn = (value: unknown) => (Array.isArray(value) ? value : [value]).some(one => (typeof one === 'string' || typeof one === 'number') && own.includes(String(one).trim().toLowerCase()))
456
+ const onViewer = [...Object.entries(where).filter(([, value]) => isOwn(value)).map(([name]) => name), ...matches.filter(one => isOwn(one?.value)).map(one => one.column)]
457
+ if (onViewer.length) {
458
+ if (source.mine) issues.push({ code: 'viewer-filter-on-own-read', params: { filters: onViewer } })
459
+ else if (mineSources.length) issues.push({ code: 'viewer-filter-use-own-read', params: { filters: onViewer, mine: mineSources } })
460
+ }
461
+ return issues
462
+ }