refactor: move project star state from localStorage to backend

This commit is contained in:
Haileyesus
2026-04-25 21:08:16 +03:00
parent 7a82fb54dc
commit 113c7631b8
9 changed files with 301 additions and 32 deletions

View File

@@ -0,0 +1,78 @@
import { projectsDb } from '@/modules/database/index.js';
import { AppError } from '@/shared/utils.js';
type ToggleProjectStarResult = {
isStarred: boolean;
};
type ApplyLegacyStarredProjectIdsResult = {
updated: number;
};
function normalizeProjectId(projectId: string): string {
return projectId.trim();
}
function uniqueProjectIds(projectIds: string[]): string[] {
const uniqueIds = new Set<string>();
for (const projectId of projectIds) {
const normalizedProjectId = normalizeProjectId(projectId);
if (!normalizedProjectId) {
continue;
}
uniqueIds.add(normalizedProjectId);
}
return [...uniqueIds];
}
/**
* Applies legacy `localStorage` stars keyed by DB `projectId` onto `projects.isStarred`.
*
* The operation is idempotent: already-starred projects are ignored, unknown ids are skipped.
*/
export function applyLegacyStarredProjectIds(projectIds: string[]): ApplyLegacyStarredProjectIdsResult {
const normalizedProjectIds = uniqueProjectIds(projectIds);
let updated = 0;
for (const projectId of normalizedProjectIds) {
const project = projectsDb.getProjectById(projectId);
if (!project) {
continue;
}
if (Boolean(project.isStarred)) {
continue;
}
projectsDb.updateProjectIsStarredById(projectId, true);
updated += 1;
}
return { updated };
}
/**
* Flips `projects.isStarred` for one project and returns the new state.
*/
export function toggleProjectStar(projectId: string): ToggleProjectStarResult {
const normalizedProjectId = normalizeProjectId(projectId);
if (!normalizedProjectId) {
throw new AppError('projectId is required', {
code: 'PROJECT_ID_REQUIRED',
statusCode: 400,
});
}
const project = projectsDb.getProjectById(normalizedProjectId);
if (!project) {
throw new AppError('Project not found', {
code: 'PROJECT_NOT_FOUND',
statusCode: 404,
});
}
const nextStarredState = !Boolean(project.isStarred);
projectsDb.updateProjectIsStarredById(normalizedProjectId, nextStarredState);
return { isStarred: nextStarredState };
}

View File

@@ -21,6 +21,7 @@ export type ProjectListItem = {
path: string;
displayName: string;
fullPath: string;
isStarred: boolean;
sessions: SessionSummary[];
cursorSessions: SessionSummary[];
codexSessions: SessionSummary[];
@@ -201,6 +202,7 @@ export async function getProjectsWithSessions(): Promise<ProjectListItem[]> {
project_id: string;
project_path: string;
custom_project_name?: string | null;
isStarred?: number;
}>;
const totalProjects = projectRows.length;
const projects: ProjectListItem[] = [];
@@ -233,6 +235,7 @@ export async function getProjectsWithSessions(): Promise<ProjectListItem[]> {
path: projectPath,
displayName,
fullPath: projectPath,
isStarred: Boolean(row.isStarred),
sessions: claudeSessions,
cursorSessions: sessionsByProvider.cursor,
codexSessions: sessionsByProvider.codex,

View File

@@ -0,0 +1,123 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { projectsDb } from '@/modules/database/index.js';
import { applyLegacyStarredProjectIds, toggleProjectStar } from '@/modules/projects/services/project-star.service.js';
import { AppError } from '@/shared/utils.js';
type ProjectRow = {
project_id: string;
project_path: string;
custom_project_name: string | null;
isStarred: number;
isArchived: number;
};
test('toggleProjectStar throws when projectId is missing', () => {
assert.throws(
() => toggleProjectStar(' '),
(error: unknown) =>
error instanceof AppError
&& error.code === 'PROJECT_ID_REQUIRED'
&& error.statusCode === 400,
);
});
test('toggleProjectStar throws when project does not exist', () => {
const originalGetProjectById = projectsDb.getProjectById;
try {
projectsDb.getProjectById = () => null;
assert.throws(
() => toggleProjectStar('project-1'),
(error: unknown) =>
error instanceof AppError
&& error.code === 'PROJECT_NOT_FOUND'
&& error.statusCode === 404,
);
} finally {
projectsDb.getProjectById = originalGetProjectById;
}
});
test('toggleProjectStar flips star state and persists it', () => {
const originalGetProjectById = projectsDb.getProjectById;
const originalUpdateProjectIsStarredById = projectsDb.updateProjectIsStarredById;
let capturedProjectId = '';
let capturedState = false;
try {
projectsDb.getProjectById = () =>
({
project_id: 'project-1',
project_path: '/workspace/project-1',
custom_project_name: 'project-1',
isStarred: 0,
isArchived: 0,
}) as ProjectRow;
projectsDb.updateProjectIsStarredById = (projectId: string, isStarred: boolean) => {
capturedProjectId = projectId;
capturedState = isStarred;
};
const result = toggleProjectStar('project-1');
assert.equal(result.isStarred, true);
assert.equal(capturedProjectId, 'project-1');
assert.equal(capturedState, true);
} finally {
projectsDb.getProjectById = originalGetProjectById;
projectsDb.updateProjectIsStarredById = originalUpdateProjectIsStarredById;
}
});
test('applyLegacyStarredProjectIds stars only valid, unstarred projects', () => {
const originalGetProjectById = projectsDb.getProjectById;
const originalUpdateProjectIsStarredById = projectsDb.updateProjectIsStarredById;
const updatedProjectIds: string[] = [];
try {
projectsDb.getProjectById = (projectId: string) => {
if (projectId === 'project-a') {
return {
project_id: 'project-a',
project_path: '/workspace/project-a',
custom_project_name: 'A',
isStarred: 0,
isArchived: 0,
} as ProjectRow;
}
if (projectId === 'project-b') {
return {
project_id: 'project-b',
project_path: '/workspace/project-b',
custom_project_name: 'B',
isStarred: 1,
isArchived: 0,
} as ProjectRow;
}
return null;
};
projectsDb.updateProjectIsStarredById = (projectId: string) => {
updatedProjectIds.push(projectId);
};
const result = applyLegacyStarredProjectIds([
'project-a',
'project-b',
'missing-project',
'project-a',
'',
' ',
]);
assert.equal(result.updated, 1);
assert.deepEqual(updatedProjectIds, ['project-a']);
} finally {
projectsDb.getProjectById = originalGetProjectById;
projectsDb.updateProjectIsStarredById = originalUpdateProjectIsStarredById;
}
});

View File

@@ -13,6 +13,7 @@ test('createProjectsSnapshot returns an object matching the predefined snapshot
path: '/tmp/project-1',
displayName: 'project-1',
fullPath: '/tmp/project-1',
isStarred: false,
sessions: [],
cursorSessions: [],
codexSessions: [],