feat(v2.4): 完成领域主写迁移
This commit is contained in:
@@ -2,10 +2,14 @@ import { api } from './api';
|
||||
import type { Bug, BugSeverity, BugStatus } from './bug';
|
||||
import type { DevTask, DevTaskStatus, Reference } from './dev-task';
|
||||
import type { Priority } from './derive';
|
||||
import type { Member } from './members';
|
||||
import type { OvertimeRecord } from './overtime';
|
||||
import type { Requirement, RequirementStatus, SourceType } from './requirement';
|
||||
import type { TaskCategory } from './task-category';
|
||||
import type { TaskWorklog } from './task-worklog';
|
||||
import type { TestCase, TestCaseStatus } from './test-case';
|
||||
import type { VersionPlan, VersionPlanLog, VersionPlanRequirementCoverage } from './version-plan';
|
||||
import type { WorkActivity, WorkActivityCategory, WorkActivitySourceType } from './work-activity';
|
||||
import type { WorkActivity, WorkActivityCategory, WorkActivityDraft, WorkActivitySourceType } from './work-activity';
|
||||
|
||||
export interface RootProject {
|
||||
id: string;
|
||||
@@ -254,6 +258,51 @@ interface DomainWorkActivityRow {
|
||||
occurredAt?: string | Date | null;
|
||||
}
|
||||
|
||||
interface DomainMemberRow {
|
||||
id: string;
|
||||
name: string;
|
||||
username?: string | null;
|
||||
departmentId?: string | null;
|
||||
roleId?: string | null;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
password?: string | null;
|
||||
isSystem?: boolean | null;
|
||||
createdAt?: string | Date | null;
|
||||
}
|
||||
|
||||
interface DomainTaskCategoryRow {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
group: string;
|
||||
isSystem?: boolean | null;
|
||||
createdAt?: string | Date | null;
|
||||
}
|
||||
|
||||
interface DomainTaskWorklogRow {
|
||||
id: string;
|
||||
userId?: string | null;
|
||||
sourceId: string;
|
||||
workDate?: string | Date | null;
|
||||
hours?: number | null;
|
||||
content?: string | null;
|
||||
createdAt?: string | Date | null;
|
||||
}
|
||||
|
||||
interface DomainOvertimeRow {
|
||||
id: string;
|
||||
productId?: string | null;
|
||||
projectId?: string | null;
|
||||
versionId?: string | null;
|
||||
userId?: string | null;
|
||||
reason?: string | null;
|
||||
startAt?: string | Date | null;
|
||||
endAt?: string | Date | null;
|
||||
hours?: number | null;
|
||||
createdAt?: string | Date | null;
|
||||
}
|
||||
|
||||
interface DomainMutationResponse<T> {
|
||||
item: T;
|
||||
activities?: DomainWorkActivityRow[];
|
||||
@@ -436,6 +485,83 @@ export async function deleteBugByVersionId(versionId: string, bugId: string): Pr
|
||||
await api.delete(`/versions/${versionId}/bugs/${bugId}`);
|
||||
}
|
||||
|
||||
export async function listMembersDomain(): Promise<Member[]> {
|
||||
const rows = await api.get<DomainMemberRow[]>('/members');
|
||||
return rows.map(normalizeMember);
|
||||
}
|
||||
|
||||
export async function createMemberDomain(data: Partial<Member>, _existing?: Member[]): Promise<Member> {
|
||||
return normalizeMember(await api.post<DomainMemberRow>('/members', toMemberPayload(data)));
|
||||
}
|
||||
|
||||
export async function updateMemberDomain(memberId: string, data: Partial<Member>): Promise<Member> {
|
||||
return normalizeMember(await api.patch<DomainMemberRow>(`/members/${memberId}`, toMemberPayload(data)));
|
||||
}
|
||||
|
||||
export async function deleteMemberDomain(memberId: string): Promise<void> {
|
||||
await api.delete(`/members/${memberId}`);
|
||||
}
|
||||
|
||||
export async function listTaskCategoriesDomain(): Promise<TaskCategory[]> {
|
||||
const rows = await api.get<DomainTaskCategoryRow[]>('/task-categories');
|
||||
return rows.map((row, index) => normalizeTaskCategory(row, index));
|
||||
}
|
||||
|
||||
export async function createTaskCategoryDomain(data: Partial<TaskCategory>): Promise<TaskCategory> {
|
||||
return normalizeTaskCategory(await api.post<DomainTaskCategoryRow>('/task-categories', toTaskCategoryPayload(data)), 0);
|
||||
}
|
||||
|
||||
export async function updateTaskCategoryDomain(categoryId: string, data: Partial<TaskCategory>): Promise<TaskCategory> {
|
||||
return normalizeTaskCategory(await api.patch<DomainTaskCategoryRow>(`/task-categories/${categoryId}`, toTaskCategoryPayload(data)), 0);
|
||||
}
|
||||
|
||||
export async function deleteTaskCategoryDomain(categoryId: string): Promise<void> {
|
||||
await api.delete(`/task-categories/${categoryId}`);
|
||||
}
|
||||
|
||||
export async function listTaskWorklogsDomain(): Promise<TaskWorklog[]> {
|
||||
const rows = await api.get<DomainTaskWorklogRow[]>('/task-worklogs');
|
||||
return rows.map(normalizeTaskWorklog);
|
||||
}
|
||||
|
||||
export async function createTaskWorklogDomain(data: TaskWorklog): Promise<TaskWorklog> {
|
||||
return normalizeTaskWorklog(await api.post<DomainTaskWorklogRow>('/task-worklogs', toTaskWorklogPayload(data)));
|
||||
}
|
||||
|
||||
export async function deleteTaskWorklogDomain(worklogId: string): Promise<void> {
|
||||
await api.delete(`/task-worklogs/${worklogId}`);
|
||||
}
|
||||
|
||||
export async function listOvertimeRecordsDomain(): Promise<OvertimeRecord[]> {
|
||||
const rows = await api.get<DomainOvertimeRow[]>('/overtime');
|
||||
return rows.map(normalizeOvertimeRecord);
|
||||
}
|
||||
|
||||
export async function createOvertimeRecordDomain(data: OvertimeRecord): Promise<OvertimeRecord> {
|
||||
return normalizeOvertimeRecord(await api.post<DomainOvertimeRow>('/overtime', toOvertimePayload(data)));
|
||||
}
|
||||
|
||||
export async function updateOvertimeRecordDomain(recordId: string, data: Partial<OvertimeRecord>): Promise<void> {
|
||||
await api.patch(`/overtime/${recordId}`, toOvertimePayload(data));
|
||||
}
|
||||
|
||||
export async function deleteOvertimeRecordDomain(recordId: string): Promise<void> {
|
||||
await api.delete(`/overtime/${recordId}`);
|
||||
}
|
||||
|
||||
export async function listWorkActivitiesDomain(): Promise<WorkActivity[]> {
|
||||
const rows = await api.get<DomainWorkActivityRow[]>('/work-activities');
|
||||
return rows.map(normalizeWorkActivity);
|
||||
}
|
||||
|
||||
export async function createWorkActivityDomain(data: WorkActivity | WorkActivityDraft): Promise<WorkActivity> {
|
||||
return normalizeWorkActivity(await api.post<DomainWorkActivityRow>('/work-activities', toWorkActivityPayload(data)));
|
||||
}
|
||||
|
||||
export async function deleteWorkActivityDomain(activityId: string): Promise<void> {
|
||||
await api.delete(`/work-activities/${activityId}`);
|
||||
}
|
||||
|
||||
function normalizeProductRoot(product: RootProduct): RootProduct {
|
||||
return {
|
||||
...product,
|
||||
@@ -753,6 +879,126 @@ function toBugSeverity(value: string | null | undefined): BugSeverity {
|
||||
return 'minor';
|
||||
}
|
||||
|
||||
function toMemberPayload(data: Partial<Member>) {
|
||||
return {
|
||||
...(data.id !== undefined && { id: data.id }),
|
||||
...(data.name !== undefined && { name: data.name }),
|
||||
...(data.username !== undefined && { username: data.username }),
|
||||
...(data.departmentId !== undefined && { departmentId: data.departmentId }),
|
||||
...(data.roleId !== undefined && { roleId: data.roleId }),
|
||||
...(data.phone !== undefined && { phone: data.phone }),
|
||||
...(data.email !== undefined && { email: data.email }),
|
||||
...(data.password !== undefined && { password: data.password }),
|
||||
...(data.isSystem !== undefined && { isSystem: data.isSystem }),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMember(row: DomainMemberRow): Member {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
username: row.username ?? undefined,
|
||||
departmentId: row.departmentId ?? '',
|
||||
roleId: row.roleId ?? 'member',
|
||||
phone: row.phone ?? '',
|
||||
email: row.email ?? '',
|
||||
password: row.password ?? '',
|
||||
createdAt: isoString(row.createdAt).slice(0, 10),
|
||||
isSystem: Boolean(row.isSystem),
|
||||
};
|
||||
}
|
||||
|
||||
function toTaskCategoryPayload(data: Partial<TaskCategory>) {
|
||||
return {
|
||||
...(data.name !== undefined && { name: data.name }),
|
||||
...(data.group !== undefined && { group: data.group }),
|
||||
...(data.code !== undefined && { code: data.code }),
|
||||
...(data.isSystem !== undefined && { isSystem: data.isSystem }),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskCategory(row: DomainTaskCategoryRow, index: number): TaskCategory {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code ?? row.name,
|
||||
name: row.name,
|
||||
group: toCategoryGroup(row.group),
|
||||
sortOrder: index + 1,
|
||||
isSystem: Boolean(row.isSystem),
|
||||
};
|
||||
}
|
||||
|
||||
function toCategoryGroup(value: string): TaskCategory['group'] {
|
||||
if (value === 'development' || value === 'testing' || value === 'implementation') return value;
|
||||
return 'other';
|
||||
}
|
||||
|
||||
function toTaskWorklogPayload(data: TaskWorklog) {
|
||||
return {
|
||||
taskId: data.taskId,
|
||||
userId: data.userId,
|
||||
date: data.date,
|
||||
hours: data.hours,
|
||||
workContent: data.workContent,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTaskWorklog(row: DomainTaskWorklogRow): TaskWorklog {
|
||||
return {
|
||||
id: row.id,
|
||||
taskId: row.sourceId,
|
||||
userId: row.userId ?? '',
|
||||
date: isoString(row.workDate).slice(0, 10),
|
||||
hours: row.hours ?? 0,
|
||||
workContent: row.content ?? '',
|
||||
createdAt: isoString(row.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
function toOvertimePayload(data: Partial<OvertimeRecord>) {
|
||||
return {
|
||||
...(data.productId !== undefined && { productId: data.productId }),
|
||||
...(data.projectId !== undefined && { projectId: data.projectId }),
|
||||
...(data.versionId !== undefined && { versionId: data.versionId }),
|
||||
...(data.person !== undefined && { person: data.person }),
|
||||
...(data.startTime !== undefined && { startTime: data.startTime }),
|
||||
...(data.endTime !== undefined && { endTime: data.endTime }),
|
||||
...(data.duration !== undefined && { duration: data.duration }),
|
||||
...(data.reasonId !== undefined && { reasonId: data.reasonId }),
|
||||
...(data.remark !== undefined && { remark: data.remark }),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOvertimeRecord(row: DomainOvertimeRow): OvertimeRecord {
|
||||
const [reasonId, ...remarkParts] = (row.reason ?? '').split(':');
|
||||
return {
|
||||
id: row.id,
|
||||
projectId: row.projectId ?? '',
|
||||
versionId: row.versionId ?? undefined,
|
||||
person: row.userId ?? '',
|
||||
startTime: isoString(row.startAt),
|
||||
endTime: isoString(row.endAt),
|
||||
duration: row.hours ?? 0,
|
||||
reasonId,
|
||||
remark: remarkParts.join(':') || undefined,
|
||||
createdAt: isoString(row.createdAt).slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
function toWorkActivityPayload(data: WorkActivity | WorkActivityDraft) {
|
||||
return {
|
||||
actorId: data.actorId,
|
||||
sourceType: data.sourceType,
|
||||
sourceId: data.sourceId,
|
||||
action: data.action,
|
||||
title: data.title,
|
||||
category: data.category,
|
||||
summary: data.summary,
|
||||
metadata: data.metadata,
|
||||
occurredAt: data.occurredAt,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMutation<Row, Item>(
|
||||
response: DomainMutationResponse<Row>,
|
||||
mapper: (row: Row) => Item,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { hasPermission } from './permissions';
|
||||
|
||||
export interface OvertimeRecord {
|
||||
id: string;
|
||||
productId?: string;
|
||||
projectId: string;
|
||||
versionId?: string;
|
||||
requirementId?: string;
|
||||
|
||||
58
apps/web/lib/v245-domain-source.test.ts
Normal file
58
apps/web/lib/v245-domain-source.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
function store(name: string) {
|
||||
return readFileSync(join(process.cwd(), `stores/${name}`), 'utf8');
|
||||
}
|
||||
|
||||
function storeMethodBody(text: string, name: string) {
|
||||
const implementationStart = text.indexOf('export const');
|
||||
assert.notEqual(implementationStart, -1, 'missing store implementation');
|
||||
const start = text.indexOf(` ${name}:`, implementationStart);
|
||||
assert.notEqual(start, -1, `missing store method ${name}`);
|
||||
let depth = 0;
|
||||
let sawFirstBrace = false;
|
||||
for (let i = start; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
if (char === '{') {
|
||||
depth += 1;
|
||||
sawFirstBrace = true;
|
||||
}
|
||||
if (char === '}') {
|
||||
depth -= 1;
|
||||
if (sawFirstBrace && depth === 0) return text.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(`could not extract store method ${name}`);
|
||||
}
|
||||
|
||||
test('member and dictionary stores use domain APIs as primary writes', () => {
|
||||
const members = store('useMemberStore.ts');
|
||||
const categories = store('useTaskCategoryStore.ts');
|
||||
|
||||
assert.match(members, /from '@\/lib\/domain-api'/);
|
||||
assert.match(storeMethodBody(members, 'createMember'), /createMemberDomain\(member,/);
|
||||
assert.match(storeMethodBody(members, 'updateMember'), /updateMemberDomain\(id,/);
|
||||
assert.match(storeMethodBody(members, 'deleteMember'), /deleteMemberDomain\(id\)/);
|
||||
|
||||
assert.match(categories, /from '@\/lib\/domain-api'/);
|
||||
assert.match(storeMethodBody(categories, 'addCategory'), /createTaskCategoryDomain\(item\)/);
|
||||
assert.match(storeMethodBody(categories, 'updateCategory'), /updateTaskCategoryDomain\(id,/);
|
||||
assert.match(storeMethodBody(categories, 'deleteCategory'), /deleteTaskCategoryDomain\(id\)/);
|
||||
});
|
||||
|
||||
test('evidence stores use append-style domain APIs as primary writes', () => {
|
||||
const worklogs = store('useTaskWorklogStore.ts');
|
||||
const overtime = store('useOvertimeStore.ts');
|
||||
const activities = store('useWorkActivityStore.ts');
|
||||
|
||||
assert.match(storeMethodBody(worklogs, 'addWorklog'), /createTaskWorklogDomain\(item\)/);
|
||||
assert.match(storeMethodBody(overtime, 'createRecord'), /createOvertimeRecordDomain\(record\)/);
|
||||
assert.match(storeMethodBody(activities, 'addActivity'), /createWorkActivityDomain\(item\)/);
|
||||
|
||||
assert.doesNotMatch(storeMethodBody(worklogs, 'addWorklog'), /saveServerData\('task-worklogs'/);
|
||||
assert.doesNotMatch(storeMethodBody(overtime, 'createRecord'), /saveServerData\('overtime'/);
|
||||
assert.doesNotMatch(storeMethodBody(activities, 'addActivity'), /saveServerData\('work-activities'/);
|
||||
});
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
ensureSystemAdminMember,
|
||||
sanitizeSystemAdminPatch,
|
||||
} from '@/lib/member-system';
|
||||
import {
|
||||
createMemberDomain,
|
||||
deleteMemberDomain,
|
||||
listMembersDomain,
|
||||
updateMemberDomain,
|
||||
} from '@/lib/domain-api';
|
||||
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||
import { DEFAULT_ROLE_PERMISSIONS, DEFAULT_ROLE_PRESETS } from '@/lib/permissions';
|
||||
import { mergePresetRolePermissions } from '@/lib/role-permission-migration';
|
||||
@@ -72,7 +78,10 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
|
||||
fetchMembers: async (options) => {
|
||||
if (!options?.force && get().loaded && Date.now() - lastMembersFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const cached = await loadStored();
|
||||
const [cached, domainMembers] = await Promise.all([
|
||||
loadStored(),
|
||||
listMembersDomain().catch(() => null),
|
||||
]);
|
||||
if (!options?.force && get().loaded && Date.now() - lastMembersFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
lastMembersFetchAt = Date.now();
|
||||
if (cached) {
|
||||
@@ -93,7 +102,8 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
permissionMerged.changed ||
|
||||
roles.length !== rolesWithNewDefaults.length ||
|
||||
roles.some((role, index) => JSON.stringify(role) !== JSON.stringify(rolesWithNewDefaults[index]));
|
||||
const ensured = ensureSystemAdminMember(cached.members);
|
||||
const sourceMembers = domainMembers && domainMembers.length > 0 ? domainMembers : cached.members;
|
||||
const ensured = ensureSystemAdminMember(sourceMembers);
|
||||
const usernameEnsured = ensureMemberUsernames(ensured.members);
|
||||
const next = {
|
||||
departments: cached.departments,
|
||||
@@ -104,7 +114,7 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
set({ ...next, loaded: true });
|
||||
if (ensured.changed || usernameEnsured.changed || rolesChanged) void saveStored(next).catch(() => {});
|
||||
} else {
|
||||
set({ loaded: true });
|
||||
set({ members: domainMembers && domainMembers.length > 0 ? domainMembers : get().members, loaded: true });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -166,7 +176,16 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
const members = [...previous, member];
|
||||
set({ members });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }),
|
||||
save: async () => {
|
||||
try {
|
||||
const created = await createMemberDomain(member, previous);
|
||||
set({
|
||||
members: get().members.map((item) => (item.id === member.id ? { ...member, ...created } : item)),
|
||||
});
|
||||
} catch {
|
||||
await saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
}
|
||||
},
|
||||
expected: members,
|
||||
getCurrent: () => get().members,
|
||||
rollback: () => set({ members: previous }),
|
||||
@@ -179,10 +198,14 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
const ensured = ensureSystemAdminMember(updatedMembers);
|
||||
set({ members: ensured.members });
|
||||
try {
|
||||
await saveStored({ departments: get().departments, members: ensured.members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
await updateMemberDomain(id, safeData);
|
||||
} catch (error) {
|
||||
set({ members: previousMembers });
|
||||
throw error;
|
||||
try {
|
||||
await saveStored({ departments: get().departments, members: ensured.members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
} catch {
|
||||
set({ members: previousMembers });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
},
|
||||
deleteMember: (id) => {
|
||||
@@ -191,7 +214,13 @@ export const useMemberStore = create<MemberState>((set, get) => ({
|
||||
const members = previous.filter((m) => m.id !== id);
|
||||
set({ members });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule }),
|
||||
save: async () => {
|
||||
try {
|
||||
await deleteMemberDomain(id);
|
||||
} catch {
|
||||
await saveStored({ departments: get().departments, members, roles: get().roles, passwordRule: get().passwordRule });
|
||||
}
|
||||
},
|
||||
expected: members,
|
||||
getCurrent: () => get().members,
|
||||
rollback: () => set({ members: previous }),
|
||||
|
||||
@@ -3,6 +3,12 @@ import { create } from 'zustand';
|
||||
import type { OvertimeRecord } from '@/lib/overtime';
|
||||
import type { DictItem } from '@/lib/requirement';
|
||||
import { calcDuration } from '@/lib/overtime';
|
||||
import {
|
||||
createOvertimeRecordDomain,
|
||||
deleteOvertimeRecordDomain,
|
||||
listOvertimeRecordsDomain,
|
||||
updateOvertimeRecordDomain,
|
||||
} from '@/lib/domain-api';
|
||||
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
|
||||
@@ -52,10 +58,17 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
|
||||
fetchRecords: async (options) => {
|
||||
if (!options?.force && get().loaded && Date.now() - lastOvertimeFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const cached = await loadStored();
|
||||
const [cached, domainRecords] = await Promise.all([
|
||||
loadStored(),
|
||||
listOvertimeRecordsDomain().catch(() => null),
|
||||
]);
|
||||
if (!options?.force && get().loaded && Date.now() - lastOvertimeFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
lastOvertimeFetchAt = Date.now();
|
||||
set({ records: cached?.records ?? MOCK_RECORDS, reasons: cached?.reasons ?? PRESET_REASONS, loaded: true });
|
||||
set({
|
||||
records: domainRecords && domainRecords.length > 0 ? domainRecords : cached?.records ?? MOCK_RECORDS,
|
||||
reasons: cached?.reasons ?? PRESET_REASONS,
|
||||
loaded: true,
|
||||
});
|
||||
},
|
||||
|
||||
createRecord: (data) => {
|
||||
@@ -70,7 +83,17 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
const records = [...previous, record];
|
||||
set({ records, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('overtime', { records, reasons: get().reasons }),
|
||||
save: async () => {
|
||||
try {
|
||||
const created = await createOvertimeRecordDomain(record);
|
||||
set({
|
||||
records: get().records.map((item) => (item.id === record.id ? { ...record, ...created } : item)),
|
||||
loaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveOvertimeFallback(records, get().reasons);
|
||||
}
|
||||
},
|
||||
expected: records,
|
||||
getCurrent: () => get().records,
|
||||
rollback: () => set({ records: previous, loaded: true }),
|
||||
@@ -89,7 +112,13 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
});
|
||||
set({ records, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('overtime', { records, reasons: get().reasons }),
|
||||
save: async () => {
|
||||
try {
|
||||
await updateOvertimeRecordDomain(id, records.find((record) => record.id === id) ?? {});
|
||||
} catch {
|
||||
await saveOvertimeFallback(records, get().reasons);
|
||||
}
|
||||
},
|
||||
expected: records,
|
||||
getCurrent: () => get().records,
|
||||
rollback: () => set({ records: previous, loaded: true }),
|
||||
@@ -101,7 +130,13 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
const records = previous.filter((r) => r.id !== id);
|
||||
set({ records, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('overtime', { records, reasons: get().reasons }),
|
||||
save: async () => {
|
||||
try {
|
||||
await deleteOvertimeRecordDomain(id);
|
||||
} catch {
|
||||
await saveOvertimeFallback(records, get().reasons);
|
||||
}
|
||||
},
|
||||
expected: records,
|
||||
getCurrent: () => get().records,
|
||||
rollback: () => set({ records: previous, loaded: true }),
|
||||
@@ -145,3 +180,7 @@ export const useOvertimeStore = create<OvertimeState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
async function saveOvertimeFallback(records: OvertimeRecord[], reasons: DictItem[]) {
|
||||
await saveServerData('overtime', { records, reasons });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
import { create } from 'zustand';
|
||||
import type { TaskCategory, CategoryGroup } from '@/lib/task-category';
|
||||
import { PRESET_CATEGORIES, normalizeTaskCategories, resolveAiTaskCategoryByName } from '@/lib/task-category';
|
||||
import {
|
||||
createTaskCategoryDomain,
|
||||
deleteTaskCategoryDomain,
|
||||
listTaskCategoriesDomain,
|
||||
updateTaskCategoryDomain,
|
||||
} from '@/lib/domain-api';
|
||||
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
|
||||
@@ -30,7 +36,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
|
||||
fetchCategories: async (options) => {
|
||||
if (!options?.force && get().loaded && Date.now() - lastCategoriesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const cached = await loadStored();
|
||||
const cached = await listTaskCategoriesDomain().catch(loadStored);
|
||||
if (!options?.force && get().loaded && Date.now() - lastCategoriesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
lastCategoriesFetchAt = Date.now();
|
||||
set({ categories: cached ? normalizeTaskCategories(cached) : PRESET_CATEGORIES, loaded: true });
|
||||
@@ -50,7 +56,17 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
const updated = [...list, item];
|
||||
set({ categories: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('task-categories', updated),
|
||||
save: async () => {
|
||||
try {
|
||||
const created = await createTaskCategoryDomain(item);
|
||||
set({
|
||||
categories: get().categories.map((category) => (category.id === item.id ? { ...item, ...created } : category)),
|
||||
loaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveTaskCategoriesFallback(updated);
|
||||
}
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().categories,
|
||||
rollback: () => set({ categories: list, loaded: true }),
|
||||
@@ -63,7 +79,13 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
if (result.created) {
|
||||
set({ categories: result.categories, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('task-categories', result.categories),
|
||||
save: async () => {
|
||||
try {
|
||||
await createTaskCategoryDomain(result.category);
|
||||
} catch {
|
||||
await saveTaskCategoriesFallback(result.categories);
|
||||
}
|
||||
},
|
||||
expected: result.categories,
|
||||
getCurrent: () => get().categories,
|
||||
rollback: () => set({ categories: previous, loaded: true }),
|
||||
@@ -77,7 +99,13 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
const updated = previous.map((c) => (c.id === id ? { ...c, ...data } : c));
|
||||
set({ categories: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('task-categories', updated),
|
||||
save: async () => {
|
||||
try {
|
||||
await updateTaskCategoryDomain(id, data);
|
||||
} catch {
|
||||
await saveTaskCategoriesFallback(updated);
|
||||
}
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().categories,
|
||||
rollback: () => set({ categories: previous, loaded: true }),
|
||||
@@ -91,7 +119,13 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
const updated = previous.filter((c) => c.id !== id);
|
||||
set({ categories: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('task-categories', updated),
|
||||
save: async () => {
|
||||
try {
|
||||
await deleteTaskCategoryDomain(id);
|
||||
} catch {
|
||||
await saveTaskCategoriesFallback(updated);
|
||||
}
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().categories,
|
||||
rollback: () => set({ categories: previous, loaded: true }),
|
||||
@@ -99,3 +133,7 @@ export const useTaskCategoryStore = create<TaskCategoryState>((set, get) => ({
|
||||
return true;
|
||||
},
|
||||
}));
|
||||
|
||||
async function saveTaskCategoriesFallback(categories: TaskCategory[]) {
|
||||
await saveServerData('task-categories', categories);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
'use client';
|
||||
import { create } from 'zustand';
|
||||
import type { TaskWorklog } from '@/lib/task-worklog';
|
||||
import {
|
||||
createTaskWorklogDomain,
|
||||
deleteTaskWorklogDomain,
|
||||
listTaskWorklogsDomain,
|
||||
} from '@/lib/domain-api';
|
||||
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
|
||||
@@ -28,7 +33,7 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
|
||||
|
||||
fetchWorklogs: async (options) => {
|
||||
if (!options?.force && get().loaded && Date.now() - lastWorklogsFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const cached = await loadStored();
|
||||
const cached = await listTaskWorklogsDomain().catch(loadStored);
|
||||
if (!options?.force && get().loaded && Date.now() - lastWorklogsFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
lastWorklogsFetchAt = Date.now();
|
||||
set({ worklogs: cached ?? [], loaded: true });
|
||||
@@ -44,7 +49,17 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
|
||||
const updated = [...previous, item];
|
||||
set({ worklogs: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('task-worklogs', updated),
|
||||
save: async () => {
|
||||
try {
|
||||
const created = await createTaskWorklogDomain(item);
|
||||
set({
|
||||
worklogs: get().worklogs.map((worklog) => (worklog.id === item.id ? { ...item, ...created } : worklog)),
|
||||
loaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveTaskWorklogsFallback(updated);
|
||||
}
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().worklogs,
|
||||
rollback: () => set({ worklogs: previous, loaded: true }),
|
||||
@@ -56,7 +71,13 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
|
||||
const updated = previous.filter((w) => w.id !== id);
|
||||
set({ worklogs: updated, loaded: true });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: () => saveServerData('task-worklogs', updated),
|
||||
save: async () => {
|
||||
try {
|
||||
await deleteTaskWorklogDomain(id);
|
||||
} catch {
|
||||
await saveTaskWorklogsFallback(updated);
|
||||
}
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().worklogs,
|
||||
rollback: () => set({ worklogs: previous, loaded: true }),
|
||||
@@ -69,3 +90,7 @@ export const useTaskWorklogStore = create<TaskWorklogState>((set, get) => ({
|
||||
.reduce((sum, w) => sum + w.hours, 0);
|
||||
},
|
||||
}));
|
||||
|
||||
async function saveTaskWorklogsFallback(worklogs: TaskWorklog[]) {
|
||||
await saveServerData('task-worklogs', worklogs);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ import { create } from 'zustand';
|
||||
import { formatLocalDate } from '@/lib/format';
|
||||
import { mergeWorkActivities, type WorkActivity, type WorkActivityDraft } from '@/lib/work-activity';
|
||||
import { makeProgressNoteActivity, type ProgressNoteActivityInput } from '@/lib/work-activity-factory';
|
||||
import {
|
||||
createWorkActivityDomain,
|
||||
deleteWorkActivityDomain,
|
||||
listWorkActivitiesDomain,
|
||||
} from '@/lib/domain-api';
|
||||
import { scheduleSaveWithOptimisticRollback } from '@/lib/optimistic-persistence';
|
||||
import { loadServerData, saveServerData, SERVER_DATA_CACHE_MS } from '@/lib/server-data';
|
||||
|
||||
interface WorkActivityState {
|
||||
@@ -52,13 +58,14 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
|
||||
|
||||
fetchActivities: async (options) => {
|
||||
if (!options?.force && get().loaded && Date.now() - lastActivitiesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
const cached = await loadStored();
|
||||
const cached = await listWorkActivitiesDomain().catch(loadStored);
|
||||
if (!options?.force && get().loaded && Date.now() - lastActivitiesFetchAt < SERVER_DATA_CACHE_MS) return;
|
||||
lastActivitiesFetchAt = Date.now();
|
||||
set({ activities: cached ?? [], loaded: true });
|
||||
},
|
||||
|
||||
addActivity: (data) => {
|
||||
const previous = get().activities;
|
||||
const now = new Date();
|
||||
const item: WorkActivity = {
|
||||
...data,
|
||||
@@ -66,9 +73,24 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
|
||||
date: data.date ?? formatLocalDate(now),
|
||||
occurredAt: data.occurredAt ?? now.toISOString(),
|
||||
};
|
||||
const updated = [...get().activities, item];
|
||||
const updated = [...previous, item];
|
||||
set({ activities: updated, loaded: true });
|
||||
saveStored(updated, (activities) => set({ activities, loaded: true }));
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
const created = await createWorkActivityDomain(item);
|
||||
set({
|
||||
activities: get().activities.map((activity) => (activity.id === item.id ? { ...item, ...created } : activity)),
|
||||
loaded: true,
|
||||
});
|
||||
} catch {
|
||||
await saveStored(updated, (activities) => set({ activities, loaded: true }));
|
||||
}
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().activities,
|
||||
rollback: () => set({ activities: previous, loaded: true }),
|
||||
});
|
||||
return item;
|
||||
},
|
||||
|
||||
@@ -77,8 +99,20 @@ export const useWorkActivityStore = create<WorkActivityState>((set, get) => ({
|
||||
},
|
||||
|
||||
deleteActivity: (id) => {
|
||||
const updated = get().activities.filter((activity) => activity.id !== id);
|
||||
const previous = get().activities;
|
||||
const updated = previous.filter((activity) => activity.id !== id);
|
||||
set({ activities: updated, loaded: true });
|
||||
saveStored(updated, (activities) => set({ activities, loaded: true }), { mergeRemote: false });
|
||||
scheduleSaveWithOptimisticRollback({
|
||||
save: async () => {
|
||||
try {
|
||||
await deleteWorkActivityDomain(id);
|
||||
} catch {
|
||||
await saveStored(updated, (activities) => set({ activities, loaded: true }), { mergeRemote: false });
|
||||
}
|
||||
},
|
||||
expected: updated,
|
||||
getCurrent: () => get().activities,
|
||||
rollback: () => set({ activities: previous, loaded: true }),
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user