refactor: 首次运行为空列表并简化存储初始化

- 移除 seed-data 模块,首次运行改为持久化空任务列表
- 简化 initializeStore(),将 tasksInitialized 处理集中到 store 层
- 移除 loadTasksData() 写入 tasksInitialized 的副作用
- 已归一化的任务数据跳过多余的 localStorage 回写
- 为旧版安装补写 tasksInitialized;首次写入失败时下次访问重试
- 补充存储兼容性测试并更新 README
This commit is contained in:
eddy
2026-08-01 14:53:32 +08:00
parent ebc245da50
commit f1c6c052bc
6 changed files with 78 additions and 132 deletions
+4 -3
View File
@@ -155,7 +155,6 @@ npm run format
- `src/js/timeline.js`:手动及系统时间轴记录 - `src/js/timeline.js`:手动及系统时间轴记录
- `src/js/import-export.js`1.0 版备份验证、导入和导出 - `src/js/import-export.js`1.0 版备份验证、导入和导出
- `src/js/notify.js`:通知和截止日期提醒 - `src/js/notify.js`:通知和截止日期提醒
- `src/js/seed-data.js`:首次运行时的示例任务
- `src/js/i18n/`:中英文词典及插值逻辑 - `src/js/i18n/`:中英文词典及插值逻辑
- `test/`:单元测试和旧版数据兼容性测试 - `test/`:单元测试和旧版数据兼容性测试
@@ -167,10 +166,12 @@ npm run format
- `taskSortOrders` - `taskSortOrders`
- `showHiddenCompletedTasks` - `showHiddenCompletedTasks`
- `showHiddenTodoTasks` - `showHiddenTodoTasks`
- `tasksInitialized` - `tasksInitialized`(历史兼容标记)
- `tasksCorruptedBackup` - `tasksCorruptedBackup`
任务、排序和显示偏好使用 JSON 编码。若任务列表格式损坏,原始文本会保存到 `tasksCorruptedBackup`。当浏览器存储不可用或空间不足时,应用会在当前页面会话中继续运行,但刷新后内存中的改动会丢失 首次运行时任务列表为空,并会写入 `tasks: []``tasksInitialized` 标记。`tasksInitialized` 仅作历史兼容用途:读取时用于判断是否需要为旧版安装补写,写入则是为了在回退到含示例任务的旧版本时能识别出已初始化状态。从含示例任务的旧版本升级后,已有本地数据保持不变;仅全新安装(本地尚无 `tasks` 键)时以空列表开始
任务、排序和显示偏好使用 JSON 编码。若任务列表格式损坏,原始文本会保存到 `tasksCorruptedBackup`。当浏览器存储不可用时,应用会在当前页面会话中继续运行而不尝试写入,改动在刷新后丢失;当存储可用但空间不足时,首次初始化若写入失败则不会设置 `tasksInitialized`,下次访问会重试写入。
## 备份格式 ## 备份格式
+2 -3
View File
@@ -12,7 +12,6 @@ import {
setTimelineExpanded, setTimelineExpanded,
updateTaskCounts, updateTaskCounts,
} from './render.js'; } from './render.js';
import { createSeedTasks } from './seed-data.js';
import { import {
getShowHidden, getShowHidden,
getTasks, getTasks,
@@ -24,7 +23,7 @@ import {
togglePin, togglePin,
wasCorrupted, wasCorrupted,
} from './store.js'; } from './store.js';
import { consumeStorageWriteFailure, isInitialized } from './storage.js'; import { consumeStorageWriteFailure } from './storage.js';
import { addTimelineEntry, deleteTimelineEntry, updateTimelineEntry } from './timeline.js'; import { addTimelineEntry, deleteTimelineEntry, updateTimelineEntry } from './timeline.js';
import { isHiddenTask } from './utils.js'; import { isHiddenTask } from './utils.js';
@@ -256,7 +255,7 @@ function bind() {
}); });
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initializeStore(createSeedTasks(), isInitialized()); initializeStore();
applyTranslations(); applyTranslations();
renderAllTasks(); renderAllTasks();
bind(); bind();
-94
View File
@@ -1,94 +0,0 @@
export function createSeedTasks(now = new Date()) {
const createdDate = now.toISOString();
return [
{
id: 1,
title: '所有设备相机参数标准化',
description: '完成所有生产设备的相机参数标准化工作,确保图像采集的一致性和可靠性。',
progressNotes: '目前已完成大部分设备的参数标准化工作,仅剩DieBond贴片相机待确认参数配置。',
status: 'inProgress',
progress: 90,
assignee: '技术团队',
collaborators: [],
dueDate: '2025-02-15',
createdDate,
language: 'zh',
priority: 'high',
isPinned: false,
timeline: [],
isHidden: false,
},
{
id: 2,
title: 'DieBond设备照明优化',
description:
'定制组合光源:同轴光+环形光,采购更窄、更亮的侧面光源,以解决支架白边问题,提升图像质量。',
progressNotes:
'配置方案已与供应商确认,技术参数符合要求。供应商承诺2025年7月4日发货,目前正在生产中。',
status: 'inProgress',
progress: 60,
assignee: '硬件团队',
collaborators: ['供应商'],
dueDate: '2025-07-04',
createdDate,
language: 'zh',
priority: 'medium',
isPinned: true,
timeline: [],
isHidden: false,
},
{
id: 3,
title: 'DieBond和ClipBond软件界面中英文翻译更新',
description: '完成DieBond和ClipBond设备软件界面的中英文翻译工作,支持多语言操作环境。',
progressNotes:
'SG-TATA GUI 的中英文翻译已完成。未来更新杨杰的代码后,需要重新进行翻译(任务量很少)。',
status: 'inProgress',
progress: 80,
assignee: '杨杰',
collaborators: ['翻译团队'],
dueDate: '2025-02-28',
createdDate,
language: 'zh',
priority: 'medium',
isPinned: false,
timeline: [],
isHidden: false,
},
{
id: 4,
title: '所有设备相机手册',
description: '编写所有设备相机的操作手册,包括校准流程、位置引导方法、检测标准等技术文档。',
progressNotes: '任务刚启动,正在收集各设备的相机技术规格和操作要求。',
status: 'todo',
progress: 0,
assignee: '技术文档组',
collaborators: [],
dueDate: '2025-03-31',
createdDate,
language: 'zh',
priority: 'low',
isPinned: false,
timeline: [],
isHidden: false,
},
{
id: 5,
title: '开发 TaTa SECS/GEM & Map 软件',
description:
'开发符合SEMI标准的SECS/GEM通信协议软件,实现设备与MES系统的标准化通信,包括Map数据管理功能。',
progressNotes: '需求分析阶段,正在梳理SECS/GEM协议规范和技术架构设计。',
status: 'todo',
progress: 0,
assignee: '软件开发组',
collaborators: ['系统架构师'],
dueDate: '2025-06-30',
createdDate,
language: 'zh',
priority: 'high',
isPinned: false,
timeline: [],
isHidden: false,
},
];
}
+6 -5
View File
@@ -36,16 +36,17 @@ function writeJson(key, value) {
} }
export function loadTasksData() { export function loadTasksData() {
const { value: raw, available } = readRaw(STORAGE_KEYS.tasks); const { value: raw, available } = readRaw(STORAGE_KEYS.tasks);
if (!available) return { tasks: [], corrupted: false, exists: false, available: false }; if (!available)
if (raw === null) return { tasks: [], corrupted: false, exists: false, available: true }; return { tasks: [], corrupted: false, exists: false, available: false, raw: null };
writeRaw(STORAGE_KEYS.initialized, 'true'); if (raw === null)
return { tasks: [], corrupted: false, exists: false, available: true, raw: null };
try { try {
const tasks = JSON.parse(raw); const tasks = JSON.parse(raw);
if (!Array.isArray(tasks)) throw new TypeError('Tasks must be an array'); if (!Array.isArray(tasks)) throw new TypeError('Tasks must be an array');
return { tasks, corrupted: false, exists: true, available: true }; return { tasks, corrupted: false, exists: true, available: true, raw };
} catch { } catch {
writeRaw(STORAGE_KEYS.corruptedBackup, raw); writeRaw(STORAGE_KEYS.corruptedBackup, raw);
return { tasks: [], corrupted: true, exists: true, available: true }; return { tasks: [], corrupted: true, exists: true, available: true, raw };
} }
} }
export const saveTasksData = (tasks) => writeJson(STORAGE_KEYS.tasks, tasks); export const saveTasksData = (tasks) => writeJson(STORAGE_KEYS.tasks, tasks);
+8 -6
View File
@@ -2,6 +2,7 @@ import { DEFAULT_SORT_ORDERS, HIDEABLE_STATUSES, SORT_VALUES, STATUSES } from '.
import { normalizeSortOrders, normalizeTask, normalizeTasks } from './task-model.js'; import { normalizeSortOrders, normalizeTask, normalizeTasks } from './task-model.js';
import { generateUniqueId } from './utils.js'; import { generateUniqueId } from './utils.js';
import { import {
isInitialized,
loadShowHiddenData, loadShowHiddenData,
loadSortOrdersData, loadSortOrdersData,
loadTasksData, loadTasksData,
@@ -16,15 +17,16 @@ let sortOrders = { ...DEFAULT_SORT_ORDERS };
let showHidden = Object.fromEntries(HIDEABLE_STATUSES.map((status) => [status, false])); let showHidden = Object.fromEntries(HIDEABLE_STATUSES.map((status) => [status, false]));
let corrupted = false; let corrupted = false;
const persistTasks = () => saveTasksData(tasks); const persistTasks = () => saveTasksData(tasks);
export function initializeStore(seedTasks, initialized) { export function initializeStore() {
const loaded = loadTasksData(); const loaded = loadTasksData();
corrupted = loaded.corrupted; corrupted = loaded.corrupted;
tasks = normalizeTasks(loaded.tasks); tasks = normalizeTasks(loaded.tasks);
if (loaded.exists && !loaded.corrupted) persistTasks(); if (loaded.available && !loaded.corrupted) {
if (!initialized && !loaded.exists) { // normalizeTask 以 { ...input } 展开、保留原 key 顺序,故本应用写入的数据可用字符串
tasks = normalizeTasks(seedTasks); // 相等判断“无需回写”;未归一化的旧版数据必然不等,会被归一化后回写。
persistTasks(); const unchanged = loaded.exists && loaded.raw === JSON.stringify(tasks);
markInitialized(); const writeOk = unchanged || persistTasks();
if (!isInitialized() && (loaded.exists || writeOk)) markInitialized();
} }
sortOrders = { ...DEFAULT_SORT_ORDERS, ...normalizeSortOrders(loadSortOrdersData()) }; sortOrders = { ...DEFAULT_SORT_ORDERS, ...normalizeSortOrders(loadSortOrdersData()) };
showHidden = Object.fromEntries( showHidden = Object.fromEntries(
+58 -21
View File
@@ -32,7 +32,6 @@ import {
setSortOrder, setSortOrder,
updateTask, updateTask,
} from '../src/js/store.js'; } from '../src/js/store.js';
import { createSeedTasks } from '../src/js/seed-data.js';
const base = (id, title, extra = {}) => ({ const base = (id, title, extra = {}) => ({
id, id,
@@ -46,7 +45,10 @@ const base = (id, title, extra = {}) => ({
...extra, ...extra,
}); });
beforeEach(() => localStorage.clear()); beforeEach(() => {
localStorage.clear();
consumeStorageWriteFailure();
});
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
describe('task model', () => { describe('task model', () => {
@@ -153,28 +155,62 @@ describe('due dates', () => {
}); });
}); });
describe('seed data', () => {
it('creates five unique tasks at the initialization time', () => {
const now = new Date('2026-07-19T02:54:00.000Z');
const tasks = createSeedTasks(now);
expect(tasks).toHaveLength(5);
expect(new Set(tasks.map((task) => task.id)).size).toBe(5);
expect(tasks.every((task) => task.createdDate === now.toISOString())).toBe(true);
});
});
describe('storage and store compatibility', () => { describe('storage and store compatibility', () => {
it('distinguishes a missing task key from an intentionally empty task list', () => { it('distinguishes a missing task key from an intentionally empty task list', () => {
expect(loadTasksData()).toMatchObject({ tasks: [], corrupted: false, exists: false }); expect(loadTasksData()).toMatchObject({ tasks: [], corrupted: false, exists: false });
localStorage.setItem('tasks', '[]'); localStorage.setItem('tasks', '[]');
expect(loadTasksData()).toMatchObject({ tasks: [], corrupted: false, exists: true }); expect(loadTasksData()).toMatchObject({ tasks: [], corrupted: false, exists: true });
}); });
it('preserves corrupted raw data without replacing it with seed tasks', () => { it('backfills tasksInitialized for legacy installs that already have tasks', () => {
localStorage.setItem('tasks', JSON.stringify([base(1, 'Legacy')]));
expect(isInitialized()).toBe(false);
initializeStore();
expect(isInitialized()).toBe(true);
expect(getTasks()).toMatchObject([{ id: 1, title: 'Legacy' }]);
});
it('starts with an empty task list on first use', () => {
initializeStore();
expect(getTasks()).toEqual([]);
expect(localStorage.getItem('tasks')).toBe('[]');
expect(isInitialized()).toBe(true);
expect(consumeStorageWriteFailure()).toBe(false);
});
it('skips the redundant rewrite when stored tasks are already normalized', () => {
initializeStore();
addTask(base(1, 'Kept'));
const setItem = vi.spyOn(Storage.prototype, 'setItem');
initializeStore();
const taskWrites = setItem.mock.calls.filter(([key]) => key === 'tasks');
expect(taskWrites).toHaveLength(0);
expect(getTasks()).toMatchObject([{ id: 1, title: 'Kept' }]);
});
it('does not mark initialized when first persist fails', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('full', 'QuotaExceededError');
});
initializeStore();
expect(getTasks()).toEqual([]);
expect(localStorage.getItem('tasks')).toBeNull();
expect(isInitialized()).toBe(false);
expect(consumeStorageWriteFailure()).toBe(true);
});
it('attempts no write at all when storage is unavailable', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new DOMException('blocked', 'SecurityError');
});
const setItem = vi.spyOn(Storage.prototype, 'setItem');
initializeStore();
expect(getTasks()).toEqual([]);
expect(setItem).not.toHaveBeenCalled();
expect(consumeStorageWriteFailure()).toBe(false);
});
it('preserves corrupted raw data without replacing it', () => {
localStorage.setItem('tasks', '{broken'); localStorage.setItem('tasks', '{broken');
initializeStore([base(1, 'Seed')], false); initializeStore();
expect(getTasks()).toEqual([]); expect(getTasks()).toEqual([]);
expect(localStorage.getItem('tasks')).toBe('{broken'); expect(localStorage.getItem('tasks')).toBe('{broken');
expect(localStorage.getItem('tasksCorruptedBackup')).toBe('{broken'); expect(localStorage.getItem('tasksCorruptedBackup')).toBe('{broken');
expect(isInitialized()).toBe(false);
}); });
it('restores all legacy preferences and task fields', () => { it('restores all legacy preferences and task fields', () => {
localStorage.setItem('tasks', JSON.stringify(legacyBackup.data.tasks)); localStorage.setItem('tasks', JSON.stringify(legacyBackup.data.tasks));
@@ -183,8 +219,9 @@ describe('storage and store compatibility', () => {
localStorage.setItem('showHiddenTodoTasks', 'false'); localStorage.setItem('showHiddenTodoTasks', 'false');
localStorage.setItem('tasksInitialized', 'true'); localStorage.setItem('tasksInitialized', 'true');
localStorage.setItem('tasksCorruptedBackup', '{historical backup'); localStorage.setItem('tasksCorruptedBackup', '{historical backup');
initializeStore([], isInitialized()); initializeStore();
expect(getTasks()).toMatchObject(legacyBackup.data.tasks); expect(getTasks()).toMatchObject(legacyBackup.data.tasks);
expect(JSON.parse(localStorage.getItem('tasks'))).toEqual(getTasks());
expect(getSortOrders()).toEqual(legacyBackup.data.sortOrders); expect(getSortOrders()).toEqual(legacyBackup.data.sortOrders);
expect(getShowHidden('completed')).toBe(true); expect(getShowHidden('completed')).toBe(true);
expect(getShowHidden('todo')).toBe(false); expect(getShowHidden('todo')).toBe(false);
@@ -193,7 +230,7 @@ describe('storage and store compatibility', () => {
expect(localStorage.getItem('tasksCorruptedBackup')).toBe('{historical backup'); expect(localStorage.getItem('tasksCorruptedBackup')).toBe('{historical backup');
}); });
it('maintains store ids and rejects invalid preference mutations', () => { it('maintains store ids and rejects invalid preference mutations', () => {
initializeStore([], true); initializeStore();
const first = addTask(base(1, 'First')); const first = addTask(base(1, 'First'));
const duplicate = addTask(base(1, 'Second')); const duplicate = addTask(base(1, 'Second'));
const [appended] = appendTasks([base(first.id, 'Third')]); const [appended] = appendTasks([base(first.id, 'Third')]);
@@ -213,7 +250,7 @@ describe('storage and store compatibility', () => {
vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new DOMException('full', 'QuotaExceededError'); throw new DOMException('full', 'QuotaExceededError');
}); });
expect(() => initializeStore([base(1, 'Seed')], false)).not.toThrow(); expect(() => initializeStore()).not.toThrow();
expect(consumeStorageWriteFailure()).toBe(true); expect(consumeStorageWriteFailure()).toBe(true);
expect(consumeStorageWriteFailure()).toBe(false); expect(consumeStorageWriteFailure()).toBe(false);
}); });
@@ -237,7 +274,7 @@ describe('imports', () => {
Modal: { getOrCreateInstance: () => ({ hide: vi.fn(), show: vi.fn() }) }, Modal: { getOrCreateInstance: () => ({ hide: vi.fn(), show: vi.fn() }) },
}; };
document.body.innerHTML = '<div id="importModal"></div>'; document.body.innerHTML = '<div id="importModal"></div>';
initializeStore([], true); initializeStore();
}); });
it('validates the legacy export fixture', () => { it('validates the legacy export fixture', () => {
expect(validateImportData(legacyBackup)).toBe(true); expect(validateImportData(legacyBackup)).toBe(true);
@@ -296,7 +333,7 @@ describe('imports', () => {
}); });
describe('timeline entries', () => { describe('timeline entries', () => {
beforeEach(() => initializeStore([], true)); beforeEach(() => initializeStore());
it('ignores fields that the change set omits', () => { it('ignores fields that the change set omits', () => {
const task = addTask(base(1, 'Untouched', { assignee: 'Ada', dueDate: '2025-01-01' })); const task = addTask(base(1, 'Untouched', { assignee: 'Ada', dueDate: '2025-01-01' }));
@@ -367,7 +404,7 @@ describe('board rendering', () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = fixture(); document.body.innerHTML = fixture();
initializeStore([], true); initializeStore();
}); });
it('lists timeline entries newest first within a single day', () => { it('lists timeline entries newest first within a single day', () => {
@@ -474,7 +511,7 @@ describe('export', () => {
original.createObjectURL = URL.createObjectURL; original.createObjectURL = URL.createObjectURL;
original.revokeObjectURL = URL.revokeObjectURL; original.revokeObjectURL = URL.revokeObjectURL;
document.body.innerHTML = ''; document.body.innerHTML = '';
initializeStore([], true); initializeStore();
}); });
afterEach(() => { afterEach(() => {
URL.createObjectURL = original.createObjectURL; URL.createObjectURL = original.createObjectURL;