feat: keep five editable starter templates

This commit is contained in:
tianma-if committed 2026-08-15 21:32:03 +08:00
1 parent c9d0785c81
commit b57c2edc64
7 files changed
+134 -26

No files matched your search

+25 -2
View File
@@ -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);
});
});
+1 -1
View File
@@ -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);
});
});
+50
View File
@@ -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;
+43 -5
View File
@@ -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");
});
+2 -15
View File
@@ -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 = (