feat: keep five editable starter templates
This commit is contained in:
1 parent
c9d0785c81
commit
b57c2edc64
7 files changed
+134
-26
No files matched your search
@@ -22,13 +22,36 @@ describe("editable template migration", () => {
|
||||
`).run();
|
||||
|
||||
sqlite.exec(migration("0030_unify_editable_note_templates.sql"));
|
||||
sqlite.exec(migration("0031_limit_default_memo_templates.sql"));
|
||||
|
||||
const clean = sqlite.query(`SELECT id, name FROM memo_templates WHERE workspace_id = ? ORDER BY id`).all("ws_clean");
|
||||
const edited = sqlite.query(`SELECT id, name FROM memo_templates WHERE workspace_id = ? ORDER BY id`).all("ws_edited");
|
||||
expect(clean).toHaveLength(6);
|
||||
expect(clean).toHaveLength(5);
|
||||
expect(clean.some((row) => row.id === "ws_clean_template_project_weekly")).toBe(false);
|
||||
expect(edited).toHaveLength(7);
|
||||
expect(edited).toHaveLength(6);
|
||||
expect(edited.some((row) => row.name === "我的项目周报")).toBe(true);
|
||||
expect(clean.every((row) => !row.name.includes("内置"))).toBe(true);
|
||||
});
|
||||
|
||||
test("preserves a user-edited post-mortem template", () => {
|
||||
const sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE workspaces (id TEXT PRIMARY KEY);
|
||||
INSERT INTO workspaces (id) VALUES ('ws_edited');
|
||||
`);
|
||||
sqlite.exec(migration("0016_templates.sql"));
|
||||
sqlite.exec(migration("0030_unify_editable_note_templates.sql"));
|
||||
sqlite.query(`
|
||||
UPDATE memo_templates
|
||||
SET name = '我的事故复盘', updated_at = '2099-01-01T00:00:00.000Z'
|
||||
WHERE id = 'ws_edited_template_post-mortem'
|
||||
`).run();
|
||||
|
||||
sqlite.exec(migration("0031_limit_default_memo_templates.sql"));
|
||||
|
||||
const templates = sqlite.query(`SELECT id, name FROM memo_templates WHERE workspace_id = ? ORDER BY id`).all("ws_edited");
|
||||
expect(templates).toHaveLength(6);
|
||||
expect(templates.some((row) => row.name === "我的事故复盘")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -57,7 +57,7 @@ describe("workspace provisioning", () => {
|
||||
expect(calls[0].sql).toContain("INSERT OR IGNORE INTO memo_templates");
|
||||
expect(calls[0].values).toContain("ws_new_template_quick-note");
|
||||
expect(calls[0].values).toContain("Quick Spark");
|
||||
expect(calls.filter((call) => call.sql.includes("INSERT OR IGNORE INTO memo_templates"))).toHaveLength(6);
|
||||
expect(calls.filter((call) => call.sql.includes("INSERT OR IGNORE INTO memo_templates"))).toHaveLength(5);
|
||||
expect(calls.some((call) => call.sql.includes("INSERT OR IGNORE INTO ai_prompt_templates"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,56 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("web repository offline boundaries", () => {
|
||||
test("reconciles stale template cache entries while preserving queued local edits", async () => {
|
||||
const previousOnline = globalThis.navigator?.onLine;
|
||||
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: true });
|
||||
const restoreWindow = installTestWindow();
|
||||
const scope = "https://demo.edgeever.org|user-1";
|
||||
const template = (id, name, updatedAt) => ({
|
||||
id,
|
||||
name,
|
||||
description: null,
|
||||
title: name,
|
||||
contentJson: { type: "doc", content: [] },
|
||||
contentMarkdown: "",
|
||||
tags: [],
|
||||
createdAt: updatedAt,
|
||||
updatedAt,
|
||||
});
|
||||
const stale = template("template-stale", "Stale", "2026-01-01T00:00:00.000Z");
|
||||
const edited = template("template-edited", "Local edit", "2026-01-03T00:00:00.000Z");
|
||||
const remoteEdited = template("template-edited", "Remote old", "2026-01-02T00:00:00.000Z");
|
||||
const remote = template("template-remote", "Remote", "2026-01-04T00:00:00.000Z");
|
||||
await localDb.templates.bulkPut([{ ...stale, scope }, { ...edited, scope }]);
|
||||
await localDb.syncQueue.put({
|
||||
id: `action:${scope}:template.update:${edited.id}`,
|
||||
kind: "template.update",
|
||||
scope,
|
||||
memoId: edited.id,
|
||||
status: "pending",
|
||||
payload: { templateId: edited.id, name: edited.name },
|
||||
attemptCount: 0,
|
||||
lastError: null,
|
||||
nextAttemptAt: null,
|
||||
claimId: null,
|
||||
createdAt: edited.updatedAt,
|
||||
updatedAt: edited.updatedAt,
|
||||
});
|
||||
const originalListTemplates = api.listTemplates;
|
||||
api.listTemplates = async () => ({ templates: [remote, remoteEdited] });
|
||||
|
||||
try {
|
||||
const result = await createWebRepository(scope).listTemplates();
|
||||
expect(result.templates.map((item) => item.id)).toEqual([remote.id, edited.id]);
|
||||
expect(result.templates.find((item) => item.id === edited.id)?.name).toBe("Local edit");
|
||||
expect(await localDb.templates.get([scope, stale.id])).toBeUndefined();
|
||||
} finally {
|
||||
api.listTemplates = originalListTemplates;
|
||||
restoreWindow();
|
||||
if (globalThis.navigator) Object.defineProperty(globalThis.navigator, "onLine", { configurable: true, value: previousOnline });
|
||||
}
|
||||
});
|
||||
|
||||
test("saves memo edits locally while deferring remote synchronization", async () => {
|
||||
const restoreWindow = installTestWindow();
|
||||
let immediateEvents = 0;
|
||||
|
||||
@@ -106,6 +106,44 @@ const hasActiveLocalMemoUpdate = async (memoId: string) => {
|
||||
return isActiveLocalMemoUpdateStatus(item?.status);
|
||||
};
|
||||
|
||||
const reconcileLocalTemplates = async (
|
||||
scope: string,
|
||||
remoteTemplates: MemoTemplate[],
|
||||
): Promise<MemoTemplate[]> => {
|
||||
const [local, queuedItems] = await Promise.all([
|
||||
listLocalTemplates(scope),
|
||||
localDb.syncQueue.filter((item) => item.scope === scope && item.kind.startsWith("template.")).toArray(),
|
||||
]);
|
||||
const localById = new Map(local.templates.map((template) => [template.id, template]));
|
||||
const pendingUpsertIds = new Set(
|
||||
queuedItems
|
||||
.filter((item) => item.kind === "template.create" || item.kind === "template.update")
|
||||
.map((item) => item.memoId),
|
||||
);
|
||||
const pendingDeleteIds = new Set(
|
||||
queuedItems.filter((item) => item.kind === "template.delete").map((item) => item.memoId),
|
||||
);
|
||||
const reconciled = new Map<string, MemoTemplate>();
|
||||
|
||||
for (const remote of remoteTemplates) {
|
||||
if (pendingDeleteIds.has(remote.id)) continue;
|
||||
reconciled.set(remote.id, pendingUpsertIds.has(remote.id) ? localById.get(remote.id) ?? remote : remote);
|
||||
}
|
||||
for (const templateId of pendingUpsertIds) {
|
||||
const localTemplate = localById.get(templateId);
|
||||
if (localTemplate) reconciled.set(templateId, localTemplate);
|
||||
}
|
||||
|
||||
const templates = [...reconciled.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
await localDb.transaction("rw", localDb.templates, async () => {
|
||||
await localDb.templates.where("scope").equals(scope).delete();
|
||||
if (templates.length > 0) {
|
||||
await localDb.templates.bulkPut(templates.map((template) => ({ ...template, scope })));
|
||||
}
|
||||
});
|
||||
return templates;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persist and surface a remote detail only when it is strictly fresher than the
|
||||
* local mirror (and no memo.update is still waiting to push). Returns whether
|
||||
@@ -187,13 +225,13 @@ export const createWebRepository = (scope: string): EdgeEverRepository => {
|
||||
|
||||
async listTemplates() {
|
||||
const local = await listLocalTemplates(scope);
|
||||
if (local.templates.length > 0 || isOffline()) {
|
||||
if (!isOffline()) void api.listTemplates().then((remote) => Promise.all(remote.templates.map((template) => putLocalTemplate(scope, template)))).catch(() => {});
|
||||
if (isOffline()) return local;
|
||||
try {
|
||||
const remote = await api.listTemplates();
|
||||
return { templates: await reconcileLocalTemplates(scope, remote.templates) };
|
||||
} catch {
|
||||
return local;
|
||||
}
|
||||
const remote = await api.listTemplates();
|
||||
await Promise.all(remote.templates.map((template) => putLocalTemplate(scope, template)));
|
||||
return remote;
|
||||
},
|
||||
createTemplate: async (input) => {
|
||||
const template = await createLocalTemplate(scope, input);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- Five starter templates cover the most common workflows. Remove only the
|
||||
-- untouched post-mortem preset; edited copies remain ordinary user templates.
|
||||
DELETE FROM memo_templates
|
||||
WHERE id = workspace_id || '_template_post-mortem'
|
||||
AND name = '问题排查与复盘'
|
||||
AND description = '记录故障现象、根因分析 (5 Whys) 与防范机制。'
|
||||
AND title = '问题排查与复盘'
|
||||
AND updated_at = created_at;
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
} from "./memo-template-seeds.ts";
|
||||
|
||||
describe("memo template seeds", () => {
|
||||
test("provides six unique editable starter templates in both supported languages", () => {
|
||||
expect(DEFAULT_MEMO_TEMPLATE_SEEDS).toHaveLength(6);
|
||||
expect(new Set(DEFAULT_MEMO_TEMPLATE_SEEDS.map((seed) => seed.key)).size).toBe(6);
|
||||
test("provides five unique editable starter templates in both supported languages", () => {
|
||||
expect(DEFAULT_MEMO_TEMPLATE_SEEDS).toHaveLength(5);
|
||||
expect(new Set(DEFAULT_MEMO_TEMPLATE_SEEDS.map((seed) => seed.key)).size).toBe(5);
|
||||
expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], "zh-CN").title).toBe("灵感速记");
|
||||
expect(localizeMemoTemplateSeed(DEFAULT_MEMO_TEMPLATE_SEEDS[0], "en-GB").title).toBe("Quick Spark");
|
||||
});
|
||||
|
||||
@@ -7,8 +7,8 @@ export type MemoTemplateSeedTranslation = {
|
||||
};
|
||||
|
||||
export type MemoTemplateSeed = {
|
||||
key: "quick-note" | "meeting" | "weekly-review" | "reading" | "okr" | "post-mortem";
|
||||
translationKey: "quickNote" | "meeting" | "weeklyReview" | "reading" | "okr" | "postMortem";
|
||||
key: "quick-note" | "meeting" | "weekly-review" | "reading" | "okr";
|
||||
translationKey: "quickNote" | "meeting" | "weeklyReview" | "reading" | "okr";
|
||||
tag: string;
|
||||
translations: Record<MemoTemplateSeedLocale, MemoTemplateSeedTranslation>;
|
||||
};
|
||||
@@ -88,19 +88,6 @@ export const DEFAULT_MEMO_TEMPLATE_SEEDS: readonly MemoTemplateSeed[] = [
|
||||
contentMarkdown: "# 🎯 Goal Breakdown\n\n- **Period**:\n- **Owner**:\n\n---\n\n## 📌 Objective\n\n> \n\n## 📈 Key Results\n\n- **KR 1**: Target metric -> Current progress\n- **KR 2**: Target metric -> Current progress\n\n## 🗓️ Milestones\n\n- [ ] **Phase 1 (Date)**: Target\n- [ ] **Phase 2 (Date)**: Target\n\n## 📋 Execution Checklist\n\n- [ ] \n- [ ] \n",
|
||||
},
|
||||
),
|
||||
seed(
|
||||
{ key: "post-mortem", translationKey: "postMortem", tag: "post-mortem" },
|
||||
{
|
||||
title: "问题排查与复盘",
|
||||
description: "记录故障现象、根因分析 (5 Whys) 与防范机制。",
|
||||
contentMarkdown: "# 🔍 问题排查与复盘 (Post-mortem)\n\n- **发生时间**:\n- **影响范围**:\n- **处理状态**:已解决 / 处理中\n\n---\n\n## 🚨 故障现象与影响\n\n\n\n## 🛠️ 排查过程与解决方案\n\n1. \n2. \n\n## 🔬 根因分析 (Root Cause / 5 Whys)\n\n- **根本原因**:\n\n## 🛡️ 预防措施 (Action Items)\n\n- [ ] **[短期规避]** \n- [ ] **[长期优化]** \n",
|
||||
},
|
||||
{
|
||||
title: "Problem & Post-mortem",
|
||||
description: "Document incident symptoms, 5-Whys root cause, and preventive measures.",
|
||||
contentMarkdown: "# 🔍 Post-mortem & Problem Investigation\n\n- **Occurred At**:\n- **Impact Scope**:\n- **Status**: Resolved / In-Progress\n\n---\n\n## 🚨 Symptoms & Impact\n\n\n\n## 🛠️ Investigation & Fix Steps\n\n1. \n2. \n\n## 🔬 Root Cause Analysis (5 Whys)\n\n- **Root Cause**:\n\n## 🛡️ Action Items & Prevention\n\n- [ ] **[Short-term]** \n- [ ] **[Long-term]** \n",
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
export const normalizeMemoTemplateSeedLocale = (
|
||||
|
||||
Reference in new issue
Block a user