@allbluecn/web-app 0.5.1 → 0.6.1
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 +230 -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/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 +33 -1
- package/src/components/story/create-story-dialog.tsx +30 -10
- 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 +11 -4
- package/src/components/story/detail/story-detail-body.tsx +89 -38
- package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +118 -9
- package/src/components/story/detail/story-detail-toolbar/children.tsx +7 -4
- package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +67 -7
- package/src/components/story/detail/story-detail-toolbar.tsx +6 -1
- 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 +22 -59
- package/src/components/story/peek-panel.tsx +121 -21
- package/src/components/story/relations/link-manage-dialog.tsx +2 -1
- 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 +1 -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/utils.d.ts +1 -1
- package/src/providers/index.tsx +6 -0
- package/src/routeTree.gen.ts +54 -0
- package/src/routes/__root.tsx +0 -8
- package/src/routes/_nav/knowledge/index.lazy.tsx +2 -2
- 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 +4 -4
- 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/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/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/project/module-overview.ts +109 -0
- package/src/server/serverFns/project.ts +2 -2
- 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-dependencies.test.ts +106 -2
- package/src/server/serverFns/story/index.ts +3 -0
- package/src/server/serverFns/story/story-assoc.ts +36 -4
- package/src/server/serverFns/story/story-attachments.ts +20 -2
- 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 +32 -2
- package/src/server/serverFns/story/story-dependencies.ts +123 -2
- package/src/server/serverFns/story/story-links.ts +30 -6
- package/src/server/serverFns/story/story-transfer.ts +5 -4
- package/src/server/serverFns/story/types.ts +1 -0
- package/src/types/project.ts +5 -5
- 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/lib/project-mock.ts +0 -156
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
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 { z } from "zod";
|
|
19
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
20
|
+
import { getRequest } from "@tanstack/react-start/server";
|
|
21
|
+
import { prisma } from "@allbluecn/database";
|
|
22
|
+
import { requireAuth } from "@/server/auth-helpers";
|
|
23
|
+
import { deriveIterationStatus, isDateRangeOverlapping, toDatePart } from "@/lib/iteration/status";
|
|
24
|
+
import {
|
|
25
|
+
listIterationsSchema,
|
|
26
|
+
createIterationSchema,
|
|
27
|
+
updateIterationSchema,
|
|
28
|
+
iterationIdSchema,
|
|
29
|
+
type IterationSummary,
|
|
30
|
+
} from "./schemas";
|
|
31
|
+
import type { StoryState } from "@/components/story/constants";
|
|
32
|
+
import { ensureUpcomingIterations, ensureAutoRollover } from "./auto-schedule-internal";
|
|
33
|
+
|
|
34
|
+
const ITERATION_SELECT = {
|
|
35
|
+
id: true,
|
|
36
|
+
projectId: true,
|
|
37
|
+
name: true,
|
|
38
|
+
description: true,
|
|
39
|
+
startDate: true,
|
|
40
|
+
endDate: true,
|
|
41
|
+
archived: true,
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
/** 与项目内未归档迭代校验日期不重叠(规格 §2.1:约束在 serverFn 层) */
|
|
45
|
+
async function assertNoOverlap(projectId: string, range: { startDate: string; endDate: string }, excludeId?: string) {
|
|
46
|
+
const siblings = await prisma.iteration.findMany({
|
|
47
|
+
where: { projectId, archived: false, ...(excludeId ? { id: { not: excludeId } } : {}) },
|
|
48
|
+
select: { startDate: true, endDate: true },
|
|
49
|
+
});
|
|
50
|
+
if (siblings.some((s) => isDateRangeOverlapping(range, { startDate: s.startDate, endDate: s.endDate }))) {
|
|
51
|
+
throw new Error("迭代日期与现有迭代重叠");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toSummary(
|
|
56
|
+
it: { id: string; name: string; description: string | null; startDate: Date; endDate: Date; archived: boolean },
|
|
57
|
+
stateCounts: Record<string, number>,
|
|
58
|
+
): IterationSummary {
|
|
59
|
+
const startDate = toDatePart(it.startDate);
|
|
60
|
+
const endDate = toDatePart(it.endDate);
|
|
61
|
+
return {
|
|
62
|
+
id: it.id,
|
|
63
|
+
name: it.name,
|
|
64
|
+
description: it.description,
|
|
65
|
+
startDate,
|
|
66
|
+
endDate,
|
|
67
|
+
archived: it.archived,
|
|
68
|
+
status: deriveIterationStatus(startDate, endDate),
|
|
69
|
+
storyCount: Object.values(stateCounts).reduce((a, b) => a + b, 0),
|
|
70
|
+
stateCounts,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const listIterationsFn = createServerFn({ method: "GET" })
|
|
75
|
+
.validator(listIterationsSchema)
|
|
76
|
+
.handler(async ({ data }) => {
|
|
77
|
+
const request = getRequest();
|
|
78
|
+
await requireAuth(request);
|
|
79
|
+
// 惰性自愈:自动排期补建 + autoRollover(规格 §3.6)
|
|
80
|
+
await ensureUpcomingIterations(data.projectId);
|
|
81
|
+
await ensureAutoRollover(data.projectId);
|
|
82
|
+
const items = await prisma.iteration.findMany({
|
|
83
|
+
where: { projectId: data.projectId, ...(data.includeArchived ? {} : { archived: false }) },
|
|
84
|
+
orderBy: { startDate: "desc" },
|
|
85
|
+
select: ITERATION_SELECT,
|
|
86
|
+
});
|
|
87
|
+
const grouped = await prisma.story.groupBy({
|
|
88
|
+
by: ["iterationId", "state"],
|
|
89
|
+
where: { iterationId: { in: items.map((i) => i.id) } },
|
|
90
|
+
_count: { _all: true },
|
|
91
|
+
});
|
|
92
|
+
const counts = new Map<string, Record<string, number>>();
|
|
93
|
+
for (const g of grouped) {
|
|
94
|
+
if (!g.iterationId) continue;
|
|
95
|
+
const m = counts.get(g.iterationId) ?? {};
|
|
96
|
+
m[g.state] = g._count._all;
|
|
97
|
+
counts.set(g.iterationId, m);
|
|
98
|
+
}
|
|
99
|
+
return { items: items.map((i) => toSummary(i, counts.get(i.id) ?? {})) };
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
export const getIterationFn = createServerFn({ method: "GET" })
|
|
103
|
+
.validator(iterationIdSchema)
|
|
104
|
+
.handler(async ({ data }) => {
|
|
105
|
+
const request = getRequest();
|
|
106
|
+
await requireAuth(request);
|
|
107
|
+
const it = await prisma.iteration.findUnique({ where: { id: data.id }, select: ITERATION_SELECT });
|
|
108
|
+
if (!it) throw new Error("迭代不存在");
|
|
109
|
+
const grouped = await prisma.story.groupBy({
|
|
110
|
+
by: ["state"],
|
|
111
|
+
where: { iterationId: data.id },
|
|
112
|
+
_count: { _all: true },
|
|
113
|
+
});
|
|
114
|
+
const stateCounts = Object.fromEntries(grouped.map((g) => [g.state, g._count._all]));
|
|
115
|
+
return toSummary(it, stateCounts);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
export const createIterationsFn = createServerFn({ method: "POST" })
|
|
119
|
+
.validator(createIterationSchema)
|
|
120
|
+
.handler(async ({ data }) => {
|
|
121
|
+
const request = getRequest();
|
|
122
|
+
const session = await requireAuth(request);
|
|
123
|
+
await assertNoOverlap(data.projectId, data);
|
|
124
|
+
const created = await prisma.iteration.create({
|
|
125
|
+
data: {
|
|
126
|
+
projectId: data.projectId,
|
|
127
|
+
name: data.name,
|
|
128
|
+
description: data.description,
|
|
129
|
+
startDate: new Date(data.startDate + "T00:00:00.000Z"),
|
|
130
|
+
endDate: new Date(data.endDate + "T00:00:00.000Z"),
|
|
131
|
+
createdById: session.user!.id!,
|
|
132
|
+
},
|
|
133
|
+
select: { id: true },
|
|
134
|
+
});
|
|
135
|
+
return { id: created.id };
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
export const updateIterationsFn = createServerFn({ method: "POST" })
|
|
139
|
+
.validator(updateIterationSchema)
|
|
140
|
+
.handler(async ({ data }) => {
|
|
141
|
+
const request = getRequest();
|
|
142
|
+
await requireAuth(request);
|
|
143
|
+
const existing = await prisma.iteration.findUnique({ where: { id: data.id }, select: ITERATION_SELECT });
|
|
144
|
+
if (!existing) throw new Error("迭代不存在");
|
|
145
|
+
const next = {
|
|
146
|
+
startDate: data.startDate ?? toDatePart(existing.startDate),
|
|
147
|
+
endDate: data.endDate ?? toDatePart(existing.endDate),
|
|
148
|
+
};
|
|
149
|
+
if (next.startDate > next.endDate) throw new Error("结束日期不能早于开始日期");
|
|
150
|
+
// completed 锁定编辑(规格 §3.1)
|
|
151
|
+
if (deriveIterationStatus(next.startDate, next.endDate) === "completed") {
|
|
152
|
+
throw new Error("已完成的迭代不可编辑");
|
|
153
|
+
}
|
|
154
|
+
if (data.startDate || data.endDate) await assertNoOverlap(existing.projectId, next, data.id);
|
|
155
|
+
await prisma.iteration.update({
|
|
156
|
+
where: { id: data.id },
|
|
157
|
+
data: {
|
|
158
|
+
...(data.name !== undefined ? { name: data.name } : {}),
|
|
159
|
+
...(data.description !== undefined ? { description: data.description } : {}),
|
|
160
|
+
...(data.startDate ? { startDate: new Date(next.startDate + "T00:00:00.000Z") } : {}),
|
|
161
|
+
...(data.endDate ? { endDate: new Date(next.endDate + "T00:00:00.000Z") } : {}),
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
return { ok: true };
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
export const archiveIterationsFn = createServerFn({ method: "POST" })
|
|
168
|
+
.validator(z.object({ id: z.string(), archived: z.boolean() }))
|
|
169
|
+
.handler(async ({ data }) => {
|
|
170
|
+
const request = getRequest();
|
|
171
|
+
await requireAuth(request);
|
|
172
|
+
await prisma.iteration.update({
|
|
173
|
+
where: { id: data.id },
|
|
174
|
+
data: { archived: data.archived, archivedAt: data.archived ? new Date() : null },
|
|
175
|
+
});
|
|
176
|
+
return { ok: true };
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
export const deleteIterationsFn = createServerFn({ method: "POST" })
|
|
180
|
+
.validator(iterationIdSchema)
|
|
181
|
+
.handler(async ({ data }) => {
|
|
182
|
+
const request = getRequest();
|
|
183
|
+
await requireAuth(request);
|
|
184
|
+
await prisma.iteration.delete({ where: { id: data.id } }); // Story.iterationId SetNull 自动解绑
|
|
185
|
+
return { ok: true };
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const USER_BRIEF_SELECT = {
|
|
189
|
+
id: true, username: true, email: true, avatarConfig: true, bgShape: true, bgColor: true,
|
|
190
|
+
} as const;
|
|
191
|
+
|
|
192
|
+
const listIterationStoriesSchema = z.object({ iterationId: z.string() });
|
|
193
|
+
|
|
194
|
+
export const listIterationStoriesFn = createServerFn({ method: "GET" })
|
|
195
|
+
.validator(listIterationStoriesSchema)
|
|
196
|
+
.handler(async ({ data }) => {
|
|
197
|
+
const request = getRequest();
|
|
198
|
+
await requireAuth(request);
|
|
199
|
+
const stories = await prisma.story.findMany({
|
|
200
|
+
where: { iterationId: data.iterationId, archived: false },
|
|
201
|
+
orderBy: [{ state: "asc" }, { updatedAt: "desc" }],
|
|
202
|
+
select: {
|
|
203
|
+
id: true, identifier: true, name: true, state: true, priority: true,
|
|
204
|
+
assignee: { select: USER_BRIEF_SELECT },
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
return { items: stories.map((s) => ({ ...s, state: s.state as StoryState })) };
|
|
208
|
+
});
|