- Add Projects page with Sprint Board and Backlog views - Copy SprintBoard and BacklogView components to components/gantt/ - Copy useTaskStore for project/task/sprint management - Add API routes for task persistence with SQLite - Add UI components: dialog, select, table, textarea - Add avatar and attachment utilities - Update sidebar with Projects navigation link - Remove static export config to support API routes - Add dist to .gitignore
572 lines
20 KiB
TypeScript
572 lines
20 KiB
TypeScript
import Database from "better-sqlite3";
|
|
import { mkdirSync } from "fs";
|
|
import { join } from "path";
|
|
|
|
export interface TaskAttachment {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
size: number;
|
|
dataUrl: string;
|
|
uploadedAt: string;
|
|
}
|
|
|
|
export interface TaskComment {
|
|
id: string;
|
|
text: string;
|
|
createdAt: string;
|
|
author: TaskCommentAuthor | "user" | "assistant";
|
|
replies?: TaskComment[];
|
|
}
|
|
|
|
export interface TaskCommentAuthor {
|
|
id: string;
|
|
name: string;
|
|
email?: string;
|
|
avatarUrl?: string;
|
|
type: "human" | "assistant";
|
|
}
|
|
|
|
export interface Task {
|
|
id: string;
|
|
title: string;
|
|
description?: string;
|
|
type: "idea" | "task" | "bug" | "research" | "plan";
|
|
status: "open" | "todo" | "blocked" | "in-progress" | "review" | "validate" | "archived" | "canceled" | "done";
|
|
priority: "low" | "medium" | "high" | "urgent";
|
|
projectId: string;
|
|
sprintId?: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
createdById?: string;
|
|
createdByName?: string;
|
|
createdByAvatarUrl?: string;
|
|
updatedById?: string;
|
|
updatedByName?: string;
|
|
updatedByAvatarUrl?: string;
|
|
assigneeId?: string;
|
|
assigneeName?: string;
|
|
assigneeEmail?: string;
|
|
assigneeAvatarUrl?: string;
|
|
dueDate?: string;
|
|
comments: TaskComment[];
|
|
tags: string[];
|
|
attachments: TaskAttachment[];
|
|
}
|
|
|
|
export interface Project {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
color: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface Sprint {
|
|
id: string;
|
|
name: string;
|
|
goal?: string;
|
|
startDate: string;
|
|
endDate: string;
|
|
status: "planning" | "active" | "completed";
|
|
projectId: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface DataStore {
|
|
projects: Project[];
|
|
tasks: Task[];
|
|
sprints: Sprint[];
|
|
lastUpdated: number;
|
|
}
|
|
|
|
const DATA_DIR = join(process.cwd(), "data");
|
|
const DB_FILE = join(DATA_DIR, "tasks.db");
|
|
|
|
const defaultData: DataStore = {
|
|
projects: [
|
|
{ id: "1", name: "OpenClaw iOS", description: "Main iOS app development", color: "#8b5cf6", createdAt: new Date().toISOString() },
|
|
{ id: "2", name: "Web Projects", description: "Web tools and dashboards", color: "#3b82f6", createdAt: new Date().toISOString() },
|
|
{ id: "3", name: "Research", description: "Experiments and learning", color: "#10b981", createdAt: new Date().toISOString() },
|
|
],
|
|
tasks: [],
|
|
sprints: [],
|
|
lastUpdated: Date.now(),
|
|
};
|
|
|
|
type SqliteDb = InstanceType<typeof Database>;
|
|
|
|
let db: SqliteDb | null = null;
|
|
|
|
interface UserProfileLookup {
|
|
id: string;
|
|
name: string;
|
|
email?: string;
|
|
avatarUrl?: string;
|
|
}
|
|
|
|
function ensureTaskSchema(database: SqliteDb) {
|
|
const taskColumns = database.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
|
if (!taskColumns.some((column) => column.name === "attachments")) {
|
|
database.exec("ALTER TABLE tasks ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]';");
|
|
}
|
|
if (!taskColumns.some((column) => column.name === "createdById")) {
|
|
database.exec("ALTER TABLE tasks ADD COLUMN createdById TEXT;");
|
|
}
|
|
if (!taskColumns.some((column) => column.name === "createdByName")) {
|
|
database.exec("ALTER TABLE tasks ADD COLUMN createdByName TEXT;");
|
|
}
|
|
if (!taskColumns.some((column) => column.name === "createdByAvatarUrl")) {
|
|
database.exec("ALTER TABLE tasks ADD COLUMN createdByAvatarUrl TEXT;");
|
|
}
|
|
if (!taskColumns.some((column) => column.name === "updatedById")) {
|
|
database.exec("ALTER TABLE tasks ADD COLUMN updatedById TEXT;");
|
|
}
|
|
if (!taskColumns.some((column) => column.name === "updatedByName")) {
|
|
database.exec("ALTER TABLE tasks ADD COLUMN updatedByName TEXT;");
|
|
}
|
|
if (!taskColumns.some((column) => column.name === "updatedByAvatarUrl")) {
|
|
database.exec("ALTER TABLE tasks ADD COLUMN updatedByAvatarUrl TEXT;");
|
|
}
|
|
if (!taskColumns.some((column) => column.name === "assigneeId")) {
|
|
database.exec("ALTER TABLE tasks ADD COLUMN assigneeId TEXT;");
|
|
}
|
|
if (!taskColumns.some((column) => column.name === "assigneeName")) {
|
|
database.exec("ALTER TABLE tasks ADD COLUMN assigneeName TEXT;");
|
|
}
|
|
if (!taskColumns.some((column) => column.name === "assigneeEmail")) {
|
|
database.exec("ALTER TABLE tasks ADD COLUMN assigneeEmail TEXT;");
|
|
}
|
|
if (!taskColumns.some((column) => column.name === "assigneeAvatarUrl")) {
|
|
database.exec("ALTER TABLE tasks ADD COLUMN assigneeAvatarUrl TEXT;");
|
|
}
|
|
}
|
|
|
|
function safeParseArray<T>(value: string | null, fallback: T[]): T[] {
|
|
if (!value) return fallback;
|
|
try {
|
|
const parsed = JSON.parse(value);
|
|
return Array.isArray(parsed) ? (parsed as T[]) : fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function getUserLookup(database: SqliteDb): Map<string, UserProfileLookup> {
|
|
const hasUsersTable = database
|
|
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'users' LIMIT 1")
|
|
.get() as { 1: number } | undefined;
|
|
if (!hasUsersTable) return new Map();
|
|
|
|
try {
|
|
const rows = database
|
|
.prepare("SELECT id, name, email, avatarUrl FROM users")
|
|
.all() as Array<{ id: string; name: string; email: string | null; avatarUrl: string | null }>;
|
|
|
|
const lookup = new Map<string, UserProfileLookup>();
|
|
for (const row of rows) {
|
|
lookup.set(row.id, {
|
|
id: row.id,
|
|
name: row.name,
|
|
email: row.email ?? undefined,
|
|
avatarUrl: row.avatarUrl ?? undefined,
|
|
});
|
|
}
|
|
return lookup;
|
|
} catch {
|
|
return new Map();
|
|
}
|
|
}
|
|
|
|
function normalizeAttachments(attachments: unknown): TaskAttachment[] {
|
|
if (!Array.isArray(attachments)) return [];
|
|
|
|
return attachments
|
|
.map((attachment) => {
|
|
if (!attachment || typeof attachment !== "object") return null;
|
|
const value = attachment as Partial<TaskAttachment>;
|
|
const name = typeof value.name === "string" ? value.name.trim() : "";
|
|
const dataUrl = typeof value.dataUrl === "string" ? value.dataUrl : "";
|
|
if (!name || !dataUrl) return null;
|
|
|
|
return {
|
|
id: typeof value.id === "string" && value.id ? value.id : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
name,
|
|
type: typeof value.type === "string" ? value.type : "application/octet-stream",
|
|
size: typeof value.size === "number" && Number.isFinite(value.size) ? value.size : 0,
|
|
dataUrl,
|
|
uploadedAt: typeof value.uploadedAt === "string" && value.uploadedAt ? value.uploadedAt : new Date().toISOString(),
|
|
};
|
|
})
|
|
.filter((attachment): attachment is TaskAttachment => attachment !== null);
|
|
}
|
|
|
|
function normalizeComments(comments: unknown): TaskComment[] {
|
|
if (!Array.isArray(comments)) return [];
|
|
|
|
const normalized: TaskComment[] = [];
|
|
for (const entry of comments) {
|
|
if (!entry || typeof entry !== "object") continue;
|
|
const value = entry as Partial<TaskComment>;
|
|
if (typeof value.id !== "string" || typeof value.text !== "string") continue;
|
|
|
|
normalized.push({
|
|
id: value.id,
|
|
text: value.text,
|
|
createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date().toISOString(),
|
|
author: normalizeCommentAuthor(value.author),
|
|
replies: normalizeComments(value.replies),
|
|
});
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function normalizeCommentAuthor(author: unknown): TaskCommentAuthor {
|
|
if (author === "assistant") {
|
|
return { id: "assistant", name: "Assistant", type: "assistant" };
|
|
}
|
|
if (author === "user") {
|
|
return { id: "legacy-user", name: "User", type: "human" };
|
|
}
|
|
|
|
if (!author || typeof author !== "object") {
|
|
return { id: "legacy-user", name: "User", type: "human" };
|
|
}
|
|
|
|
const value = author as Partial<TaskCommentAuthor>;
|
|
const type: TaskCommentAuthor["type"] =
|
|
value.type === "assistant" || value.id === "assistant" ? "assistant" : "human";
|
|
const id = typeof value.id === "string" && value.id.trim().length > 0
|
|
? value.id
|
|
: type === "assistant"
|
|
? "assistant"
|
|
: "legacy-user";
|
|
const name = typeof value.name === "string" && value.name.trim().length > 0
|
|
? value.name.trim()
|
|
: type === "assistant"
|
|
? "Assistant"
|
|
: "User";
|
|
const email = typeof value.email === "string" && value.email.trim().length > 0 ? value.email.trim() : undefined;
|
|
const avatarUrl = typeof value.avatarUrl === "string" && value.avatarUrl.trim().length > 0 ? value.avatarUrl : undefined;
|
|
|
|
return { id, name, email, avatarUrl, type };
|
|
}
|
|
|
|
function normalizeTask(task: Partial<Task>): Task {
|
|
return {
|
|
id: String(task.id ?? Date.now()),
|
|
title: String(task.title ?? ""),
|
|
description: task.description || undefined,
|
|
type: (task.type as Task["type"]) ?? "task",
|
|
status: (task.status as Task["status"]) ?? "open",
|
|
priority: (task.priority as Task["priority"]) ?? "medium",
|
|
projectId: String(task.projectId ?? "2"),
|
|
sprintId: task.sprintId || undefined,
|
|
createdAt: task.createdAt || new Date().toISOString(),
|
|
updatedAt: task.updatedAt || new Date().toISOString(),
|
|
createdById: typeof task.createdById === "string" && task.createdById.trim().length > 0 ? task.createdById : undefined,
|
|
createdByName: typeof task.createdByName === "string" && task.createdByName.trim().length > 0 ? task.createdByName : undefined,
|
|
createdByAvatarUrl: typeof task.createdByAvatarUrl === "string" && task.createdByAvatarUrl.trim().length > 0 ? task.createdByAvatarUrl : undefined,
|
|
updatedById: typeof task.updatedById === "string" && task.updatedById.trim().length > 0 ? task.updatedById : undefined,
|
|
updatedByName: typeof task.updatedByName === "string" && task.updatedByName.trim().length > 0 ? task.updatedByName : undefined,
|
|
updatedByAvatarUrl: typeof task.updatedByAvatarUrl === "string" && task.updatedByAvatarUrl.trim().length > 0 ? task.updatedByAvatarUrl : undefined,
|
|
assigneeId: typeof task.assigneeId === "string" && task.assigneeId.trim().length > 0 ? task.assigneeId : undefined,
|
|
assigneeName: typeof task.assigneeName === "string" && task.assigneeName.trim().length > 0 ? task.assigneeName : undefined,
|
|
assigneeEmail: typeof task.assigneeEmail === "string" && task.assigneeEmail.trim().length > 0 ? task.assigneeEmail : undefined,
|
|
assigneeAvatarUrl: typeof task.assigneeAvatarUrl === "string" && task.assigneeAvatarUrl.trim().length > 0 ? task.assigneeAvatarUrl : undefined,
|
|
dueDate: task.dueDate || undefined,
|
|
comments: normalizeComments(task.comments),
|
|
tags: Array.isArray(task.tags) ? task.tags.filter((tag): tag is string => typeof tag === "string") : [],
|
|
attachments: normalizeAttachments(task.attachments),
|
|
};
|
|
}
|
|
|
|
function setLastUpdated(database: SqliteDb, value: number) {
|
|
database
|
|
.prepare(`
|
|
INSERT INTO meta (key, value)
|
|
VALUES ('lastUpdated', ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
`)
|
|
.run(String(value));
|
|
}
|
|
|
|
function getLastUpdated(database: SqliteDb): number {
|
|
const row = database.prepare("SELECT value FROM meta WHERE key = 'lastUpdated'").get() as { value?: string } | undefined;
|
|
const parsed = Number(row?.value ?? Date.now());
|
|
return Number.isFinite(parsed) ? parsed : Date.now();
|
|
}
|
|
|
|
function replaceAllData(database: SqliteDb, data: DataStore) {
|
|
const write = database.transaction((payload: DataStore) => {
|
|
database.exec("DELETE FROM projects;");
|
|
database.exec("DELETE FROM sprints;");
|
|
database.exec("DELETE FROM tasks;");
|
|
|
|
const insertProject = database.prepare(`
|
|
INSERT INTO projects (id, name, description, color, createdAt)
|
|
VALUES (@id, @name, @description, @color, @createdAt)
|
|
`);
|
|
const insertSprint = database.prepare(`
|
|
INSERT INTO sprints (id, name, goal, startDate, endDate, status, projectId, createdAt)
|
|
VALUES (@id, @name, @goal, @startDate, @endDate, @status, @projectId, @createdAt)
|
|
`);
|
|
const insertTask = database.prepare(`
|
|
INSERT INTO tasks (id, title, description, type, status, priority, projectId, sprintId, createdAt, updatedAt, createdById, createdByName, createdByAvatarUrl, updatedById, updatedByName, updatedByAvatarUrl, assigneeId, assigneeName, assigneeEmail, assigneeAvatarUrl, dueDate, comments, tags, attachments)
|
|
VALUES (@id, @title, @description, @type, @status, @priority, @projectId, @sprintId, @createdAt, @updatedAt, @createdById, @createdByName, @createdByAvatarUrl, @updatedById, @updatedByName, @updatedByAvatarUrl, @assigneeId, @assigneeName, @assigneeEmail, @assigneeAvatarUrl, @dueDate, @comments, @tags, @attachments)
|
|
`);
|
|
|
|
for (const project of payload.projects) {
|
|
insertProject.run({
|
|
id: project.id,
|
|
name: project.name,
|
|
description: project.description ?? null,
|
|
color: project.color,
|
|
createdAt: project.createdAt,
|
|
});
|
|
}
|
|
|
|
for (const sprint of payload.sprints) {
|
|
insertSprint.run({
|
|
id: sprint.id,
|
|
name: sprint.name,
|
|
goal: sprint.goal ?? null,
|
|
startDate: sprint.startDate,
|
|
endDate: sprint.endDate,
|
|
status: sprint.status,
|
|
projectId: sprint.projectId,
|
|
createdAt: sprint.createdAt,
|
|
});
|
|
}
|
|
|
|
for (const task of payload.tasks.map(normalizeTask)) {
|
|
insertTask.run({
|
|
...task,
|
|
sprintId: task.sprintId ?? null,
|
|
createdById: task.createdById ?? null,
|
|
createdByName: task.createdByName ?? null,
|
|
createdByAvatarUrl: task.createdByAvatarUrl ?? null,
|
|
updatedById: task.updatedById ?? null,
|
|
updatedByName: task.updatedByName ?? null,
|
|
updatedByAvatarUrl: task.updatedByAvatarUrl ?? null,
|
|
assigneeId: task.assigneeId ?? null,
|
|
assigneeName: task.assigneeName ?? null,
|
|
assigneeEmail: task.assigneeEmail ?? null,
|
|
assigneeAvatarUrl: task.assigneeAvatarUrl ?? null,
|
|
dueDate: task.dueDate ?? null,
|
|
comments: JSON.stringify(task.comments ?? []),
|
|
tags: JSON.stringify(task.tags ?? []),
|
|
attachments: JSON.stringify(task.attachments ?? []),
|
|
});
|
|
}
|
|
|
|
setLastUpdated(database, payload.lastUpdated || Date.now());
|
|
});
|
|
|
|
write(data);
|
|
}
|
|
|
|
function seedIfEmpty(database: SqliteDb) {
|
|
const counts = database
|
|
.prepare(
|
|
`
|
|
SELECT
|
|
(SELECT COUNT(*) FROM projects) AS projectsCount,
|
|
(SELECT COUNT(*) FROM sprints) AS sprintsCount,
|
|
(SELECT COUNT(*) FROM tasks) AS tasksCount
|
|
`
|
|
)
|
|
.get() as { projectsCount: number; sprintsCount: number; tasksCount: number };
|
|
|
|
if (counts.projectsCount > 0 || counts.sprintsCount > 0 || counts.tasksCount > 0) return;
|
|
replaceAllData(database, defaultData);
|
|
}
|
|
|
|
function getDb(): SqliteDb {
|
|
if (db) {
|
|
ensureTaskSchema(db);
|
|
return db;
|
|
}
|
|
|
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
const database = new Database(DB_FILE);
|
|
database.pragma("journal_mode = WAL");
|
|
database.exec(`
|
|
CREATE TABLE IF NOT EXISTS projects (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
description TEXT,
|
|
color TEXT NOT NULL,
|
|
createdAt TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS sprints (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
goal TEXT,
|
|
startDate TEXT NOT NULL,
|
|
endDate TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
projectId TEXT NOT NULL,
|
|
createdAt TEXT NOT NULL
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id TEXT PRIMARY KEY,
|
|
title TEXT NOT NULL,
|
|
description TEXT,
|
|
type TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
priority TEXT NOT NULL,
|
|
projectId TEXT NOT NULL,
|
|
sprintId TEXT,
|
|
createdAt TEXT NOT NULL,
|
|
updatedAt TEXT NOT NULL,
|
|
createdById TEXT,
|
|
createdByName TEXT,
|
|
createdByAvatarUrl TEXT,
|
|
updatedById TEXT,
|
|
updatedByName TEXT,
|
|
updatedByAvatarUrl TEXT,
|
|
assigneeId TEXT,
|
|
assigneeName TEXT,
|
|
assigneeEmail TEXT,
|
|
assigneeAvatarUrl TEXT,
|
|
dueDate TEXT,
|
|
comments TEXT NOT NULL DEFAULT '[]',
|
|
tags TEXT NOT NULL DEFAULT '[]',
|
|
attachments TEXT NOT NULL DEFAULT '[]'
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
`);
|
|
|
|
ensureTaskSchema(database);
|
|
|
|
seedIfEmpty(database);
|
|
db = database;
|
|
return database;
|
|
}
|
|
|
|
export function getData(): DataStore {
|
|
const database = getDb();
|
|
const usersById = getUserLookup(database);
|
|
|
|
const projects = database.prepare("SELECT * FROM projects ORDER BY createdAt ASC").all() as Array<{
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
color: string;
|
|
createdAt: string;
|
|
}>;
|
|
|
|
const sprints = database.prepare("SELECT * FROM sprints ORDER BY startDate ASC").all() as Array<{
|
|
id: string;
|
|
name: string;
|
|
goal: string | null;
|
|
startDate: string;
|
|
endDate: string;
|
|
status: Sprint["status"];
|
|
projectId: string;
|
|
createdAt: string;
|
|
}>;
|
|
|
|
const tasks = database.prepare("SELECT * FROM tasks ORDER BY createdAt ASC").all() as Array<{
|
|
id: string;
|
|
title: string;
|
|
description: string | null;
|
|
type: Task["type"];
|
|
status: Task["status"];
|
|
priority: Task["priority"];
|
|
projectId: string;
|
|
sprintId: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
createdById: string | null;
|
|
createdByName: string | null;
|
|
createdByAvatarUrl: string | null;
|
|
updatedById: string | null;
|
|
updatedByName: string | null;
|
|
updatedByAvatarUrl: string | null;
|
|
assigneeId: string | null;
|
|
assigneeName: string | null;
|
|
assigneeEmail: string | null;
|
|
assigneeAvatarUrl: string | null;
|
|
dueDate: string | null;
|
|
comments: string | null;
|
|
tags: string | null;
|
|
attachments: string | null;
|
|
}>;
|
|
|
|
return {
|
|
projects: projects.map((project) => ({
|
|
id: project.id,
|
|
name: project.name,
|
|
description: project.description ?? undefined,
|
|
color: project.color,
|
|
createdAt: project.createdAt,
|
|
})),
|
|
sprints: sprints.map((sprint) => ({
|
|
id: sprint.id,
|
|
name: sprint.name,
|
|
goal: sprint.goal ?? undefined,
|
|
startDate: sprint.startDate,
|
|
endDate: sprint.endDate,
|
|
status: sprint.status,
|
|
projectId: sprint.projectId,
|
|
createdAt: sprint.createdAt,
|
|
})),
|
|
tasks: tasks.map((task) => {
|
|
const createdByUser = task.createdById ? usersById.get(task.createdById) : undefined;
|
|
const updatedByUser = task.updatedById ? usersById.get(task.updatedById) : undefined;
|
|
const assigneeUser = task.assigneeId ? usersById.get(task.assigneeId) : undefined;
|
|
|
|
return {
|
|
id: task.id,
|
|
title: task.title,
|
|
description: task.description ?? undefined,
|
|
type: task.type,
|
|
status: task.status,
|
|
priority: task.priority,
|
|
projectId: task.projectId,
|
|
sprintId: task.sprintId ?? undefined,
|
|
createdAt: task.createdAt,
|
|
updatedAt: task.updatedAt,
|
|
createdById: task.createdById ?? undefined,
|
|
createdByName: task.createdByName ?? createdByUser?.name ?? undefined,
|
|
createdByAvatarUrl: createdByUser?.avatarUrl ?? task.createdByAvatarUrl ?? undefined,
|
|
updatedById: task.updatedById ?? undefined,
|
|
updatedByName: task.updatedByName ?? updatedByUser?.name ?? undefined,
|
|
updatedByAvatarUrl: updatedByUser?.avatarUrl ?? task.updatedByAvatarUrl ?? undefined,
|
|
assigneeId: task.assigneeId ?? undefined,
|
|
assigneeName: assigneeUser?.name ?? task.assigneeName ?? undefined,
|
|
assigneeEmail: assigneeUser?.email ?? task.assigneeEmail ?? undefined,
|
|
assigneeAvatarUrl: assigneeUser?.avatarUrl ?? undefined,
|
|
dueDate: task.dueDate ?? undefined,
|
|
comments: normalizeComments(safeParseArray(task.comments, [])),
|
|
tags: safeParseArray(task.tags, []),
|
|
attachments: normalizeAttachments(safeParseArray(task.attachments, [])),
|
|
};
|
|
}),
|
|
lastUpdated: getLastUpdated(database),
|
|
};
|
|
}
|
|
|
|
export function saveData(data: DataStore): DataStore {
|
|
const database = getDb();
|
|
const payload: DataStore = {
|
|
...data,
|
|
projects: data.projects ?? [],
|
|
sprints: data.sprints ?? [],
|
|
tasks: (data.tasks ?? []).map(normalizeTask),
|
|
lastUpdated: Date.now(),
|
|
};
|
|
|
|
replaceAllData(database, payload);
|
|
return getData();
|
|
}
|