@aiquants/daily-report 0.7.0 → 0.8.0
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/README.md +13 -6
- package/dist/client.d.mts +363 -21
- package/dist/client.d.ts +363 -21
- package/dist/client.js +5 -4
- package/dist/client.js.map +1 -1
- package/dist/client.mjs +5 -4
- package/dist/client.mjs.map +1 -1
- package/dist/index.d.mts +76 -2
- package/dist/index.d.ts +76 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/server.d.mts +2 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +8 -6
- package/dist/server.js.map +1 -1
- package/dist/server.mjs +7 -5
- package/dist/server.mjs.map +1 -1
- package/dist/styles/daily-report.standalone.css +1 -1
- package/package.json +3 -3
- package/src/client/components/daily-report-ids-stream-status.spec.tsx +93 -0
- package/src/client/components/daily-report-ids-stream-status.tsx +67 -0
- package/src/client/components/daily-report-page.spec.tsx +119 -0
- package/src/client/components/daily-report-page.tsx +56 -68
- package/src/client/components/daily-report-resolved-content.tsx +31 -7
- package/src/client/contexts/daily-report-action-context.spec.tsx +262 -0
- package/src/client/contexts/daily-report-action-context.tsx +149 -52
- package/src/client/hooks/use-animated-number.spec.ts +103 -0
- package/src/client/hooks/use-animated-number.ts +69 -0
- package/src/client/route-helpers.spec.ts +100 -0
- package/src/client/route-helpers.ts +20 -41
- package/src/client/streaming/daily-report-ids-stream-client.spec.ts +641 -0
- package/src/client/streaming/daily-report-ids-stream-client.ts +599 -0
- package/src/client/streaming/daily-report-ids-stream-session.spec.ts +360 -0
- package/src/client/streaming/daily-report-ids-stream-session.ts +280 -0
- package/src/client.ts +4 -0
- package/src/index.ts +1 -0
- package/src/server/handlers.ids-stream.spec.ts +362 -0
- package/src/server/handlers.ts +245 -5
- package/src/server/service.ts +40 -0
- package/src/server/test-helpers/handlers-config.ts +40 -1
- package/src/shared/ids-stream.ts +92 -0
package/src/server/service.ts
CHANGED
|
@@ -419,6 +419,45 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
419
419
|
}))
|
|
420
420
|
}
|
|
421
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Fetches only the first page of report ids for time-to-first-render (uncached).
|
|
424
|
+
* 最初の描画までの時間専用に、ID 一覧の先頭ページだけを取得する内部処理 (キャッシュ無し)。
|
|
425
|
+
*
|
|
426
|
+
* `ids-stream` の新規接続でキャッシュミスした場合、全量クエリの確定を待たずに
|
|
427
|
+
* この軽量クエリ (TOP n・インデックス順で n 行走査した時点で打ち切り) の結果を
|
|
428
|
+
* 最初のチャンクとして送出する。可視性述語と並び順は全量クエリと完全同一。
|
|
429
|
+
* `.top(n)` は `.select()` の直後でなければならない (drizzle-mssql の構造的制約)。
|
|
430
|
+
*
|
|
431
|
+
* @param externalId External user id. 外部ユーザー ID。
|
|
432
|
+
* @param limit Page size. ページサイズ。
|
|
433
|
+
* @returns First-page items in stream order. ストリーム順の先頭ページ。
|
|
434
|
+
*/
|
|
435
|
+
const getDailyReportIdsFirstPageByExternalId = async (externalId: string, limit: number): Promise<DailyReportItem[]> => {
|
|
436
|
+
const userId = await getUserIdByExternalId(externalId)
|
|
437
|
+
if (!userId) {
|
|
438
|
+
return []
|
|
439
|
+
}
|
|
440
|
+
const DraftLabelRelation = aliasedTable(DailyReportHub_Label as unknown as Parameters<typeof aliasedTable>[0], "draft_label") as unknown as typeof DailyReportHub_Label
|
|
441
|
+
const draftLabelJoinIds = await getDraftLabelJoinIds()
|
|
442
|
+
|
|
443
|
+
const result = await db
|
|
444
|
+
.select<{ reportHubId: number; businessDate: Date | string | null; sourceType: string }>({
|
|
445
|
+
reportHubId: DailyReportHub.id,
|
|
446
|
+
businessDate: DailyReportHub.businessDate,
|
|
447
|
+
sourceType: DailyReportHub.sourceType,
|
|
448
|
+
})
|
|
449
|
+
.top(limit)
|
|
450
|
+
.from(DailyReportHub)
|
|
451
|
+
.leftJoin(DraftLabelRelation, and(eq(DraftLabelRelation.hubId, DailyReportHub.id), inArray(DraftLabelRelation.labelId, draftLabelJoinIds)))
|
|
452
|
+
.where(and(isNull(DailyReportHub.deletedAt), or(eq(DailyReportHub.userId, userId), isNull(DailyReportHub.userId), isNull(DraftLabelRelation.hubId))))
|
|
453
|
+
.orderBy(desc(DailyReportHub.businessDate), desc(DailyReportHub.id))
|
|
454
|
+
|
|
455
|
+
return result.map((item) => ({
|
|
456
|
+
...item,
|
|
457
|
+
businessDate: formatDateValue(item.businessDate, "YYYY-MM-DD"),
|
|
458
|
+
}))
|
|
459
|
+
}
|
|
460
|
+
|
|
422
461
|
/**
|
|
423
462
|
* Fetches daily report details for the provided business date.
|
|
424
463
|
* 指定した営業日の日報詳細を取得する内部処理。
|
|
@@ -1600,6 +1639,7 @@ export function createDailyReportService(config: DailyReportServiceConfig) {
|
|
|
1600
1639
|
getUserIdByExternalId,
|
|
1601
1640
|
// 参照系
|
|
1602
1641
|
getDailyReportIdsByExternalId,
|
|
1642
|
+
getDailyReportIdsFirstPageByExternalId,
|
|
1603
1643
|
getDailyReportsByBusinessDate,
|
|
1604
1644
|
getDailyReportsByBusinessDateByExternalId,
|
|
1605
1645
|
getDailyReportDetailById,
|
|
@@ -51,6 +51,14 @@ export type HandlersFixtureOptions = {
|
|
|
51
51
|
attachmentRateLimitPerMinute?: number
|
|
52
52
|
/** 同時実行上限。 */
|
|
53
53
|
attachmentConcurrency?: number
|
|
54
|
+
/** ids ストリームが返すアイテム一覧 (ストリーム順で渡す)。 */
|
|
55
|
+
idsStreamItems?: Array<{ reportHubId: number; businessDate: string | null; sourceType?: string }>
|
|
56
|
+
/** ids 取得 (全量・先頭ページの両方) を失敗させるエラー。 */
|
|
57
|
+
idsStreamError?: Error
|
|
58
|
+
/** 全量クエリの解決を保留にする Promise (高速先頭ページ経路の検証用)。 */
|
|
59
|
+
idsStreamFullGate?: Promise<void>
|
|
60
|
+
/** 先頭ページの結果を上書きする (既定は idsStreamItems の先頭スライス)。 */
|
|
61
|
+
idsStreamFirstPageItems?: Array<{ reportHubId: number; businessDate: string | null; sourceType?: string }>
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
/** 呼び出し記録。テストが副作用を検証するために使う。 */
|
|
@@ -60,6 +68,10 @@ export type HandlersFixtureCalls = {
|
|
|
60
68
|
readAttachment: Array<{ filePath: string; maxBytes: number; head: boolean; principal?: string }>
|
|
61
69
|
markAttachmentMissing: number[]
|
|
62
70
|
markAttachmentPresent: number[]
|
|
71
|
+
/** `service.getDailyReportIdsByExternalId` が受け取った `(externalId, forceRefresh)`。 */
|
|
72
|
+
getDailyReportIds: Array<[string, boolean]>
|
|
73
|
+
/** `service.getDailyReportIdsFirstPageByExternalId` が受け取った `(externalId, limit)`。 */
|
|
74
|
+
getDailyReportIdsFirstPage: Array<[string, number]>
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
/**
|
|
@@ -70,7 +82,7 @@ export type HandlersFixtureCalls = {
|
|
|
70
82
|
* @returns The config and its call log. 設定と呼び出し記録。
|
|
71
83
|
*/
|
|
72
84
|
export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { config: DailyReportHandlersConfig; calls: HandlersFixtureCalls } => {
|
|
73
|
-
const calls: HandlersFixtureCalls = { getAttachmentForUser: [], readAttachment: [], markAttachmentMissing: [], markAttachmentPresent: [] }
|
|
85
|
+
const calls: HandlersFixtureCalls = { getAttachmentForUser: [], readAttachment: [], markAttachmentMissing: [], markAttachmentPresent: [], getDailyReportIds: [], getDailyReportIdsFirstPage: [] }
|
|
74
86
|
|
|
75
87
|
const user = options.user === undefined ? { id: TEST_EXTERNAL_USER_ID } : options.user
|
|
76
88
|
const internalUserId = options.internalUserId === undefined ? TEST_INTERNAL_USER_ID : options.internalUserId
|
|
@@ -79,6 +91,17 @@ export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { con
|
|
|
79
91
|
|
|
80
92
|
const service = {
|
|
81
93
|
getUserIdByExternalId: async () => internalUserId,
|
|
94
|
+
getDailyReportIdsByExternalId: async (externalId: string, opts?: { forceRefresh?: boolean }) => {
|
|
95
|
+
calls.getDailyReportIds.push([externalId, opts?.forceRefresh ?? false])
|
|
96
|
+
if (options.idsStreamError) throw options.idsStreamError
|
|
97
|
+
if (options.idsStreamFullGate) await options.idsStreamFullGate
|
|
98
|
+
return options.idsStreamItems ?? []
|
|
99
|
+
},
|
|
100
|
+
getDailyReportIdsFirstPageByExternalId: async (externalId: string, limit: number) => {
|
|
101
|
+
calls.getDailyReportIdsFirstPage.push([externalId, limit])
|
|
102
|
+
if (options.idsStreamError) throw options.idsStreamError
|
|
103
|
+
return options.idsStreamFirstPageItems ?? (options.idsStreamItems ?? []).slice(0, limit)
|
|
104
|
+
},
|
|
82
105
|
getAttachmentForUser: async (attachmentId: number, viewerUserId: number) => {
|
|
83
106
|
// 引数を記録する。記録しないと「閲覧者 ID を渡し忘れた」欠陥がテストに映らない
|
|
84
107
|
calls.getAttachmentForUser.push([attachmentId, viewerUserId])
|
|
@@ -133,6 +156,22 @@ export const makeHandlersFixture = (options: HandlersFixtureOptions = {}): { con
|
|
|
133
156
|
* @param init Optional method / query / headers. メソッド・クエリ・ヘッダーの任意指定。
|
|
134
157
|
* @returns Loader args. loader 引数。
|
|
135
158
|
*/
|
|
159
|
+
/**
|
|
160
|
+
* Builds loader args for the `:endpoint` API router.
|
|
161
|
+
* `:endpoint` API ルーター用の loader 引数を組み立てる処理。
|
|
162
|
+
*
|
|
163
|
+
* @param endpoint Endpoint segment (e.g. "ids-stream"). エンドポイントセグメント。
|
|
164
|
+
* @param init Optional method / query / signal. メソッド・クエリ・中断シグナルの任意指定。
|
|
165
|
+
* @returns Loader args. loader 引数。
|
|
166
|
+
*/
|
|
167
|
+
export const makeApiArgs = (endpoint: string, init: { method?: string; query?: string; signal?: AbortSignal } = {}) => ({
|
|
168
|
+
request: new Request(`http://localhost/daily_report/api/${endpoint}${init.query ?? ""}`, {
|
|
169
|
+
method: init.method ?? "GET",
|
|
170
|
+
signal: init.signal ?? undefined,
|
|
171
|
+
}),
|
|
172
|
+
params: { endpoint } as Record<string, string | undefined>,
|
|
173
|
+
})
|
|
174
|
+
|
|
136
175
|
export const makeAttachmentArgs = (token: string, init: { method?: string; query?: string; headers?: Record<string, string> } = {}) => ({
|
|
137
176
|
request: new Request(`http://localhost/daily_report/api/attachment/${encodeURIComponent(token)}${init.query ?? ""}`, {
|
|
138
177
|
method: init.method ?? "GET",
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire contract and ordering helpers for the daily-report ids NDJSON stream.
|
|
3
|
+
* 日報 ID 一覧 NDJSON ストリームのワイヤ契約と並び順ヘルパー。
|
|
4
|
+
*
|
|
5
|
+
* サーバーは `GET {apiBasePath}/ids-stream` で 1 行 1 JSON の NDJSON を返す。
|
|
6
|
+
* 行は原則 {@link DailyReportIdsStreamChunkLine} で、送出中に予期しない失敗が
|
|
7
|
+
* 起きた場合のみ {@link DailyReportIdsStreamErrorLine} を 1 行流して正常クローズする
|
|
8
|
+
* (クライアントは「isLast 無しで終端」を再開シグナルとして扱う)。
|
|
9
|
+
*
|
|
10
|
+
* 並び順は SQL の `ORDER BY business_date DESC, id DESC` (MSSQL の DESC は NULL を
|
|
11
|
+
* 末尾に置く) と完全一致させる。カーソル再開はこの全順序に対する「厳密に後」の
|
|
12
|
+
* 位置からのスライスとして定義する。
|
|
13
|
+
*/
|
|
14
|
+
import type { DailyReportItem } from "./types"
|
|
15
|
+
|
|
16
|
+
/** ストリーム再開カーソル。直前チャンク末尾アイテムの並び順キー。 */
|
|
17
|
+
export type DailyReportIdsStreamCursor = {
|
|
18
|
+
/** 営業日 (YYYY-MM-DD)。null は「営業日なし領域」(並び順の末尾) を表す。 */
|
|
19
|
+
businessDate: string | null
|
|
20
|
+
/** 日報 Hub ID。 */
|
|
21
|
+
reportHubId: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** データを運ぶ NDJSON 行。 */
|
|
25
|
+
export type DailyReportIdsStreamChunkLine = {
|
|
26
|
+
/** このチャンクのアイテム群 (並び順どおり)。 */
|
|
27
|
+
items: DailyReportItem[]
|
|
28
|
+
/** 次チャンクの再開カーソル。最終チャンクでは null。 */
|
|
29
|
+
nextCursor: DailyReportIdsStreamCursor | null
|
|
30
|
+
/** これが最終チャンクかどうか。 */
|
|
31
|
+
isLast: boolean
|
|
32
|
+
/** 全体件数。接続ごとの先頭行にのみ載る。 */
|
|
33
|
+
total?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** サーバー側の予期しない失敗を伝える NDJSON 行 (内部情報は含めない)。 */
|
|
37
|
+
export type DailyReportIdsStreamErrorLine = {
|
|
38
|
+
error: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** NDJSON 1 行のユニオン。 */
|
|
42
|
+
export type DailyReportIdsStreamLine = DailyReportIdsStreamChunkLine | DailyReportIdsStreamErrorLine
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Type guard for chunk lines (anything else is treated as an error line).
|
|
46
|
+
* チャンク行の型ガード (それ以外はエラー行として扱う)。
|
|
47
|
+
*
|
|
48
|
+
* @param line Parsed NDJSON line. パース済みの NDJSON 行。
|
|
49
|
+
* @returns True when the line carries items. アイテムを運ぶ行なら true。
|
|
50
|
+
*/
|
|
51
|
+
export const isIdsStreamChunkLine = (line: unknown): line is DailyReportIdsStreamChunkLine => {
|
|
52
|
+
return typeof line === "object" && line !== null && Array.isArray((line as { items?: unknown }).items)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Total order of the ids stream: businessDate DESC (NULL last), then id DESC.
|
|
57
|
+
* ids ストリームの全順序。営業日降順 (NULL は末尾)、同日内は ID 降順。
|
|
58
|
+
*
|
|
59
|
+
* MSSQL の `ORDER BY business_date DESC, id DESC` と一致させることが唯一の存在理由。
|
|
60
|
+
* 営業日は正規化済みの `YYYY-MM-DD` 文字列なので辞書順比較 = 時系列比較になる。
|
|
61
|
+
*
|
|
62
|
+
* @param a Left key. 左辺キー。
|
|
63
|
+
* @param b Right key. 右辺キー。
|
|
64
|
+
* @returns Negative when `a` streams before `b`. `a` が先に流れるなら負値。
|
|
65
|
+
*/
|
|
66
|
+
export const compareIdsStreamOrder = (a: DailyReportIdsStreamCursor, b: DailyReportIdsStreamCursor): number => {
|
|
67
|
+
if (a.businessDate !== b.businessDate) {
|
|
68
|
+
// null 営業日は並び順の末尾 (MSSQL DESC の NULL 末尾挙動へ揃える)
|
|
69
|
+
if (a.businessDate === null) return 1
|
|
70
|
+
if (b.businessDate === null) return -1
|
|
71
|
+
return a.businessDate > b.businessDate ? -1 : 1
|
|
72
|
+
}
|
|
73
|
+
return b.reportHubId - a.reportHubId
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Finds the first index that streams strictly after the cursor.
|
|
78
|
+
* カーソルより厳密に後ろへ並ぶ最初のインデックスを求める処理。
|
|
79
|
+
*
|
|
80
|
+
* 配列は必ずストリーム順 (SQL の ORDER BY) で渡されるが、比較器とのズレが
|
|
81
|
+
* あっても壊れない線形走査で決定する (件数は高々数万・再開時のみの実行)。
|
|
82
|
+
* 該当なし (カーソルが末尾以降) は `items.length` を返し、空チャンク +
|
|
83
|
+
* `isLast: true` の正常終端に落ちる。
|
|
84
|
+
*
|
|
85
|
+
* @param items Stream-ordered items. ストリーム順のアイテム配列。
|
|
86
|
+
* @param cursor Resume cursor. 再開カーソル。
|
|
87
|
+
* @returns Index to resume emission from. 送出を再開するインデックス。
|
|
88
|
+
*/
|
|
89
|
+
export const findIdsStreamResumeIndex = (items: readonly DailyReportItem[], cursor: DailyReportIdsStreamCursor): number => {
|
|
90
|
+
const index = items.findIndex((item) => compareIdsStreamOrder({ businessDate: item.businessDate, reportHubId: item.reportHubId }, cursor) > 0)
|
|
91
|
+
return index === -1 ? items.length : index
|
|
92
|
+
}
|