@allbluecn/web-app 0.5.0 → 0.6.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/package.json +239 -30
- package/src/components/iteration/auto-schedule-dialog.tsx +150 -0
- package/src/components/iteration/burndown-chart.tsx +63 -0
- package/src/components/iteration/constants.ts +28 -0
- package/src/components/iteration/create-iteration-dialog.tsx +140 -0
- package/src/components/iteration/iteration-card.tsx +60 -0
- package/src/components/iteration/iteration-editor.tsx +116 -0
- package/src/components/iteration/iteration-progress-bar.tsx +44 -0
- package/src/components/iteration/iteration-status-badge.tsx +33 -0
- package/src/components/knowledge/dialog/knowledge-form-dialog.tsx +21 -30
- package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
- package/src/components/layout/sidebar-03/__tests__/nav-workspace.test.tsx +2 -2
- package/src/components/layout/sidebar-03/nav-workspace.tsx +2 -2
- package/src/components/project/create-project-dialog.tsx +44 -57
- package/src/components/shared/__tests__/lucide-curated-icons.test.ts +83 -0
- package/src/components/shared/__tests__/lucide-icon-data.test.ts +131 -0
- package/src/components/shared/__tests__/lucide-icon-picker.test.tsx +197 -0
- package/src/components/shared/lucide-icon-picker/curated-icons.ts +478 -0
- package/src/components/shared/lucide-icon-picker/icon-data.ts +109 -0
- package/src/components/shared/lucide-icon-picker/index.tsx +82 -0
- package/src/components/shared/lucide-icon-picker/lucide-icon-by-name.tsx +78 -0
- package/src/components/shared/lucide-icon-picker/panel.tsx +163 -0
- package/src/components/story/__tests__/constants.test.ts +255 -0
- package/src/components/story/ai/story-ai-panel.tsx +23 -15
- package/src/components/story/ai/story-ai-preview-list.tsx +19 -0
- package/src/components/story/constants.ts +43 -1
- package/src/components/story/create-story-dialog.tsx +42 -16
- package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
- package/src/components/story/detail/__tests__/categories.test.tsx +62 -0
- package/src/components/story/detail/__tests__/dependencies-button.test.tsx +49 -0
- package/src/components/story/detail/activity-timeline.tsx +26 -4
- package/src/components/story/detail/story-detail-body.tsx +90 -38
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +857 -0
- package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
- package/src/components/story/detail/story-detail-toolbar/children.tsx +9 -4
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +95 -27
- package/src/components/story/detail/story-detail-toolbar.tsx +159 -8
- package/src/components/story/detail/story-properties.tsx +21 -4
- package/src/components/story/display/__tests__/group-fields.test.ts +107 -0
- package/src/components/story/display/group-resolver.tsx +2 -2
- package/src/components/story/inline-editors/__tests__/dependency-type-select.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/labels-editor.test.tsx +155 -0
- package/src/components/story/inline-editors/__tests__/priority-editor.test.tsx +173 -0
- package/src/components/story/inline-editors/__tests__/state-editor.test.tsx +174 -0
- package/src/components/story/inline-editors/dependency-type-select.tsx +180 -0
- package/src/components/story/inline-editors/index.ts +2 -1
- package/src/components/story/inline-editors/labels-editor.tsx +159 -3
- package/src/components/story/inline-editors/story-select-dialog.tsx +41 -63
- package/src/components/story/peek-panel.tsx +121 -21
- package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
- package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
- package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
- package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
- package/src/components/story/relations/file-preview.tsx +173 -0
- package/src/components/story/relations/format.ts +120 -0
- package/src/components/story/relations/general-groups.ts +32 -0
- package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
- package/src/components/story/relations/link-manage-dialog.tsx +192 -0
- package/src/components/story/relations/story-ref-row.tsx +17 -27
- package/src/components/story/story-panel-header.tsx +25 -19
- package/src/components/story/story-toolbar.tsx +3 -3
- package/src/components/story/transfer-story-popover.tsx +2 -2
- package/src/components/story/types.ts +14 -0
- package/src/components/story/views/create-view-dialog.tsx +3 -3
- package/src/components/story/views/story-list/__tests__/group-header.test.tsx +219 -0
- package/src/components/story/views/story-list/group.tsx +1 -1
- package/src/components/story/views/story-list/inline-story-creator.tsx +2 -2
- package/src/components/story/views/story-list/row.tsx +1 -1
- package/src/components/workspace/create-workspace-dialog.tsx +41 -49
- package/src/lib/__tests__/project-display-id.test.ts +8 -5
- package/src/lib/changelog/sdk-versions.ts +20 -20
- package/src/lib/iteration/__tests__/status.test.ts +41 -0
- package/src/lib/iteration/status.ts +28 -0
- package/src/lib/project-display-id.ts +14 -5
- package/src/lib/project-module-meta.ts +77 -0
- package/src/lib/story/__tests__/video-embed.test.ts +77 -0
- package/src/lib/story/video-embed.ts +59 -0
- package/src/lib/utils.d.ts +1 -1
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +8 -0
- package/src/providers/index.tsx +6 -0
- package/src/routeTree.gen.ts +96 -0
- package/src/routes/__root.tsx +0 -8
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/projects/$projectId/$storyId/route.lazy.tsx +52 -25
- package/src/routes/_nav/projects/$projectId/$storyId/route.tsx +6 -3
- package/src/routes/_nav/projects/$projectId/index.lazy.tsx +32 -15
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.lazy.tsx +289 -0
- package/src/routes/_nav/projects/$projectId/iterations/$iterationId/route.tsx +27 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.lazy.tsx +111 -0
- package/src/routes/_nav/projects/$projectId/iterations/index.tsx +24 -0
- package/src/routes/_nav/projects/$projectId/route.tsx +4 -2
- package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +78 -7
- package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
- package/src/routes/_nav/stories/$storyId/route.lazy.tsx +5 -1
- package/src/routes/_nav/stories/$storyId/route.tsx +4 -0
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/_nav/workspaces/$workspaceId/route.lazy.tsx +3 -3
- package/src/routes/_nav/workspaces/$workspaceId/settings/route.lazy.tsx +4 -4
- package/src/routes/_nav/workspaces/index.lazy.tsx +3 -3
- package/src/routes/api/attachments/$id/content.ts +60 -0
- package/src/routes/api/attachments/upload.ts +148 -0
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
- package/src/server/serverFns/iteration/__tests__/auto-schedule-core.test.ts +62 -0
- package/src/server/serverFns/iteration/__tests__/burndown-core.test.ts +90 -0
- package/src/server/serverFns/iteration/__tests__/iteration-crud.test.ts +211 -0
- package/src/server/serverFns/iteration/__tests__/iteration-stories.test.ts +155 -0
- package/src/server/serverFns/iteration/__tests__/iteration-transfer.test.ts +136 -0
- package/src/server/serverFns/iteration/auto-schedule-core.ts +54 -0
- package/src/server/serverFns/iteration/auto-schedule-internal.ts +88 -0
- package/src/server/serverFns/iteration/burndown-core.ts +60 -0
- package/src/server/serverFns/iteration/index.ts +22 -0
- package/src/server/serverFns/iteration/iteration-auto-schedule.ts +66 -0
- package/src/server/serverFns/iteration/iteration-burndown.ts +70 -0
- package/src/server/serverFns/iteration/iteration-crud.ts +208 -0
- package/src/server/serverFns/iteration/iteration-stories.ts +111 -0
- package/src/server/serverFns/iteration/iteration-transfer.ts +42 -0
- package/src/server/serverFns/iteration/schemas.ts +58 -0
- package/src/server/serverFns/iteration/transfer-internal.ts +62 -0
- package/src/server/serverFns/knowledge.ts +50 -4
- package/src/server/serverFns/project/module-overview.ts +109 -0
- package/src/server/serverFns/project.ts +45 -2
- package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
- package/src/server/serverFns/story/__tests__/label-suggest-fn.test.ts +100 -0
- package/src/server/serverFns/story/__tests__/labels.test.ts +67 -0
- package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
- package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +132 -2
- package/src/server/serverFns/story/index.ts +9 -4
- package/src/server/serverFns/story/kb-general.ts +39 -0
- package/src/server/serverFns/story/kb-scope.ts +52 -0
- package/src/server/serverFns/story/link-preview.ts +115 -0
- package/src/server/serverFns/story/story-assoc.ts +372 -51
- package/src/server/serverFns/story/story-attachments.ts +32 -45
- package/src/{components/project/project-icon.tsx → server/serverFns/story/story-crud/label-palette.ts} +16 -19
- package/src/server/serverFns/story/story-crud/label-suggest-core.ts +88 -0
- package/src/server/serverFns/story/story-crud/label-suggest.ts +38 -0
- package/src/{components/knowledge/knowledge-icon.tsx → server/serverFns/story/story-crud/label-upsert.ts} +13 -19
- package/src/server/serverFns/story/story-crud/labels.ts +3 -5
- package/src/server/serverFns/story/story-crud.ts +53 -6
- package/src/server/serverFns/story/story-dependencies.ts +158 -5
- package/src/server/serverFns/story/story-links.ts +43 -7
- package/src/server/serverFns/story/story-transfer.ts +5 -4
- package/src/server/serverFns/story/story-tree.ts +28 -1
- package/src/server/serverFns/story/types.ts +38 -0
- package/src/styles/globals.css +25 -0
- package/src/types/project.ts +5 -5
- package/vite.shared.ts +2 -0
- package/src/components/project/project-animated-icon.tsx +0 -73
- package/src/components/shared/icon-packs/index.ts +0 -1
- package/src/components/shared/icon-packs/knowledge.ts +0 -1
- package/src/components/shared/icon-packs/prd.ts +0 -1
- package/src/components/shared/icon-packs/project.ts +0 -1
- package/src/components/shared/icon-picker.tsx +0 -1
- package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
- package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
- package/src/components/story/relations/attachments-panel.tsx +0 -179
- package/src/components/story/relations/knowledge-panel.tsx +0 -284
- package/src/components/story/relations/links-panel.tsx +0 -240
- package/src/lib/project-mock.ts +0 -156
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
19
|
+
|
|
20
|
+
vi.mock("@tanstack/react-start", () => ({
|
|
21
|
+
createServerFn: (config: any) => {
|
|
22
|
+
const api: any = {
|
|
23
|
+
_validator: undefined,
|
|
24
|
+
validator(schema: any) {
|
|
25
|
+
this._validator = schema;
|
|
26
|
+
return this;
|
|
27
|
+
},
|
|
28
|
+
handler(fn: Function) {
|
|
29
|
+
const wrapped = async (args: any) => {
|
|
30
|
+
let data = args?.data;
|
|
31
|
+
if (this._validator && data !== undefined) {
|
|
32
|
+
data = this._validator.parse ? this._validator.parse(data) : data;
|
|
33
|
+
}
|
|
34
|
+
return fn({ data });
|
|
35
|
+
};
|
|
36
|
+
return wrapped;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
return api;
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
vi.mock("@tanstack/react-start/server", () => ({
|
|
44
|
+
getRequest: () => ({}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const mocks = vi.hoisted(() => ({
|
|
48
|
+
findUnique: vi.fn(),
|
|
49
|
+
update: vi.fn(),
|
|
50
|
+
updateMany: vi.fn(),
|
|
51
|
+
create: vi.fn(),
|
|
52
|
+
createMany: vi.fn(),
|
|
53
|
+
currentSession: vi.fn(),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
vi.mock("@allbluecn/database", () => ({
|
|
57
|
+
prisma: {
|
|
58
|
+
iteration: { findUnique: mocks.findUnique },
|
|
59
|
+
story: { findUnique: mocks.findUnique, update: mocks.update, updateMany: mocks.updateMany },
|
|
60
|
+
storyActivity: { create: mocks.create, createMany: mocks.createMany },
|
|
61
|
+
},
|
|
62
|
+
Prisma: {},
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
vi.mock("@/server/auth-helpers", () => ({
|
|
66
|
+
requireAuth: mocks.currentSession,
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
import { setStoryIterationFn, addStoriesToIterationFn, removeStoryFromIterationFn } from "../iteration-stories";
|
|
70
|
+
|
|
71
|
+
describe("setStoryIterationFn", () => {
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
vi.clearAllMocks();
|
|
74
|
+
mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("目标是 completed 迭代时拒绝", async () => {
|
|
78
|
+
mocks.findUnique.mockResolvedValue({
|
|
79
|
+
startDate: new Date("2026-08-01"),
|
|
80
|
+
endDate: new Date("2026-08-14"),
|
|
81
|
+
archived: false,
|
|
82
|
+
name: "已完成",
|
|
83
|
+
});
|
|
84
|
+
await expect(
|
|
85
|
+
setStoryIterationFn({ data: { storyId: "s1", iterationId: "i1" } }),
|
|
86
|
+
).rejects.toMatchObject({ message: expect.stringContaining("已完成") });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("合法目标更新 story 并记录活动", async () => {
|
|
90
|
+
mocks.findUnique.mockImplementation(async (args: any) => {
|
|
91
|
+
if (args.where?.id === "i1") {
|
|
92
|
+
return { startDate: new Date("2026-09-01"), endDate: new Date("2026-09-14"), archived: false, name: "Sprint 1" };
|
|
93
|
+
}
|
|
94
|
+
if (args.where?.id === "s1") {
|
|
95
|
+
return { iterationId: null, iteration: null };
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
});
|
|
99
|
+
mocks.update.mockResolvedValue({ id: "s1" });
|
|
100
|
+
mocks.create.mockResolvedValue({ id: "a1" });
|
|
101
|
+
const r = await setStoryIterationFn({ data: { storyId: "s1", iterationId: "i1" } });
|
|
102
|
+
expect(r.ok).toBe(true);
|
|
103
|
+
expect(mocks.update).toHaveBeenCalledWith(expect.objectContaining({ data: { iterationId: "i1" } }));
|
|
104
|
+
const activity = mocks.create.mock.calls[0][0].data;
|
|
105
|
+
expect(activity.action).toBe("iteration_changed");
|
|
106
|
+
expect(activity.field).toBe("iterationId");
|
|
107
|
+
expect(activity.newValue).toBe("Sprint 1");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("addStoriesToIterationFn", () => {
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
vi.clearAllMocks();
|
|
114
|
+
mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("批量更新并记录活动", async () => {
|
|
118
|
+
mocks.findUnique.mockResolvedValue({
|
|
119
|
+
startDate: new Date("2026-09-01"), endDate: new Date("2026-09-14"), archived: false, name: "Sprint 1",
|
|
120
|
+
});
|
|
121
|
+
mocks.updateMany.mockResolvedValue({ count: 2 });
|
|
122
|
+
mocks.createMany.mockResolvedValue(undefined);
|
|
123
|
+
const r = await addStoriesToIterationFn({ data: { iterationId: "i1", storyIds: ["s1", "s2"] } });
|
|
124
|
+
expect(r.count).toBe(2);
|
|
125
|
+
expect(mocks.createMany).toHaveBeenCalledWith(expect.objectContaining({
|
|
126
|
+
data: expect.arrayContaining([
|
|
127
|
+
expect.objectContaining({ storyId: "s1", action: "iteration_changed" }),
|
|
128
|
+
expect.objectContaining({ storyId: "s2", action: "iteration_changed" }),
|
|
129
|
+
]),
|
|
130
|
+
}));
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("removeStoryFromIterationFn", () => {
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
vi.clearAllMocks();
|
|
137
|
+
mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("移出迭代并记录活动", async () => {
|
|
141
|
+
mocks.findUnique.mockResolvedValue({
|
|
142
|
+
iterationId: "i1",
|
|
143
|
+
iteration: { name: "Sprint 1" },
|
|
144
|
+
});
|
|
145
|
+
mocks.updateMany.mockResolvedValue({ count: 1 });
|
|
146
|
+
mocks.create.mockResolvedValue({ id: "a1" });
|
|
147
|
+
const r = await removeStoryFromIterationFn({ data: { storyId: "s1" } });
|
|
148
|
+
expect(r.ok).toBe(true);
|
|
149
|
+
const activity = mocks.create.mock.calls[0][0].data;
|
|
150
|
+
expect(activity.action).toBe("iteration_changed");
|
|
151
|
+
expect(activity.field).toBe("iterationId");
|
|
152
|
+
expect(activity.oldValue).toBe("Sprint 1");
|
|
153
|
+
expect(activity.newValue).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
19
|
+
|
|
20
|
+
vi.mock("@tanstack/react-start", () => ({
|
|
21
|
+
createServerFn: (config: any) => {
|
|
22
|
+
const api: any = {
|
|
23
|
+
_validator: undefined,
|
|
24
|
+
validator(schema: any) {
|
|
25
|
+
this._validator = schema;
|
|
26
|
+
return this;
|
|
27
|
+
},
|
|
28
|
+
handler(fn: Function) {
|
|
29
|
+
const wrapped = async (args: any) => {
|
|
30
|
+
let data = args?.data;
|
|
31
|
+
if (this._validator && data !== undefined) {
|
|
32
|
+
data = this._validator.parse ? this._validator.parse(data) : data;
|
|
33
|
+
}
|
|
34
|
+
return fn({ data });
|
|
35
|
+
};
|
|
36
|
+
return wrapped;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
return api;
|
|
40
|
+
},
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
vi.mock("@tanstack/react-start/server", () => ({
|
|
44
|
+
getRequest: () => ({}),
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
const mocks = vi.hoisted(() => ({
|
|
48
|
+
findMany: vi.fn(),
|
|
49
|
+
findUnique: vi.fn(),
|
|
50
|
+
updateMany: vi.fn(),
|
|
51
|
+
createMany: vi.fn(),
|
|
52
|
+
projectFindUnique: vi.fn(),
|
|
53
|
+
currentSession: vi.fn(),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
vi.mock("@allbluecn/database", () => ({
|
|
57
|
+
prisma: {
|
|
58
|
+
iteration: { findUnique: mocks.findUnique },
|
|
59
|
+
story: { findMany: mocks.findMany, updateMany: mocks.updateMany },
|
|
60
|
+
storyActivity: { createMany: mocks.createMany },
|
|
61
|
+
project: { findUnique: mocks.projectFindUnique },
|
|
62
|
+
},
|
|
63
|
+
Prisma: {},
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
vi.mock("@/server/auth-helpers", () => ({
|
|
67
|
+
requireAuth: mocks.currentSession,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
import { transferUnfinishedFn } from "../iteration-transfer";
|
|
71
|
+
|
|
72
|
+
describe("transferUnfinishedFn", () => {
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
vi.clearAllMocks();
|
|
75
|
+
mocks.currentSession.mockResolvedValue({ user: { id: "u1" } });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("目标是 completed 迭代时拒绝", async () => {
|
|
79
|
+
mocks.findUnique.mockResolvedValue({
|
|
80
|
+
startDate: new Date("2026-08-01"),
|
|
81
|
+
endDate: new Date("2026-08-14"),
|
|
82
|
+
archived: false,
|
|
83
|
+
name: "已完成迭代",
|
|
84
|
+
projectId: "p1",
|
|
85
|
+
});
|
|
86
|
+
await expect(
|
|
87
|
+
transferUnfinishedFn({ data: { iterationId: "src1", targetIterationId: "tgt1" } }),
|
|
88
|
+
).rejects.toMatchObject({ message: expect.stringContaining("已完成") });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("where 断言 state notIn 且 iterationId = 源迭代", async () => {
|
|
92
|
+
mocks.findUnique.mockResolvedValue({
|
|
93
|
+
startDate: new Date("2026-09-01"),
|
|
94
|
+
endDate: new Date("2026-09-14"),
|
|
95
|
+
archived: false,
|
|
96
|
+
name: "目标迭代",
|
|
97
|
+
projectId: "p1",
|
|
98
|
+
});
|
|
99
|
+
mocks.findMany.mockResolvedValue([{ id: "s1" }, { id: "s2" }]);
|
|
100
|
+
mocks.updateMany.mockResolvedValue({ count: 2 });
|
|
101
|
+
mocks.createMany.mockResolvedValue(undefined);
|
|
102
|
+
mocks.projectFindUnique.mockResolvedValue({ ownerId: "u1" });
|
|
103
|
+
const result = await transferUnfinishedFn({ data: { iterationId: "src1", targetIterationId: "tgt1" } });
|
|
104
|
+
expect(result).toEqual({ count: 2 });
|
|
105
|
+
const whereArg = mocks.findMany.mock.calls[0][0].where;
|
|
106
|
+
expect(whereArg.iterationId).toBe("src1");
|
|
107
|
+
expect(whereArg.state.notIn).toContain("done");
|
|
108
|
+
expect(whereArg.state.notIn).toContain("cancelled");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("归档目标拒绝", async () => {
|
|
112
|
+
mocks.findUnique.mockResolvedValue({
|
|
113
|
+
startDate: new Date("2026-09-01"),
|
|
114
|
+
endDate: new Date("2026-09-14"),
|
|
115
|
+
archived: true,
|
|
116
|
+
name: "归档迭代",
|
|
117
|
+
projectId: "p1",
|
|
118
|
+
});
|
|
119
|
+
await expect(
|
|
120
|
+
transferUnfinishedFn({ data: { iterationId: "src1", targetIterationId: "tgt1" } }),
|
|
121
|
+
).rejects.toMatchObject({ message: expect.stringContaining("迭代不存在") });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("二次调用(无未完成)返回 { count: 0 },不抛错", async () => {
|
|
125
|
+
mocks.findUnique.mockResolvedValue({
|
|
126
|
+
startDate: new Date("2026-09-01"),
|
|
127
|
+
endDate: new Date("2026-09-14"),
|
|
128
|
+
archived: false,
|
|
129
|
+
name: "目标迭代",
|
|
130
|
+
projectId: "p1",
|
|
131
|
+
});
|
|
132
|
+
mocks.findMany.mockResolvedValue([]);
|
|
133
|
+
const result = await transferUnfinishedFn({ data: { iterationId: "src1", targetIterationId: "tgt1" } });
|
|
134
|
+
expect(result).toEqual({ count: 0 });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
export interface AutoSchedulePlanConfig {
|
|
19
|
+
title: string; durationDays: number; cooldownDays: number;
|
|
20
|
+
startsOn: string; futureCount: number; lastSeq: number;
|
|
21
|
+
}
|
|
22
|
+
export interface ExistingIterationRange { startDate: string; endDate: string; archived: boolean }
|
|
23
|
+
export interface PlannedIteration { name: string; startDate: string; endDate: string; seq: number }
|
|
24
|
+
|
|
25
|
+
function addDays(d: string, n: number): string {
|
|
26
|
+
return new Date(Date.parse(d + "T00:00:00Z") + n * 86400000).toISOString().slice(0, 10);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 惰性补建计划(规格 §3.6):返回需补建的迭代 + 用于持久化的 nextSeq */
|
|
30
|
+
export function planMissingIterations(
|
|
31
|
+
config: AutoSchedulePlanConfig,
|
|
32
|
+
existing: ExistingIterationRange[],
|
|
33
|
+
today: string,
|
|
34
|
+
): { planned: PlannedIteration[]; nextSeq: number } {
|
|
35
|
+
const live = existing.filter((e) => !e.archived);
|
|
36
|
+
const upcomingCount = live.filter((e) => e.startDate > today).length;
|
|
37
|
+
const need = config.futureCount - upcomingCount;
|
|
38
|
+
if (need <= 0) return { planned: [], nextSeq: config.lastSeq };
|
|
39
|
+
const latestEnd = live.reduce((m, e) => (e.endDate > m ? e.endDate : m), config.startsOn);
|
|
40
|
+
let cursor = [latestEnd, config.startsOn, today].sort().at(-1)!; // anchor = max(最晚结束, startsOn, today)
|
|
41
|
+
cursor = addDays(cursor, config.cooldownDays);
|
|
42
|
+
const planned: PlannedIteration[] = [];
|
|
43
|
+
let seq = config.lastSeq;
|
|
44
|
+
while (planned.length < need) {
|
|
45
|
+
const s = cursor;
|
|
46
|
+
const e = addDays(s, config.durationDays - 1);
|
|
47
|
+
const overlap = live.find((x) => s <= x.endDate && x.startDate <= e);
|
|
48
|
+
if (overlap) { cursor = addDays(overlap.endDate, 1); continue; } // 顺延,不消耗序号
|
|
49
|
+
seq += 1;
|
|
50
|
+
planned.push({ name: `${config.title} ${seq}`, startDate: s, endDate: e, seq });
|
|
51
|
+
cursor = addDays(e, config.cooldownDays + 1);
|
|
52
|
+
}
|
|
53
|
+
return { planned, nextSeq: seq };
|
|
54
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { prisma } from "@allbluecn/database";
|
|
19
|
+
import { toDatePart } from "@/lib/iteration/status";
|
|
20
|
+
import { planMissingIterations } from "./auto-schedule-core";
|
|
21
|
+
import { transferStoriesInternal } from "./transfer-internal";
|
|
22
|
+
|
|
23
|
+
const CONFIG_SELECT = {
|
|
24
|
+
enabled: true, title: true, durationDays: true, cooldownDays: true,
|
|
25
|
+
startsOn: true, futureCount: true, autoRollover: true, lastSeq: true,
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
async function loadConfig(projectId: string) {
|
|
29
|
+
const [cfg] = await prisma.iterationAutoSchedule.findMany({
|
|
30
|
+
where: { projectId }, select: CONFIG_SELECT, take: 1,
|
|
31
|
+
});
|
|
32
|
+
return cfg ?? null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 惰性补建(listIterationsFn 调用;规格 §3.6)。幂等:先查 upcoming 数,够数即跳过 */
|
|
36
|
+
export async function ensureUpcomingIterations(projectId: string): Promise<void> {
|
|
37
|
+
const cfg = await loadConfig(projectId);
|
|
38
|
+
if (!cfg?.enabled) return;
|
|
39
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
40
|
+
const existing = await prisma.iteration.findMany({
|
|
41
|
+
where: { projectId },
|
|
42
|
+
select: { startDate: true, endDate: true, archived: true },
|
|
43
|
+
});
|
|
44
|
+
const { planned, nextSeq } = planMissingIterations(
|
|
45
|
+
{
|
|
46
|
+
title: cfg.title, durationDays: cfg.durationDays, cooldownDays: cfg.cooldownDays,
|
|
47
|
+
startsOn: toDatePart(cfg.startsOn), futureCount: cfg.futureCount, lastSeq: cfg.lastSeq,
|
|
48
|
+
},
|
|
49
|
+
existing.map((e) => ({ startDate: toDatePart(e.startDate), endDate: toDatePart(e.endDate), archived: e.archived })),
|
|
50
|
+
today,
|
|
51
|
+
);
|
|
52
|
+
if (planned.length === 0) return;
|
|
53
|
+
const owner = await prisma.project.findUnique({ where: { id: projectId }, select: { ownerId: true } });
|
|
54
|
+
await prisma.$transaction([
|
|
55
|
+
...planned.map((p) =>
|
|
56
|
+
prisma.iteration.create({
|
|
57
|
+
data: {
|
|
58
|
+
projectId, name: p.name,
|
|
59
|
+
startDate: new Date(p.startDate + "T00:00:00.000Z"),
|
|
60
|
+
endDate: new Date(p.endDate + "T00:00:00.000Z"),
|
|
61
|
+
createdById: owner!.ownerId!,
|
|
62
|
+
},
|
|
63
|
+
select: { id: true },
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
prisma.iterationAutoSchedule.update({ where: { projectId }, data: { lastSeq: nextSeq } }),
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** autoRollover:最近结束迭代 → 未完成需求转移到下一个 active/upcoming(规格 §3.6;无目标跳过) */
|
|
71
|
+
export async function ensureAutoRollover(projectId: string): Promise<void> {
|
|
72
|
+
const cfg = await loadConfig(projectId);
|
|
73
|
+
if (!cfg?.enabled || !cfg.autoRollover) return;
|
|
74
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
75
|
+
const [lastEnded] = await prisma.iteration.findMany({
|
|
76
|
+
where: { projectId, archived: false, endDate: { lt: new Date(today + "T00:00:00.000Z") } },
|
|
77
|
+
orderBy: { endDate: "desc" }, take: 1,
|
|
78
|
+
select: { id: true },
|
|
79
|
+
});
|
|
80
|
+
if (!lastEnded) return;
|
|
81
|
+
const [target] = await prisma.iteration.findMany({
|
|
82
|
+
where: { projectId, archived: false, startDate: { gte: today } },
|
|
83
|
+
orderBy: { startDate: "asc" }, take: 1,
|
|
84
|
+
select: { id: true },
|
|
85
|
+
});
|
|
86
|
+
if (!target) return; // 无可用目标:跳过,下次自检重试
|
|
87
|
+
await transferStoriesInternal(lastEnded.id, target.id);
|
|
88
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { formatInTimeZone } from "date-fns-tz";
|
|
19
|
+
|
|
20
|
+
export interface BurndownStory { id: string; addedAt: string }
|
|
21
|
+
export interface StateEvent { storyId: string; at: string; to: string }
|
|
22
|
+
export interface BurndownPoint { date: string; scope: number; pending: number; ideal: number }
|
|
23
|
+
export interface BurndownResult {
|
|
24
|
+
points: BurndownPoint[];
|
|
25
|
+
status: "leading" | "trailing" | "even";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function dayIn(tz: string, at: string | Date): string {
|
|
29
|
+
return formatInTimeZone(new Date(at), tz, "yyyy-MM-dd");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function addDay(d: string): string {
|
|
33
|
+
return new Date(Date.parse(d + "T00:00:00Z") + 86400000).toISOString().slice(0, 10);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 燃尽回放(规格 §3.4):按日全量重算,O(天×需求),迭代规模内可接受 */
|
|
37
|
+
export function computeBurndown(input: {
|
|
38
|
+
startDate: string; endDate: string; timezone: string;
|
|
39
|
+
stories: BurndownStory[]; stateEvents: StateEvent[]; now: Date;
|
|
40
|
+
}): BurndownResult {
|
|
41
|
+
const { startDate, endDate, timezone, stories, stateEvents } = input;
|
|
42
|
+
const today = dayIn(timezone, input.now);
|
|
43
|
+
const sortedEvents = [...stateEvents].sort((a, b) => a.at.localeCompare(b.at));
|
|
44
|
+
|
|
45
|
+
const points: BurndownPoint[] = [];
|
|
46
|
+
const totalDays = Math.round((Date.parse(endDate) - Date.parse(startDate)) / 86400000);
|
|
47
|
+
for (let cursor = startDate, i = 0; cursor <= endDate && cursor <= today; cursor = addDay(cursor), i++) {
|
|
48
|
+
const inScope = stories.filter((s) => dayIn(timezone, s.addedAt) <= cursor);
|
|
49
|
+
const scope = inScope.length;
|
|
50
|
+
const pending = inScope.filter((s) => {
|
|
51
|
+
const last = sortedEvents.filter((e) => e.storyId === s.id && dayIn(timezone, e.at) <= cursor).at(-1);
|
|
52
|
+
return !last || (last.to !== "done" && last.to !== "cancelled");
|
|
53
|
+
}).length;
|
|
54
|
+
const ideal = Math.max(0, Math.round((scope * (totalDays - i)) / Math.max(1, totalDays)));
|
|
55
|
+
points.push({ date: cursor, scope, pending, ideal });
|
|
56
|
+
}
|
|
57
|
+
const last = points.at(-1);
|
|
58
|
+
const status = !last ? "even" : last.pending < last.ideal ? "leading" : last.pending > last.ideal ? "trailing" : "even";
|
|
59
|
+
return { points, status };
|
|
60
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
export * from "./iteration-crud";
|
|
19
|
+
export * from "./iteration-stories";
|
|
20
|
+
export * from "./iteration-transfer";
|
|
21
|
+
export * from "./iteration-auto-schedule";
|
|
22
|
+
export * from "./iteration-burndown";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
19
|
+
import { getRequest } from "@tanstack/react-start/server";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { prisma } from "@allbluecn/database";
|
|
22
|
+
import { requireAuth } from "@/server/auth-helpers";
|
|
23
|
+
import { toDatePart } from "@/lib/iteration/status";
|
|
24
|
+
import { ensureUpcomingIterations } from "./auto-schedule-internal";
|
|
25
|
+
|
|
26
|
+
const CONFIG_SELECT = {
|
|
27
|
+
enabled: true, title: true, durationDays: true, cooldownDays: true,
|
|
28
|
+
startsOn: true, futureCount: true, autoRollover: true, lastSeq: true,
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export const getAutoScheduleFn = createServerFn({ method: "GET" })
|
|
32
|
+
.validator(z.object({ projectId: z.string() }))
|
|
33
|
+
.handler(async ({ data }) => {
|
|
34
|
+
const request = getRequest();
|
|
35
|
+
await requireAuth(request);
|
|
36
|
+
const [cfg] = await prisma.iterationAutoSchedule.findMany({
|
|
37
|
+
where: { projectId: data.projectId }, select: CONFIG_SELECT, take: 1,
|
|
38
|
+
});
|
|
39
|
+
return { config: cfg ? { ...cfg, startsOn: toDatePart(cfg.startsOn) } : null };
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const updateAutoScheduleFn = createServerFn({ method: "POST" })
|
|
43
|
+
.validator(
|
|
44
|
+
z.object({
|
|
45
|
+
projectId: z.string(),
|
|
46
|
+
enabled: z.boolean(),
|
|
47
|
+
title: z.string().min(1).max(50),
|
|
48
|
+
durationDays: z.number().int().min(1).max(90),
|
|
49
|
+
cooldownDays: z.number().int().min(0).max(30),
|
|
50
|
+
startsOn: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
51
|
+
futureCount: z.number().int().min(1).max(10),
|
|
52
|
+
autoRollover: z.boolean(),
|
|
53
|
+
}),
|
|
54
|
+
)
|
|
55
|
+
.handler(async ({ data }) => {
|
|
56
|
+
const request = getRequest();
|
|
57
|
+
await requireAuth(request);
|
|
58
|
+
await prisma.iterationAutoSchedule.upsert({
|
|
59
|
+
where: { projectId: data.projectId },
|
|
60
|
+
create: { ...data, startsOn: new Date(data.startsOn + "T00:00:00.000Z") },
|
|
61
|
+
update: { ...data, startsOn: new Date(data.startsOn + "T00:00:00.000Z") },
|
|
62
|
+
select: { projectId: true },
|
|
63
|
+
});
|
|
64
|
+
await ensureUpcomingIterations(data.projectId);
|
|
65
|
+
return { ok: true };
|
|
66
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// AllBlue - 理想之海
|
|
3
|
+
// Copyright (C) 2026 AllBlue Contributors
|
|
4
|
+
//
|
|
5
|
+
// This program is free software: you can redistribute it and/or modify
|
|
6
|
+
// it under the terms of the GNU Affero General Public License as published by
|
|
7
|
+
// the Free Software Foundation, either version 3 of the License, or
|
|
8
|
+
// (at your option) any later version.
|
|
9
|
+
//
|
|
10
|
+
// This program is distributed in the hope that it will be useful,
|
|
11
|
+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
// GNU Affero General Public License for more details.
|
|
14
|
+
//
|
|
15
|
+
// You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
19
|
+
import { getRequest } from "@tanstack/react-start/server";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { prisma } from "@allbluecn/database";
|
|
22
|
+
import { requireAuth } from "@/server/auth-helpers";
|
|
23
|
+
import { toDatePart } from "@/lib/iteration/status";
|
|
24
|
+
import { computeBurndown } from "./burndown-core";
|
|
25
|
+
|
|
26
|
+
const ACTIVITY_STORY_SELECT = {
|
|
27
|
+
storyId: true, field: true, oldValue: true, newValue: true, createdAt: true, action: true,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export const getIterationBurndownFn = createServerFn({ method: "GET" })
|
|
31
|
+
.validator(z.object({ iterationId: z.string() }))
|
|
32
|
+
.handler(async ({ data }) => {
|
|
33
|
+
const request = getRequest();
|
|
34
|
+
await requireAuth(request);
|
|
35
|
+
const iteration = await prisma.iteration.findUnique({
|
|
36
|
+
where: { id: data.iterationId },
|
|
37
|
+
select: { startDate: true, endDate: true, project: { select: { timezone: true } } },
|
|
38
|
+
});
|
|
39
|
+
if (!iteration) throw new Error("迭代不存在");
|
|
40
|
+
const timezone = iteration.project.timezone ?? "Asia/Shanghai";
|
|
41
|
+
const stories = await prisma.story.findMany({
|
|
42
|
+
where: { iterationId: data.iterationId },
|
|
43
|
+
select: { id: true, createdAt: true },
|
|
44
|
+
});
|
|
45
|
+
const activities = await prisma.storyActivity.findMany({
|
|
46
|
+
where: {
|
|
47
|
+
storyId: { in: stories.length ? stories.map((s) => s.id) : ["__none__"] },
|
|
48
|
+
OR: [{ action: "state_changed", field: "state" }, { action: "iteration_changed", field: "iterationId" }],
|
|
49
|
+
},
|
|
50
|
+
orderBy: { createdAt: "asc" },
|
|
51
|
+
select: ACTIVITY_STORY_SELECT,
|
|
52
|
+
});
|
|
53
|
+
// 加入时间:iteration_changed 首次进入本迭代的活动时间,无记录 fallback story.createdAt(规格 §3.4 简化口径)
|
|
54
|
+
const addedAt = new Map<string, string>();
|
|
55
|
+
for (const a of activities) {
|
|
56
|
+
if (a.action !== "iteration_changed") continue;
|
|
57
|
+
addedAt.set(a.storyId, a.createdAt.toISOString());
|
|
58
|
+
}
|
|
59
|
+
const result = computeBurndown({
|
|
60
|
+
startDate: toDatePart(iteration.startDate),
|
|
61
|
+
endDate: toDatePart(iteration.endDate),
|
|
62
|
+
timezone,
|
|
63
|
+
stories: stories.map((s) => ({ id: s.id, addedAt: addedAt.get(s.id) ?? s.createdAt.toISOString() })),
|
|
64
|
+
stateEvents: activities
|
|
65
|
+
.filter((a) => a.action === "state_changed")
|
|
66
|
+
.map((a) => ({ storyId: a.storyId, at: a.createdAt.toISOString(), to: a.newValue ?? "" })),
|
|
67
|
+
now: new Date(),
|
|
68
|
+
});
|
|
69
|
+
return { ...result, timezone };
|
|
70
|
+
});
|