From a0f59e2b8e42eed425daf9d91580b3dca15a8954 Mon Sep 17 00:00:00 2001 From: asepindrak Date: Mon, 24 Nov 2025 03:18:08 +0700 Subject: [PATCH 1/2] feat: add photo field to User model and update related services - Introduced a new 'photo' field in the User model to store user profile images. - Updated AuthService to include the 'photo' field in user data retrieval. - Modified ProjectManagementService to handle the 'photo' field when creating and updating team members. - Enhanced frontend components to support the new 'photo' field, including updates to ProjectManagement and ChatWindow components for better user experience. --- backend/prisma/schema.prisma | 1 + backend/src/auth/auth.service.ts | 2 + .../project-management.service.ts | 15 +- frontend/src/App.tsx | 39 +- frontend/src/components/ChatWindow.tsx | 9 +- frontend/src/components/EditProfileModal.tsx | 2 + frontend/src/components/ProjectManagement.tsx | 493 ++++++++++++++---- 7 files changed, 441 insertions(+), 120 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 87e3bbb..3353fb4 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -52,6 +52,7 @@ model User { email String? @unique phone String? name String? + photo String? role Role? @default(USER) password String? // kalau pake password session_token String? @unique diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index a5d37cf..309845d 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -78,6 +78,7 @@ export class AuthService { id: true, name: true, phone: true, + photo: true, email: true, members: true, }, @@ -141,6 +142,7 @@ export class AuthService { id: true, name: true, phone: true, + photo: true, email: true, members: true, password: true, diff --git a/backend/src/project-management/project-management.service.ts b/backend/src/project-management/project-management.service.ts index b28287e..1a2e4ca 100644 --- a/backend/src/project-management/project-management.service.ts +++ b/backend/src/project-management/project-management.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-base-to-string */ import { Injectable, NotFoundException, @@ -755,6 +756,7 @@ export class ProjectManagementService { email: email ?? null, password: hashed ?? null, phone: phone ?? null, + photo: photo ?? null, }, }); } @@ -762,10 +764,10 @@ export class ProjectManagementService { // create team member const tm = await tx.teamMember.create({ data: { - name: name ?? "Unnamed", + name: user.name ?? "Unnamed", role: role ?? null, - email: email ?? null, - photo: photo ?? null, + email: user.email ?? null, + photo: user.photo ? user.photo : photo ?? null, phone: phone ?? null, clientId: clientId ?? null, userId: user.id, @@ -818,6 +820,8 @@ export class ProjectManagementService { if (typeof payload.name !== "undefined") userData.name = payload.name; if (typeof payload.phone !== "undefined") userData.phone = payload.phone ?? null; + if (typeof payload.photo !== "undefined") + userData.photo = payload.photo ?? null; // password must be hashed if (typeof payload.password !== "undefined" && payload.password !== null) { userData.password = hashPassword(payload.password); @@ -844,10 +848,13 @@ export class ProjectManagementService { user = await tx.user.update({ where: { id: user.id }, data: userData, + include: { + members: true, + }, }); } } - + console.log(user); return { teamMember: updatedTeam, user }; }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c934627..ec78672 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,25 +2,37 @@ import Welcome from "./components/Welcome"; import AiAgent from "./components/AiAgent"; import ChatWindow from "./components/ChatWindow"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; import "react-toastify/dist/ReactToastify.css"; import { ToastContainer } from "react-toastify"; import ProjectManagement from "./components/ProjectManagement"; import AuthCard from "./components/Auth/AuthCard"; import { useAuthStore } from "./utils/store"; +import { playSound } from "./utils/playSound"; +import { getState, saveState } from "./utils/local"; function App() { const [isShow, setIsShow] = useState(false); const [isLogin, setIsLogin] = useState(false); const setAuth = useAuthStore((s) => s.setAuth); // ambil setter dari store - const initSession = useAuthStore((s) => s.initSession); const token = useAuthStore((s) => s.token); // untuk kontrol animasi splash logo const [showLogo, setShowLogo] = useState(true); + const KEY = "isPlaySound"; + + const [isPlaySound, setIsPlaySound] = useState(() => { + const fromLS = getState(KEY); + return fromLS ?? false; // fallback default false + }); + + useEffect(() => { + saveState(KEY, isPlaySound); + }, [isPlaySound]); + useEffect(() => { initSession(); }, [isLogin]); @@ -29,21 +41,13 @@ function App() { new Audio("/sounds/send.mp3").load(); new Audio("/sounds/incoming.mp3").load(); new Audio("/sounds/close.mp3").load(); - setTimeout(() => { - playSound("/sounds/send.mp3"); - }, 1000); + playSound("/sounds/send.mp3", isPlaySound); }, []); const onClose = () => { setIsShow(false); }; - const playSound = (src: string) => { - const audio = new Audio(src); - audio.volume = 0.2; - audio.play().catch(() => {}); - }; - const handleAuth = (r: any) => { // r adalah AuthResult dari backend: { token, userId, teamMemberId?, ... } if (!r || !r.token) { @@ -118,7 +122,10 @@ function App() { visible: { opacity: 1, y: 0, transition: { delay: 0.2 } }, }} > - + )} @@ -139,7 +146,13 @@ function App() { {!isShow && } - {isShow && } + {isShow && ( + + )} )} diff --git a/frontend/src/components/ChatWindow.tsx b/frontend/src/components/ChatWindow.tsx index 70ed8d9..260320f 100644 --- a/frontend/src/components/ChatWindow.tsx +++ b/frontend/src/components/ChatWindow.tsx @@ -19,9 +19,15 @@ import { playSound } from "../utils/playSound"; interface ChatWindowProps { onClose: () => void; + isPlaySound: boolean; + setIsPlaySound: (value: boolean) => void; } -export default function ChatWindow({ onClose }: ChatWindowProps) { +export default function ChatWindow({ + onClose, + isPlaySound, + setIsPlaySound, +}: ChatWindowProps) { const [input, setInput] = useState(""); const containerRef = useRef(null); const [isThinking, setIsThinking] = useState(false); @@ -33,7 +39,6 @@ export default function ChatWindow({ onClose }: ChatWindowProps) { const [placeholder, setPlaceholder] = useState(getRandomPlaceholder()); const [copied, setCopied] = useState(false); const [shared, setShared] = useState(false); - const [isPlaySound, setIsPlaySound] = useState(true); const [isLoading, setIsLoading] = useState(false); const [isMessagesReady, setIsMessagesReady] = useState(false); diff --git a/frontend/src/components/EditProfileModal.tsx b/frontend/src/components/EditProfileModal.tsx index 2df636f..0621624 100644 --- a/frontend/src/components/EditProfileModal.tsx +++ b/frontend/src/components/EditProfileModal.tsx @@ -305,6 +305,7 @@ export default function EditProfileModal({ setPassword(e.target.value)} placeholder="New password (leave blank to keep)" className="px-3 py-2 rounded-lg border bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-sky-300 dark:focus:ring-sky-600" @@ -321,6 +322,7 @@ export default function EditProfileModal({ setPasswordConfirm(e.target.value)} placeholder="Confirm new password" className="px-3 py-2 rounded-lg border bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-slate-900 dark:text-slate-100 focus:ring-2 focus:ring-sky-300 dark:focus:ring-sky-600" diff --git a/frontend/src/components/ProjectManagement.tsx b/frontend/src/components/ProjectManagement.tsx index ad0fbaa..9d51290 100644 --- a/frontend/src/components/ProjectManagement.tsx +++ b/frontend/src/components/ProjectManagement.tsx @@ -3,7 +3,14 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import Sidebar from "./Sidebar"; import TaskModal from "./TaskModal"; import type { Project, Task, TeamMember, Workspace } from "../types"; -import { Sun, Moon, PlusCircle, VolumeX, Volume2 } from "lucide-react"; +import { + Sun, + Moon, + PlusCircle, + VolumeX, + Volume2, + RefreshCw, +} from "lucide-react"; import TaskView from "./TaskView"; import ExportImportControls from "./ExportImportControls"; import { toast } from "react-toastify"; @@ -32,12 +39,20 @@ const queryClient = new QueryClient(); const nid = (x: any) => typeof x === "undefined" || x === null ? "" : String(x); -export default function ProjectManagement() { +export default function ProjectManagement({ + isPlaySound, + setIsPlaySound, +}: { + isPlaySound: boolean; + setIsPlaySound: (value: boolean) => void; +}) { const initialWorkspaceId = getState("workspaceId"); const initialProjectId = getState("projectId"); + const setAuth = useAuthStore((s) => s.setAuth); // ambil setter dari store const [isLoaded, setIsLoaded] = useState(false); - const [isPlaySound, setIsPlaySound] = useState(true); + const [syncing, setSyncing] = useState(false); + const [isRequestSync, setIsRequestSync] = useState(false); const initialTeam: TeamMember[] = normalizeTeamInput([]); const [team, setTeam] = useState(() => initialTeam); const [showProfileMenu, setShowProfileMenu] = useState(false); @@ -74,22 +89,20 @@ export default function ProjectManagement() { const userWorkspace = user?.members.filter( (item: any) => item.workspaceId === activeWorkspaceId ); - console.log("userWorkspace", userWorkspace); + const userWorkspaceActive = + userWorkspace.length > 0 ? userWorkspace[0] : user; + console.log("userWorkspace", userWorkspaceActive); + if (!userWorkspaceActive?.photo) { + userWorkspaceActive.photo = user?.photo ?? null; + } // ambil huruf pertama sebagai icon - const userInitial = ( - (userWorkspace && - userWorkspace.length > 0 && - userWorkspace[0]?.name?.slice(0, 1)) || - "U" - ).toUpperCase(); - const userPhoto = - (userWorkspace && userWorkspace.length > 0 && userWorkspace[0]?.photo) || - null; - // alert(userInitial); + const userInitial = user?.name?.slice(0, 1) || "U".toUpperCase(); + const userPhoto = user?.photo + ? user?.photo + : userWorkspaceActive.photo || null; console.log(userInitial); - const authTeamMemberId = - (userWorkspace && userWorkspace.length > 0 && userWorkspace[0]?.id) || null; + const authTeamMemberId = userWorkspaceActive.id || null; useEffect(() => { if (!authTeamMemberId) return; if (team && team.length > 0) return; @@ -144,12 +157,16 @@ export default function ProjectManagement() { const openEditProfile = async () => { setShowProfileMenu(false); - if (userWorkspace && userWorkspace.length > 0) { - setEditMember(userWorkspace[0] ?? null); - } + setEditMember(userWorkspaceActive ?? null); + setShowEditProfile(true); }; + const handleSync = async () => { + setSyncing(true); + setIsRequestSync(!isRequestSync); + }; + const handleSaveProfile = async (updated: TeamMember) => { setTeam((prev) => { const map = new Map(prev.map((p) => [p.id, p])); @@ -167,7 +184,17 @@ export default function ProjectManagement() { password: updated.password ?? null, }; const saved = await api.updateTeamMember(updated.id, payload); + console.log("saved", token); + console.log("saved", userId); + + setAuth({ + token: token ?? "", + userId: userId ?? "", + user: saved.user, + teamMemberId: teamMemberId ?? null, + }); setTeam((prev) => prev.map((t) => (t.id === saved.id ? saved : t))); + window.location.reload(); } catch (err) { try { enqueueOp({ @@ -210,12 +237,6 @@ export default function ProjectManagement() { width: 300, }); - useEffect(() => { - if (isLoaded) { - playSound("/sounds/close.mp3", isPlaySound); - } - }, [selectedTask]); - // panggil ini instead of onDragStart untuk pointer devices function startPointerDrag( id: string, @@ -476,26 +497,89 @@ export default function ProjectManagement() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeWorkspaceId]); + // helper: tentukan apakah error itu unrecoverable (tidak perlu retry) + function isUnrecoverableError(err: any) { + // sesuaikan tergantung shape error dari projectApi (axios, fetch, custom) + // contoh: axios: err?.response?.status + const status = err?.response?.status ?? err?.status; + const msg = (err?.message || "").toString(); + + // treat 400/404 as unrecoverable by default (adjust to your API semantics) + if (status === 400 || status === 404) return true; + + // match specific message text too (your log showed "Project not found") + if (/project not found/i.test(msg)) return true; + if (/task not found/i.test(msg)) return true; + + return false; + } + useEffect(() => { + const prefix = (...args: any[]) => + console.log("[SYNC]", new Date().toISOString(), ...args); + + prefix("useEffect mounted", { + activeProjectId, + activeWorkspaceId, + isRequestSync, + }); + const wsHandle = createRealtimeSocket( qcRef.current, () => activeProjectId, () => activeWorkspaceId ); + prefix("websocket handle created", { wsHandleExists: !!wsHandle }); + + let isFlushing = false; const attemptFlush = async () => { + if (isFlushing) { + prefix("attemptFlush skipped because another flush is running"); + return; + } + isFlushing = true; + prefix("attemptFlush start", { activeProjectId, activeWorkspaceId }); + try { - const q = getQueue(); - if (!q.length) return; + const rawQueue = getQueue(); + prefix("rawQueue read", { length: rawQueue?.length ?? 0, rawQueue }); + + if (!rawQueue || !rawQueue.length) { + prefix("queue empty - nothing to flush"); + return; + } + + // local mutable copy + const queue: any[] = [...rawQueue]; + + setSyncing(true); + prefix("setSyncing(true)"); + let processed = 0; - while (processed < 6) { - const curQueue = getQueue(); - if (!curQueue.length) break; - const op = curQueue[0]; + const MAX_PER_RUN = 6; + + while (processed < MAX_PER_RUN && queue.length) { + const op = queue[0]; + prefix("processing op", { + index: processed, + opType: op?.op, + opSnapshot: op, + currentActiveProjectId: activeProjectId, + currentActiveWorkspaceId: activeWorkspaceId, + queueLengthBefore: queue.length, + }); + try { if (op.op === "create_task") { const originalTmpId = op.payload?.id ?? op.payload?.clientId; const payload = { ...op.payload } as any; + + prefix("create_task - before normalize", { + originalTmpId, + payload, + }); + if ( payload && typeof payload.id === "string" && @@ -503,21 +587,33 @@ export default function ProjectManagement() { ) { payload.clientId = payload.id; delete payload.id; + prefix("create_task - normalized tmp id -> clientId", { + payload, + }); } + + prefix("API createTask CALL start", { payload }); const created = await api.createTask(payload); - const cur = getQueue(); - cur.shift(); - localStorage.setItem("cf_op_queue_v1", JSON.stringify(cur)); + prefix("API createTask CALL success", { created }); + + // remove first item from local queue + queue.shift(); + processed++; + + prefix("create_task - update remaining refs start", { + originalTmpId, + remainingBefore: queue.length + 1, + }); if (originalTmpId && typeof created?.id === "string") { - const remaining = getQueue(); let changed = false; - for (const rem of remaining) { + for (const rem of queue) { if ( rem.op === "update_task" && rem.payload && rem.payload.id === originalTmpId ) { + prefix("updating rem.update_task.id", { rem }); rem.payload.id = created.id; changed = true; } @@ -526,6 +622,7 @@ export default function ProjectManagement() { rem.payload && rem.payload.id === originalTmpId ) { + prefix("updating rem.delete_task.id", { rem }); rem.payload.id = created.id; changed = true; } @@ -534,30 +631,37 @@ export default function ProjectManagement() { rem.payload && rem.payload.taskId === originalTmpId ) { + prefix("updating rem.create_comment.taskId", { rem }); rem.payload.taskId = created.id; changed = true; } } - if (changed) - localStorage.setItem( - "cf_op_queue_v1", - JSON.stringify(remaining) - ); + prefix("create_task - update remaining refs done", { changed }); } } else if (op.op === "update_task") { + prefix("API updateTaskApi CALL start", { + id: op.payload.id, + patch: op.payload.patch, + }); await api.updateTaskApi(op.payload.id, op.payload.patch); - const cur = getQueue(); - cur.shift(); - localStorage.setItem("cf_op_queue_v1", JSON.stringify(cur)); + prefix("API updateTaskApi CALL success", { id: op.payload.id }); + queue.shift(); + processed++; } else if (op.op === "delete_task") { + prefix("API deleteTaskApi CALL start", { id: op.payload.id }); await api.deleteTaskApi(op.payload.id); - const cur = getQueue(); - cur.shift(); - localStorage.setItem("cf_op_queue_v1", JSON.stringify(cur)); + prefix("API deleteTaskApi CALL success", { id: op.payload.id }); + queue.shift(); + processed++; } else if (op.op === "create_project") { const originalTmpId = op.payload?.id ?? op.payload?.clientId; const payload = { ...op.payload } as any; + prefix("create_project - before normalize", { + originalTmpId, + payload, + }); + if ( payload && typeof payload.id === "string" && @@ -565,40 +669,55 @@ export default function ProjectManagement() { ) { payload.clientId = payload.id; delete payload.id; + prefix("create_project - normalized tmp id -> clientId", { + payload, + }); } const workspaceIdToUse = payload.workspaceId ?? activeWorkspaceId; + prefix("create_project - workspaceIdToUse computed", { + workspaceIdToUse, + activeWorkspaceId, + payloadWorkspaceId: op.payload.workspaceId, + }); + if (!workspaceIdToUse || typeof workspaceIdToUse !== "string") { console.warn( - "flushQueue: create_project missing workspaceId — will retry later", + "flushQueue: create_project missing workspaceId — skipping for now, will retry later", op ); - return; + prefix( + "create_project - MISSING workspaceId, will break and retry later", + { op } + ); + // don't remove this op; break to retry later + break; } payload.workspaceId = workspaceIdToUse; + prefix("API createProject CALL start", { payload }); const created = await api.createProject(payload); + prefix("API createProject CALL success", { created }); - const cur = getQueue(); - cur.shift(); - localStorage.setItem("cf_op_queue_v1", JSON.stringify(cur)); + queue.shift(); + processed++; if (originalTmpId && typeof created?.id === "string") { - const remaining = getQueue(); - let changed = false; - for (const rem of remaining) { + for (const rem of queue) { if (rem.payload && rem.payload.projectId === originalTmpId) { + prefix("updating rem.projectId -> created.id", { rem }); rem.payload.projectId = created.id; - changed = true; } if ( rem.op === "create_task" && rem.payload && rem.payload.projectId === originalTmpId ) { + prefix("updating rem.create_task.projectId -> created.id", { + rem, + }); rem.payload.projectId = created.id; - changed = true; } if ( rem.op === "update_task" && @@ -606,26 +725,29 @@ export default function ProjectManagement() { rem.payload.patch && rem.payload.patch.projectId === originalTmpId ) { + prefix( + "updating rem.update_task.patch.projectId -> created.id", + { rem } + ); rem.payload.patch.projectId = created.id; - changed = true; } } - if (changed) - localStorage.setItem( - "cf_op_queue_v1", - JSON.stringify(remaining) - ); } } else if (op.op === "update_project") { + prefix("API updateProjectApi CALL start", { + id: op.payload.id, + patch: op.payload.patch, + }); await api.updateProjectApi(op.payload.id, op.payload.patch); - const cur = getQueue(); - cur.shift(); - localStorage.setItem("cf_op_queue_v1", JSON.stringify(cur)); + prefix("API updateProjectApi success", { id: op.payload.id }); + queue.shift(); + processed++; } else if (op.op === "delete_project") { + prefix("API deleteProjectApi CALL start", { id: op.payload.id }); await api.deleteProjectApi(op.payload.id); - const cur = getQueue(); - cur.shift(); - localStorage.setItem("cf_op_queue_v1", JSON.stringify(cur)); + prefix("API deleteProjectApi success", { id: op.payload.id }); + queue.shift(); + processed++; } else if (op.op === "create_team") { const originalTmpId = op.payload?.id ?? op.payload?.clientId; const payload = { ...op.payload } as any; @@ -636,74 +758,186 @@ export default function ProjectManagement() { ) { payload.clientId = payload.id; delete payload.id; + prefix("create_team - normalized tmp id -> clientId", { + payload, + }); } + prefix("API createTeamMember CALL start", { payload }); const created = await api.createTeamMember(payload); - const cur = getQueue(); - cur.shift(); - localStorage.setItem("cf_op_queue_v1", JSON.stringify(cur)); + prefix("API createTeamMember success", { created }); + + queue.shift(); + processed++; if (originalTmpId && typeof created?.id === "string") { - const remaining = getQueue(); - let changed = false; - for (const rem of remaining) { + for (const rem of queue) { if ( rem.op === "update_task" && rem.payload && rem.payload.patch && rem.payload.patch.assigneeId === originalTmpId ) { + prefix( + "updating rem.update_task.patch.assigneeId -> created.id", + { rem } + ); rem.payload.patch.assigneeId = created.id; - changed = true; } } - if (changed) - localStorage.setItem( - "cf_op_queue_v1", - JSON.stringify(remaining) - ); } } else if (op.op === "delete_team") { + prefix("API deleteTeamMember CALL start", { id: op.payload.id }); await api.deleteTeamMember(op.payload.id); - const cur = getQueue(); - cur.shift(); - localStorage.setItem("cf_op_queue_v1", JSON.stringify(cur)); + prefix("API deleteTeamMember success", { id: op.payload.id }); + queue.shift(); + processed++; } else if (op.op === "create_comment") { + prefix("API createComment CALL start", { + taskId: op.payload.taskId, + preview: { author: op.payload.author, body: op.payload.body }, + }); await api.createComment(op.payload.taskId, { author: op.payload.author, body: op.payload.body, attachments: op.payload.attachments || [], }); - const cur = getQueue(); - cur.shift(); - localStorage.setItem("cf_op_queue_v1", JSON.stringify(cur)); + prefix("API createComment success", { + taskId: op.payload.taskId, + }); + queue.shift(); + processed++; } else { - console.warn("Unknown queued op", op); - const cur = getQueue(); - cur.shift(); - localStorage.setItem("cf_op_queue_v1", JSON.stringify(cur)); + prefix("Unknown queued op - shifting and continuing", { op }); + queue.shift(); + processed++; + } + + // Persist queue state after each successful op to localStorage to survive reloads/crashes + localStorage.setItem("cf_op_queue_v1", JSON.stringify(queue)); + prefix("persisted queue to localStorage", { + remaining: queue.length, + }); + } catch (err: any) { + // per-op failure (network/server). + // Decide whether to retry later (network/server error) or drop op (unrecoverable) + console.warn("flushQueue op failed (raw)", err, op); + prefix("op failed", { err: err?.message || err, op }); + + // attach and increment retryCount on the op (so we don't retry forever) + op.__retryCount = (op.__retryCount || 0) + 1; + + const UNRECOVERABLE = isUnrecoverableError(err); + + if (UNRECOVERABLE) { + // Remove the bad op — it won't succeed by retrying (e.g., project/task gone) + prefix("op considered UNRECOVERABLE -> removing from queue", { + op, + reason: err?.message ?? err, + }); + + // shift it off the local queue and persist + queue.shift(); + localStorage.setItem("cf_op_queue_v1", JSON.stringify(queue)); + + // Optionally: push to a dead-letter store so user/admin can review + try { + const dlKey = "cf_op_queue_v1_dead"; + const dlRaw = localStorage.getItem(dlKey); + const dlArr = dlRaw ? JSON.parse(dlRaw) : []; + dlArr.push({ + op, + error: err?.message ?? err, + timestamp: new Date().toISOString(), + }); + localStorage.setItem(dlKey, JSON.stringify(dlArr)); + } catch (e) { + console.warn("failed to write dead-letter", e); + } + + // continue processing next op (don't break) + continue; } - } catch (err) { - console.warn("flushQueue op failed", err, op); - return; + + // If recoverable, but retry count exceeded threshold -> move to dead-letter and continue + const RETRY_LIMIT = 4; + if (op.__retryCount >= RETRY_LIMIT) { + prefix("op exceeded retry limit -> moving to dead-letter", { + op, + retryCount: op.__retryCount, + }); + queue.shift(); + try { + const dlKey = "cf_op_queue_v1_dead"; + const dlRaw = localStorage.getItem(dlKey); + const dlArr = dlRaw ? JSON.parse(dlRaw) : []; + dlArr.push({ + op, + error: err?.message ?? err, + retryCount: op.__retryCount, + timestamp: new Date().toISOString(), + }); + localStorage.setItem(dlKey, JSON.stringify(dlArr)); + } catch (e) { + console.warn("failed to write dead-letter", e); + } + localStorage.setItem("cf_op_queue_v1", JSON.stringify(queue)); + continue; + } + + // If recoverable and under retry limit, persist the incremented retryCount and break to retry later. + prefix( + "op recoverable -> persisting retryCount and will retry later", + { + op, + retryCount: op.__retryCount, + } + ); + // persist current queue (with updated op.__retryCount) + localStorage.setItem("cf_op_queue_v1", JSON.stringify(queue)); + // break out so we don't hammer API this run; next run will attempt again + break; } - processed++; - } + } // end while + // finished processing up to MAX_PER_RUN items + prefix("syncing finish", { + processed, + remaining: queue.length, + }); + + // ensure remote cache invalidation if needed if (activeProjectId) { + prefix("invalidateQueries tasks for activeProjectId", { + activeProjectId, + }); qcRef.current.invalidateQueries(["tasks", activeProjectId]); } else { + prefix("invalidateQueries tasks (all)"); qcRef.current.invalidateQueries(["tasks"], { exact: false }); } if (activeWorkspaceId) { + prefix("invalidateQueries projects/team for activeWorkspaceId", { + activeWorkspaceId, + }); qcRef.current.invalidateQueries(["projects", activeWorkspaceId]); qcRef.current.invalidateQueries(["team", activeWorkspaceId]); } else { + prefix("invalidateQueries projects/team (all)"); qcRef.current.invalidateQueries(["projects"], { exact: false }); qcRef.current.invalidateQueries(["team"], { exact: false }); } } catch (e) { - // backend still down — will retry later + console.error("attemptFlush top-level error", e); + prefix("attemptFlush top-level error", e); + } finally { + isFlushing = false; + prefix("isFlushing set false"); + // keep a short delay so UI shows the syncing state visibly (optional) + setTimeout(() => { + setSyncing(false); + prefix("setSyncing(false)"); + }, 2000); } }; @@ -713,18 +947,30 @@ export default function ProjectManagement() { ); window.addEventListener("online", attemptFlush); - attemptFlush().catch(() => {}); + prefix("attemptFlush scheduled and initial call", { intervalMs: 7000 }); + attemptFlush().catch((err) => { + console.error("initial attemptFlush failed", err); + prefix("initial attemptFlush catch", err); + }); return () => { + prefix("useEffect cleanup start"); try { wsHandle?.close(); + prefix("wsHandle closed"); } catch (e) { - console.log(e); + console.warn("wsHandle close error", e); + prefix("wsHandle close error", e); + } + if (intervalId) { + clearInterval(intervalId); + prefix("cleared interval", { intervalId }); } - if (intervalId) clearInterval(intervalId); window.removeEventListener("online", attemptFlush); + prefix("removed online listener"); + prefix("useEffect cleanup done"); }; - }, [activeProjectId, activeWorkspaceId]); + }, [activeProjectId, activeWorkspaceId, isRequestSync]); useEffect(() => { try { @@ -1394,6 +1640,7 @@ export default function ProjectManagement() { for (const c of commentsForTask) { const payload = { author: c.author ?? c.Author ?? "Imported", + // eslint-disable-next-line no-constant-binary-expression body: c.body ?? c.Body ?? String(c) ?? "", attachments: c.attachments ?? c.Attachments ?? [], }; @@ -1475,6 +1722,7 @@ export default function ProjectManagement() { payload: { taskId: f.tmpId, author: c.author ?? "Imported", + // eslint-disable-next-line no-constant-binary-expression body: c.body ?? String(c) ?? "", attachments: c.attachments ?? [], }, @@ -1585,6 +1833,48 @@ export default function ProjectManagement() {
+