Skip to content
Open
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
29 changes: 29 additions & 0 deletions apps/frontend/src/components/hooks/use-mobile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client';

import { useEffect, useState } from 'react';

/**
* Hook to detect mobile viewport (≀768px)
* Returns true when viewport width is 768px or less
*/
export function useMobile() {
const [isMobile, setIsMobile] = useState(false);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The useMobile hook causes a React hydration mismatch on mobile devices by initializing isMobile to false on the server and only updating it on the client after hydration.
Severity: MEDIUM

Suggested Fix

To prevent the hydration mismatch, ensure the component renders the same content on the server and the initial client render. Defer setting the isMobile state until the component has mounted. One way is to introduce another state variable to track mounting, and only set isMobile based on window.innerWidth inside a useEffect hook. This ensures the mobile-specific view is rendered only after the initial hydration is complete.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: apps/frontend/src/components/hooks/use-mobile.tsx#L10

Potential issue: The `useMobile` hook initializes its state with `const [isMobile,
setIsMobile] = useState(false)`. During server-side rendering (SSR), this causes the
desktop version of components like `WeekView` and `MonthView` to be rendered. On a
mobile device, after the initial client-side hydration, a `useEffect` hook runs and
updates `isMobile` to `true` based on `window.innerWidth`. This change triggers a
re-render with a different component tree (e.g., `MobileWeekView`), causing a React
hydration mismatch error. This results in a visual flash as the UI switches from the
desktop to the mobile layout and can lead to temporarily broken user interactions on
initial page load for mobile users.

Did we get this right? πŸ‘ / πŸ‘Ž to inform future reviews.


useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
};

// Initial check
checkMobile();

// Listen for resize events
window.addEventListener('resize', checkMobile);

return () => {
window.removeEventListener('resize', checkMobile);
};
}, []);

return isMobile;
}
218 changes: 216 additions & 2 deletions apps/frontend/src/components/launches/calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Integrations,
useCalendar,
} from '@gitroom/frontend/components/launches/calendar.context';
import { useMobile } from '@gitroom/frontend/components/hooks/use-mobile';
import dayjs from 'dayjs';
import 'dayjs/locale/en';
import 'dayjs/locale/he';
Expand Down Expand Up @@ -318,9 +319,109 @@ export const DayView = () => {
</div>
);
};
// Mobile-friendly week view that shows posts as cards grouped by day
export const MobileWeekView = () => {
const { startDate, posts, integrations } = useCalendar();
const t = useT();
const { editPost, deletePost, openStatistics, openMissingRelease } = usePostActions();

// Generate days for the week
const weekDays = useMemo(() => {
const days = [];
const weekStart = newDayjs(startDate);
for (let i = 0; i < 7; i++) {
const day = weekStart.add(i, 'day');
days.push({
name: day.format('dddd'),
date: day.format('L'),
dayjs: day,
});
}
return days;
}, [startDate]);

// Group posts by day
const postsByDay = useMemo(() => {
const grouped: { [key: string]: any[] } = {};
weekDays.forEach((day) => {
const dateKey = day.dayjs.format('YYYY-MM-DD');
grouped[dateKey] = posts.filter((post) => {
const postDate = dayjs.utc(post.publishDate).local();
return postDate.format('YYYY-MM-DD') === dateKey;
});
});
return grouped;
}, [posts, weekDays]);

return (
<div className="flex flex-col text-textColor flex-1 overflow-auto scrollbar scrollbar-thumb-fifth scrollbar-track-newBgColor">
<div className="px-[16px] py-[12px]">
{weekDays.map((day) => {
const dateKey = day.dayjs.format('YYYY-MM-DD');
const dayPosts = postsByDay[dateKey] || [];
const isToday = day.date === newDayjs().format('L');

return (
<div key={dateKey} className="mb-[20px]">
{/* Day header */}
<div
className={clsx(
'text-[16px] font-[600] mb-[12px] pb-[8px] border-b border-newTableBorder flex items-center gap-[8px]',
isToday && 'text-newTableTextFocused'
)}
>
{isToday && (
<div className="w-[8px] h-[8px] bg-newTableTextFocused rounded-full" />
)}
<span>{day.name}</span>
<span className="text-[14px] font-[400] text-newTableText">
{day.date}
</span>
</div>

{/* Posts for this day */}
{dayPosts.length === 0 ? (
<div className="text-center py-[16px] text-newTextColor/40 text-[14px]">
{t('no_posts_scheduled', 'No posts scheduled')}
</div>
) : (
<div className="flex flex-col gap-[12px]">
{dayPosts.map((post) => (
<CalendarItem
key={post.id}
display="day"
isBeforeNow={dayjs.utc(post.publishDate).local().isBefore(newDayjs())}
date={dayjs.utc(post.publishDate).local()}
state={post.state}
statistics={openStatistics(post.id)}
missingRelease={openMissingRelease(post.id)}
editPost={editPost(post, false)}
duplicatePost={editPost(post, true)}
post={post}
integrations={integrations}
deletePost={deletePost(post)}
showTime={true}
/>
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
};

export const WeekView = () => {
const { startDate, endDate } = useCalendar();
const t = useT();
const isMobile = useMobile();

// Use mobile view on small screens
if (isMobile) {
return <MobileWeekView />;
}

// Use dayjs to get localized day names
const localizedDays = useMemo(() => {
Expand Down Expand Up @@ -390,9 +491,118 @@ export const WeekView = () => {
</div>
);
};
// Mobile-friendly month view showing posts as list grouped by day
export const MobileMonthView = () => {
const { startDate, posts, integrations } = useCalendar();
const t = useT();
const { editPost, deletePost, openStatistics, openMissingRelease } = usePostActions();

const calendarDays = useMemo(() => {
const monthStart = newDayjs(startDate);
const currentMonth = monthStart.month();
const currentYear = monthStart.year();

const startOfMonth = newDayjs(new Date(currentYear, currentMonth, 1));
const endOfMonth = startOfMonth.endOf('month');

const calendarDays = [];
let currentDay = startOfMonth;

while (currentDay.isSameOrBefore(endOfMonth)) {
calendarDays.push(currentDay);
currentDay = currentDay.add(1, 'day');
}

return calendarDays;
}, [startDate]);

// Group posts by day
const postsByDay = useMemo(() => {
const grouped: { [key: string]: any[] } = {};
calendarDays.forEach((day) => {
const dateKey = day.format('YYYY-MM-DD');
grouped[dateKey] = posts.filter((post) => {
const postDate = dayjs.utc(post.publishDate).local();
return postDate.format('YYYY-MM-DD') === dateKey;
});
});
return grouped;
}, [posts, calendarDays]);

return (
<div className="flex flex-col text-textColor flex-1 overflow-auto scrollbar scrollbar-thumb-fifth scrollbar-track-newBgColor">
<div className="px-[16px] py-[12px]">
{calendarDays.map((day) => {
const dateKey = day.format('YYYY-MM-DD');
const dayPosts = postsByDay[dateKey] || [];
const isToday = day.format('L') === newDayjs().format('L');

// Only show days that have posts
if (dayPosts.length === 0) {
return null;
}

return (
<div key={dateKey} className="mb-[20px]">
{/* Day header */}
<div
className={clsx(
'text-[16px] font-[600] mb-[12px] pb-[8px] border-b border-newTableBorder flex items-center gap-[8px]',
isToday && 'text-newTableTextFocused'
)}
>
{isToday && (
<div className="w-[8px] h-[8px] bg-newTableTextFocused rounded-full" />
)}
<span>{day.format('dddd')}</span>
<span className="text-[14px] font-[400] text-newTableText">
{day.format('L')}
</span>
</div>

{/* Posts for this day */}
<div className="flex flex-col gap-[12px]">
{dayPosts.map((post) => (
<CalendarItem
key={post.id}
display="day"
isBeforeNow={dayjs.utc(post.publishDate).local().isBefore(newDayjs())}
date={dayjs.utc(post.publishDate).local()}
state={post.state}
statistics={openStatistics(post.id)}
missingRelease={openMissingRelease(post.id)}
editPost={editPost(post, false)}
duplicatePost={editPost(post, true)}
post={post}
integrations={integrations}
deletePost={deletePost(post)}
showTime={true}
/>
))}
</div>
</div>
);
})}

{calendarDays.every((day) => (postsByDay[day.format('YYYY-MM-DD')] || []).length === 0) && (
<div className="text-center py-[32px] text-newTextColor/40 text-[14px]">
{t('no_posts_this_month', 'No posts scheduled this month')}
</div>
)}
</div>
</div>
);
};

export const MonthView = () => {
const { startDate } = useCalendar();
const t = useT();
const isMobile = useMobile();

// Use mobile view on small screens
if (isMobile) {
return <MobileMonthView />;
}

// Use dayjs to get localized day names
const localizedDays = useMemo(() => {
Expand Down Expand Up @@ -564,6 +774,7 @@ export const CalendarColumn: FC<{
const { getDate, randomHour } = props;
const [num, setNum] = useState(0);
const user = useUser();
const isMobile = useMobile();
const {
integrations,
posts,
Expand Down Expand Up @@ -632,8 +843,9 @@ export const CalendarColumn: FC<{
}, []);
const [{ canDrop }, drop] = useDrop(() => ({
accept: 'post',
canDrop: () => !isMobile, // Disable drag-and-drop on mobile
drop: async (item: any) => {
if (isBeforeNow) return;
if (isBeforeNow || isMobile) return;

// Find the post to check its state
const post = posts.find((p) => p.id === item.id);
Expand Down Expand Up @@ -964,6 +1176,7 @@ const CalendarItem: FC<{
};
}> = memo((props) => {
const t = useT();
const isMobile = useMobile();
const {
editPost,
statistics,
Expand All @@ -984,6 +1197,7 @@ const CalendarItem: FC<{
const [{ opacity }, dragRef] = useDrag(
() => ({
type: 'post',
canDrag: () => !isMobile, // Disable dragging on mobile
item: {
id: post.id,
interval: !!post.intervalInDays,
Expand All @@ -993,7 +1207,7 @@ const CalendarItem: FC<{
opacity: monitor.isDragging() ? 0 : 1,
}),
}),
[]
[isMobile]
);
return (
<div
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/components/launches/time.table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export const TimeTable: FC<{
}, [currentTimes]);

return (
<div className="relative w-full max-w-[400px] mx-auto">
<div className="relative w-full max-w-[400px] mobile:max-w-full mx-auto mobile:px-[16px]">
{/* Add Time Slot Section */}
<div className="bg-newBgColorInner rounded-[12px] p-[20px] border border-newTableBorder">
<div className="text-[15px] font-semibold mb-[16px] flex items-center gap-[8px]">
Expand Down