@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.
- package/client/components/card-dialogs.ts +24 -1
- package/client/components/dashboard-card.ts +24 -70
- package/client/index.ts +2 -0
- package/client/pages/my-dashboard-page.ts +6 -5
- package/client/utils/card-api.ts +3 -2
- package/client/utils/card-words.ts +11 -0
- package/client/utils/examples.ts +15 -0
- package/dist-client/components/card-dialogs.js +22 -1
- package/dist-client/components/card-dialogs.js.map +1 -1
- package/dist-client/components/dashboard-card.d.ts +6 -5
- package/dist-client/components/dashboard-card.js +23 -73
- package/dist-client/components/dashboard-card.js.map +1 -1
- package/dist-client/index.d.ts +1 -0
- package/dist-client/index.js +2 -0
- package/dist-client/index.js.map +1 -1
- package/dist-client/pages/my-dashboard-page.js +6 -5
- package/dist-client/pages/my-dashboard-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/card-api.d.ts +6 -1
- package/dist-client/utils/card-api.js +1 -1
- package/dist-client/utils/card-api.js.map +1 -1
- package/dist-client/utils/card-words.d.ts +14 -0
- package/dist-client/utils/card-words.js +9 -0
- package/dist-client/utils/card-words.js.map +1 -1
- package/dist-client/utils/examples.d.ts +3 -0
- package/dist-client/utils/examples.js +14 -0
- package/dist-client/utils/examples.js.map +1 -0
- package/dist-server/service/card/card-access.d.ts +8 -2
- package/dist-server/service/card/card-access.js +9 -3
- package/dist-server/service/card/card-access.js.map +1 -1
- package/dist-server/service/card/card-definition.d.ts +42 -6
- package/dist-server/service/card/card-definition.js +172 -18
- package/dist-server/service/card/card-definition.js.map +1 -1
- package/dist-server/service/card/card-names.d.ts +5 -0
- package/dist-server/service/card/card-names.js +6 -1
- package/dist-server/service/card/card-names.js.map +1 -1
- package/dist-server/service/card/card-proposal.d.ts +6 -0
- package/dist-server/service/card/card-proposal.js +9 -0
- package/dist-server/service/card/card-proposal.js.map +1 -1
- package/dist-server/service/card/card-resolver.js +24 -5
- package/dist-server/service/card/card-resolver.js.map +1 -1
- package/dist-server/service/card/card-run.js +5 -1
- package/dist-server/service/card/card-run.js.map +1 -1
- package/dist-server/service/card/card-tool.d.ts +66 -8
- package/dist-server/service/card/card-tool.js +88 -17
- package/dist-server/service/card/card-tool.js.map +1 -1
- package/dist-server/service/card/card-types.d.ts +1 -0
- package/dist-server/service/card/card-types.js +8 -4
- package/dist-server/service/card/card-types.js.map +1 -1
- package/dist-server/service/card/dashboard-card.d.ts +2 -0
- package/dist-server/service/card/dashboard-card.js +5 -3
- package/dist-server/service/card/dashboard-card.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/service/card/card-access.test.ts +62 -7
- package/server/service/card/card-access.ts +12 -5
- package/server/service/card/card-definition.ts +182 -18
- package/server/service/card/card-intent.test.ts +250 -0
- package/server/service/card/card-names.ts +5 -0
- package/server/service/card/card-proposal.ts +10 -1
- package/server/service/card/card-resolver.ts +26 -8
- package/server/service/card/card-run.ts +5 -1
- package/server/service/card/card-tool.ts +91 -20
- package/server/service/card/card-types.ts +7 -4
- package/server/service/card/dashboard-card.ts +5 -3
- package/test/card-menu.test.ts +34 -0
- package/test/translations.test.ts +1 -1
- package/translations/en.json +3 -3
- package/translations/es.json +3 -3
- package/translations/ja.json +3 -3
- package/translations/ko.json +3 -3
- package/translations/ms.json +3 -3
- 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.
|
|
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.
|
|
37
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
38
|
-
"@things-factory/auth-base": "^10.1.
|
|
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": "
|
|
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
|
|
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 ===
|
|
133
|
-
expect(await proposeDashboardCard({ ...args, cardId:
|
|
134
|
-
expect(await proposeDashboardCard({ ...args, cardId:
|
|
135
|
-
expect(await proposeDashboardCard({ ...args, cardId:
|
|
136
|
-
expect(await proposeDashboardCard({ ...args, cardId:
|
|
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
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
193
|
-
* month
|
|
194
|
-
*
|
|
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.
|
|
200
|
-
const base = monthToken ? utc(y, m - 1 + (monthToken
|
|
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 (
|
|
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 === '
|
|
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 (
|
|
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 (
|
|
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
|
+
}
|