Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 27 additions & 22 deletions app/_components/FeatureComponents/Kanban/KanbanCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const KanbanCardComponent = ({
const [showTimeEntriesModal, setShowTimeEntriesModal] = useState(false);
const [showStatusSheet, setShowStatusSheet] = useState(false);
const hideMobileStatusDropdown = user?.hideMobileStatusDropdown === "enable";
const hideTimeTrackingOnCards = user?.hideTimeTrackingOnCards === "enable";

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const kanbanItemHook = useKanbanItem({
checklist,
Expand Down Expand Up @@ -157,7 +158,7 @@ const KanbanCardComponent = ({
isOpen={showTimeEntriesModal}
onClose={() => setShowTimeEntriesModal(false)}
timeEntries={item.timeEntries}
checklistId={checklist.uuid || checklistId}
checklistId={checklistId}
itemId={item.id}
category={category}
onUpdate={onUpdate}
Expand Down Expand Up @@ -255,29 +256,33 @@ const KanbanCardComponent = ({
)}
</div>

<KanbanItemTimer
totalTime={kanbanItemHook.totalTime}
currentTime={kanbanItemHook.currentTime}
isRunning={kanbanItemHook.isRunning}
formatTimerTime={formatTimerTime}
onTimerToggle={kanbanItemHook.handleTimerToggle}
onAddManualTime={kanbanItemHook.handleAddManualTime}
/>

{item.timeEntries && item.timeEntries.length > 0 && (
<div onPointerDown={(e) => e.stopPropagation()}>
<TimeEntriesAccordion
timeEntries={item.timeEntries}
totalTime={
kanbanItemHook.totalTime + kanbanItemHook.currentTime
}
{!hideTimeTrackingOnCards && (
<>
<KanbanItemTimer
totalTime={kanbanItemHook.totalTime}
currentTime={kanbanItemHook.currentTime}
isRunning={kanbanItemHook.isRunning}
formatTimerTime={formatTimerTime}
usersPublicData={usersPublicData}
formatDateString={formatDateString}
formatTimeString={formatTimeString}
onOpenTimeEntries={() => setShowTimeEntriesModal(true)}
onTimerToggle={kanbanItemHook.handleTimerToggle}
onAddManualTime={kanbanItemHook.handleAddManualTime}
/>
</div>

{item.timeEntries && item.timeEntries.length > 0 && (
<div onPointerDown={(e) => e.stopPropagation()}>
<TimeEntriesAccordion
timeEntries={item.timeEntries}
totalTime={
kanbanItemHook.totalTime + kanbanItemHook.currentTime
}
formatTimerTime={formatTimerTime}
usersPublicData={usersPublicData}
formatDateString={formatDateString}
formatTimeString={formatTimeString}
onOpenTimeEntries={() => setShowTimeEntriesModal(true)}
/>
</div>
)}
</>
)}

{item.recurrence && (
Expand Down
78 changes: 67 additions & 11 deletions app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ import { useTranslations } from "next-intl";
import { KanbanPriorityLevel } from "@/app/_types/enums";
import { KanbanCardDetailProperties } from "./KanbanCardDetailProperties";
import { KanbanCardDetailSubtasks } from "./KanbanCardDetailSubtasks";
import { KanbanItemTimer } from "./KanbanItemTimer";
import { TimeEntriesAccordion } from "./TimeEntriesAccordion";
import { TimeEntriesModal } from "./TimeEntriesModal";
import { useKanbanItem } from "@/app/_hooks/kanban/useKanbanItem";
import { useAppMode } from "@/app/_providers/AppModeProvider";
import { formatTimerTime } from "@/app/_utils/kanban/index";
import { DEFAULT_KANBAN_STATUSES } from "@/app/_consts/kanban";

interface KanbanCardDetailProps {
Expand Down Expand Up @@ -78,7 +84,9 @@ export const KanbanCardDetail = ({
}: KanbanCardDetailProps) => {
const t = useTranslations();
const { permissions } = usePermissions();
const { formatDateTimeString } = usePreferredDateTime();
const { usersPublicData } = useAppMode();
const { formatDateTimeString, formatDateString, formatTimeString } =
usePreferredDateTime();
const statuses = checklist.statuses || DEFAULT_KANBAN_STATUSES;
const defaultStatusId =
[...statuses].sort((a, b) => a.order - b.order)[0]?.id || "";
Expand All @@ -98,6 +106,15 @@ export const KanbanCardDetail = ({
const [estimatedTimeInput, setEstimatedTimeInput] = useState(initialItem.estimatedTime?.toString() || "");
const [availableUsers, setAvailableUsers] = useState<{ username: string; avatarUrl?: string }[]>([]);
const [boardIsShared, setBoardIsShared] = useState(false);
const [showTimeEntriesModal, setShowTimeEntriesModal] = useState(false);

const kanbanItemHook = useKanbanItem({
checklist,
item,
checklistId,
category,
onUpdate,
});

useEffect(() => {
setItem(initialItem);
Expand Down Expand Up @@ -379,16 +396,53 @@ export const KanbanCardDetail = ({
await _saveField({ estimatedTime: hours.toString() });
};

const timeTrackingContent = (
<div className="space-y-2">
<KanbanItemTimer
totalTime={kanbanItemHook.totalTime}
currentTime={kanbanItemHook.currentTime}
isRunning={kanbanItemHook.isRunning}
formatTimerTime={formatTimerTime}
onTimerToggle={kanbanItemHook.handleTimerToggle}
onAddManualTime={kanbanItemHook.handleAddManualTime}
/>
{item.timeEntries && item.timeEntries.length > 0 && (
<TimeEntriesAccordion
timeEntries={item.timeEntries}
totalTime={kanbanItemHook.totalTime + kanbanItemHook.currentTime}
formatTimerTime={formatTimerTime}
usersPublicData={usersPublicData}
formatDateString={formatDateString}
formatTimeString={formatTimeString}
onOpenTimeEntries={() => setShowTimeEntriesModal(true)}
/>
)}
</div>
);

return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={item.text || t("checklists.untitledTask")}
size="fullscreen"
allowEnlarge
defaultEnlarged
className="lg:!max-w-[80vw] lg:!w-full lg:!h-[80vh] lg:!max-h-[80vh] max-h-[min(90dvh,100dvh)]"
>
<>
{showTimeEntriesModal && item.timeEntries && (
<TimeEntriesModal
isOpen={showTimeEntriesModal}
onClose={() => setShowTimeEntriesModal(false)}
timeEntries={item.timeEntries}
checklistId={checklistId}
itemId={item.id}
category={category}
onUpdate={onUpdate}
usersPublicData={usersPublicData}
/>
)}
Comment on lines +425 to +436

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tie TimeEntriesModal lifecycle to the parent detail modal.

Right now, closing the main detail modal does not guarantee the time-entries modal closes. Gate it by isOpen (and/or reset state when isOpen becomes false) so it can’t remain orphaned.

Suggested fix
+  useEffect(() => {
+    if (!isOpen) setShowTimeEntriesModal(false);
+  }, [isOpen]);
+
   return (
     <>
-      {showTimeEntriesModal && item.timeEntries && (
+      {isOpen && showTimeEntriesModal && item.timeEntries && (
         <TimeEntriesModal
           isOpen={showTimeEntriesModal}
           onClose={() => setShowTimeEntriesModal(false)}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx` around lines
425 - 436, The TimeEntriesModal can become orphaned when the parent detail modal
closes because its visibility is not tied to the parent's isOpen state. Add a
useEffect hook that resets showTimeEntriesModal to false whenever isOpen becomes
false, ensuring the time-entries modal closes with its parent. Additionally,
update the conditional render of TimeEntriesModal to include isOpen in the
condition (change the condition from showTimeEntriesModal && item.timeEntries to
isOpen && showTimeEntriesModal && item.timeEntries) so the modal cannot render
when the parent detail modal is closed.

<Modal
isOpen={isOpen}
onClose={onClose}
title={item.text || t("checklists.untitledTask")}
size="fullscreen"
allowEnlarge
defaultEnlarged
className="lg:!max-w-[80vw] lg:!w-full lg:!h-[80vh] lg:!max-h-[80vh] max-h-[min(90dvh,100dvh)]"
>
<div className="kanban-card-detail-body flex min-h-0 flex-1 flex-col gap-6 lg:flex-row lg:overflow-hidden">
<div className="min-w-0 space-y-4 p-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
{isEditing ? (
Expand Down Expand Up @@ -545,9 +599,11 @@ export const KanbanCardDetail = ({
onEstimatedTimeSave={handleEstimatedTimeSave}
formatDateTimeString={formatDateTimeString}
onStatusChange={handleStatusChange}
timeTracking={timeTrackingContent}
/>
</div>
</div>
</Modal>
</Modal>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ interface KanbanCardDetailPropertiesProps {
onEstimatedTimeChange: (v: string) => void;
onEstimatedTimeSave: () => void;
formatDateTimeString: (v: string) => string;
timeTracking?: ReactNode;
}

interface PropertySectionProps {
Expand Down Expand Up @@ -104,6 +105,7 @@ export const KanbanCardDetailProperties = ({
onEstimatedTimeChange,
onEstimatedTimeSave,
formatDateTimeString,
timeTracking,
}: KanbanCardDetailPropertiesProps) => {
const t = useTranslations();

Expand Down Expand Up @@ -288,6 +290,12 @@ export const KanbanCardDetailProperties = ({
</>
)}

{timeTracking && (
<PropertySection title={t("kanban.timeTracking")}>
{timeTracking}
</PropertySection>
)}

{metadata.length > 0 && (
<PropertySection
title={t("auditLogs.metadata")}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
HideConnectionIndicator,
HideStatusOnCards,
HideMobileStatusDropdown,
HideTimeTrackingOnCards,
CodeBlockStyle,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} from "@/app/_types";
import { Modes } from "@/app/_types/enums";
Expand Down Expand Up @@ -75,6 +76,7 @@ const getSettingsFromUser = (user: SanitisedUser | null): Partial<SanitisedUser>
hideConnectionIndicator: user?.hideConnectionIndicator || "disable",
hideStatusOnCards: user?.hideStatusOnCards || "disable",
hideMobileStatusDropdown: user?.hideMobileStatusDropdown || "disable",
hideTimeTrackingOnCards: user?.hideTimeTrackingOnCards || "disable",
codeBlockStyle: user?.codeBlockStyle || "default",
});

Expand Down Expand Up @@ -171,6 +173,7 @@ export const UserPreferencesTab = ({ noteCategories, localeOptions }: SettingsTa
const hasKanbanChanges = hasChanges([
"hideStatusOnCards",
"hideMobileStatusDropdown",
"hideTimeTrackingOnCards",
]);

const validateAndSave = async <T extends Record<string, any>>(
Expand Down Expand Up @@ -952,7 +955,11 @@ export const UserPreferencesTab = ({ noteCategories, localeOptions }: SettingsTa
<Button
onClick={() =>
handleSaveSection(
["hideStatusOnCards", "hideMobileStatusDropdown"],
[
"hideStatusOnCards",
"hideMobileStatusDropdown",
"hideTimeTrackingOnCards",
],
kanbanSettingsSchema,
"Kanban"
)
Expand Down Expand Up @@ -1008,6 +1015,30 @@ export const UserPreferencesTab = ({ noteCategories, localeOptions }: SettingsTa
{t('settings.hideMobileStatusDropdownDescription')}
</p>
</div>

<div className="space-y-2">
<Label htmlFor="hide-time-tracking-on-cards">
{t('settings.hideTimeTrackingOnCardsLabel')}
</Label>
<Dropdown
value={currentSettings.hideTimeTrackingOnCards || "disable"}
onChange={(value) =>
handleSettingChange(
"hideTimeTrackingOnCards",
value as HideTimeTrackingOnCards
)
}
options={[
{ id: "disable", name: t('settings.showTimeTrackingOnCards') },
{ id: "enable", name: t('settings.hideTimeTrackingOnCards') },
]}
placeholder={t('settings.selectTimeTrackingOnCards')}
className="w-full"
/>
<p className="text-md lg:text-sm text-muted-foreground">
{t('settings.hideTimeTrackingOnCardsDescription')}
</p>
</div>
</FormWrapper>

<FormWrapper title={t('settings.accountManagement')}>
Expand Down
48 changes: 48 additions & 0 deletions app/_hooks/kanban/useKanbanItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { useToast } from "@/app/_providers/ToastProvider";
const TIMER_STORAGE_KEY = (checklistId: string, itemId: string) =>
`jotty-timer-${checklistId}-${itemId}`;

const TIMER_SYNC_EVENT = "jotty-timer-sync";

interface UseKanbanItemProps {
item: Item;
checklist: Checklist;
Expand Down Expand Up @@ -79,8 +81,54 @@ export const useKanbanItem = ({
} else {
localStorage.removeItem(storageKey);
}
// The same item can mount this hook twice at once (card + detail modal).
// The "storage" event only fires across tabs, so broadcast in-tab too.
window.dispatchEvent(
new CustomEvent(TIMER_SYNC_EVENT, { detail: { key: storageKey } })
);
}, [isRunning, startTime, checklistId, item.id]);

useEffect(() => {
const storageKey = TIMER_STORAGE_KEY(checklistId, item.id);

const _syncFromStorage = () => {
let parsed: { startTime?: string; isRunning?: boolean } | null = null;
try {
const stored = localStorage.getItem(storageKey);
parsed = stored ? JSON.parse(stored) : null;
} catch {
parsed = null;
}

if (parsed?.isRunning && parsed.startTime) {
const storedStart = new Date(parsed.startTime);
setStartTime((prev) =>
prev?.getTime() === storedStart.getTime() ? prev : storedStart
);
setIsRunning((prev) => (prev ? prev : true));
setCurrentTime(Math.floor((Date.now() - storedStart.getTime()) / 1000));
} else {
setIsRunning((prev) => (prev ? false : prev));
setStartTime((prev) => (prev ? null : prev));
setCurrentTime((prev) => (prev ? 0 : prev));
}
};

const _handleStorage = (e: StorageEvent) => {
if (e.key === storageKey) _syncFromStorage();
};
const _handleSync = (e: Event) => {
if ((e as CustomEvent).detail?.key === storageKey) _syncFromStorage();
};

window.addEventListener("storage", _handleStorage);
window.addEventListener(TIMER_SYNC_EVENT, _handleSync);
return () => {
window.removeEventListener("storage", _handleStorage);
window.removeEventListener(TIMER_SYNC_EVENT, _handleSync);
};
}, [checklistId, item.id]);

useEffect(() => {
let interval: NodeJS.Timeout;
if (isRunning && startTime) {
Expand Down
6 changes: 6 additions & 0 deletions app/_schemas/user-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ export const kanbanSettingsSchema = z.object({
"Hide mobile status dropdown must be either 'enable' or 'disable'",
})
.optional(),
hideTimeTrackingOnCards: z
.enum(["enable", "disable"], {
message:
"Hide time tracking on cards must be either 'enable' or 'disable'",
})
.optional(),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

export const fileSettingsSchema = z.object({
Expand Down
16 changes: 6 additions & 10 deletions app/_server/actions/kanban/time-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,10 @@ export const editTimeEntry = async (formData: FormData) => {
const endTime = formData.get("endTime") as string;
const duration = formData.get("duration") as string;

const [currentUser, list] = await Promise.all([
getCurrentUser(),
getListById(listId, undefined, category),
]);

const currentUser = await getCurrentUser();
if (!currentUser) return { error: "Not authenticated" };

const list = await getListById(listId, currentUser.username, category);
if (!list) return { error: "List not found" };

const canEdit = await checkUserPermission(
Expand Down Expand Up @@ -107,12 +105,10 @@ export const deleteTimeEntry = async (formData: FormData) => {
"listId", "itemId", "entryId", "category",
]);

const [currentUser, list] = await Promise.all([
getCurrentUser(),
getListById(listId, undefined, category),
]);

const currentUser = await getCurrentUser();
if (!currentUser) return { error: "Not authenticated" };

const list = await getListById(listId, currentUser.username, category);
if (!list) return { error: "List not found" };

const canEdit = await checkUserPermission(
Expand Down
Loading
Loading