diff --git a/backend/controller/campaign.go b/backend/controller/campaign.go index d1bae07..ad3fd62 100644 --- a/backend/controller/campaign.go +++ b/backend/controller/campaign.go @@ -337,6 +337,7 @@ func (c *Campaign) GetAllWithinDates(g *gin.Context) { companyID, &repository.CampaignOption{ QueryArgs: queryArgs, + WithCompany: true, WithCampaignTemplate: true, IncludeTestCampaigns: includeTestCampaigns, }, diff --git a/frontend/src/lib/components/CampaignCalendar.svelte b/frontend/src/lib/components/CampaignCalendar.svelte index 2c1ae54..692d894 100644 --- a/frontend/src/lib/components/CampaignCalendar.svelte +++ b/frontend/src/lib/components/CampaignCalendar.svelte @@ -4,8 +4,12 @@ format, addMonths, subMonths, + addWeeks, + subWeeks, startOfMonth, endOfMonth, + startOfWeek, + endOfWeek, startOfDay, endOfDay, addDays, @@ -17,6 +21,11 @@ } from 'date-fns'; import { scrollBarClassesHorizontal, scrollBarClassesVertical } from '$lib/utils/scrollbar'; + // localStorage keys + const STORAGE_KEY_VIEW_MODE = 'calendar_view_mode'; + const STORAGE_KEY_START_FROM_TODAY = 'calendar_start_from_today'; + const STORAGE_KEY_FILTERS = 'calendar_filters'; + /** @type * */ export let campaigns = []; /** @type {Date} */ @@ -26,12 +35,20 @@ export let onChangeDate; + /** @type {boolean} - when true, shows company name on campaign items (for global context) */ + export let showCompany = false; + let container; - let currentMonth = startOfMonth(new Date()); + let currentDate = new Date(); let isLoadingNewMonth = false; let weeks = []; let isInitialized = false; + // view options with localStorage persistence + /** @type {'month' | 'week'} */ + let viewMode = 'month'; + let startFromToday = false; + let activeFilters = { SCHEDULED: true, ACTIVE: true, @@ -41,7 +58,7 @@ let isGeneratingCalendar = false; - // Use consistent colors that match the design system - these work well in both light and dark modes + // use consistent colors that match the design system - these work well in both light and dark modes const COLORS = { SCHEDULED: '#62aded', // campaign-scheduled ACTIVE: '#5557f6', // campaign-active @@ -49,39 +66,89 @@ SELF_MANAGED: '#7C3AED' // darker purple }; + // load settings from localStorage + function loadSettings() { + try { + const storedViewMode = localStorage.getItem(STORAGE_KEY_VIEW_MODE); + if (storedViewMode === 'month' || storedViewMode === 'week') { + viewMode = storedViewMode; + } + + const storedStartFromToday = localStorage.getItem(STORAGE_KEY_START_FROM_TODAY); + if (storedStartFromToday !== null) { + startFromToday = storedStartFromToday === 'true'; + } + + const storedFilters = localStorage.getItem(STORAGE_KEY_FILTERS); + if (storedFilters) { + const parsed = JSON.parse(storedFilters); + activeFilters = { ...activeFilters, ...parsed }; + } + } catch (e) { + console.warn('failed to load calendar settings from localStorage', e); + } + } + + // save settings to localStorage + function saveViewMode(mode) { + viewMode = mode; + try { + localStorage.setItem(STORAGE_KEY_VIEW_MODE, mode); + } catch (e) { + console.warn('failed to save view mode to localStorage', e); + } + } + + function saveStartFromToday(value) { + startFromToday = value; + try { + localStorage.setItem(STORAGE_KEY_START_FROM_TODAY, String(value)); + } catch (e) { + console.warn('failed to save startFromToday to localStorage', e); + } + } + + function saveFilters() { + try { + localStorage.setItem(STORAGE_KEY_FILTERS, JSON.stringify(activeFilters)); + } catch (e) { + console.warn('failed to save filters to localStorage', e); + } + } + function sortCampaignsByPriority(campaigns, day) { const dayTime = startOfDay(day).getTime(); - // Helper function to get campaign type priority + // helper function to get campaign type priority function getTypePriority(campaign) { if (campaign.sendStartAt && campaign.sendStartAt > new Date().toISOString()) { - return 0; // Scheduled gets highest priority + return 0; // scheduled gets highest priority } if (campaign.sendStartAt && !campaign.anonymizedAt && !campaign.closedAt) { - return 1; // Active gets second priority + return 1; // active gets second priority } if (!campaign.sendStartAt || !campaign.sendEndAt) { - return 2; // Self-managed gets third priority + return 2; // self-managed gets third priority } - return 3; // Completed gets lowest priority + return 3; // completed gets lowest priority } return campaigns.sort((a, b) => { - // 1. Absolute highest priority: campaign's sendStartAt is on this day + // 1. absolute highest priority: campaign's sendStartAt is on this day const aSendStartTime = a.sendStartAt ? startOfDay(new Date(a.sendStartAt)).getTime() : null; const bSendStartTime = b.sendStartAt ? startOfDay(new Date(b.sendStartAt)).getTime() : null; if (aSendStartTime === dayTime && bSendStartTime !== dayTime) return -1; if (bSendStartTime === dayTime && aSendStartTime !== dayTime) return 1; - // 2. Second priority: type of campaign (scheduled > active > self-managed > completed) + // 2. second priority: type of campaign (scheduled > active > self-managed > completed) const aTypePriority = getTypePriority(a); const bTypePriority = getTypePriority(b); if (aTypePriority !== bTypePriority) { return aTypePriority - bTypePriority; } - // 3. If same type, sort by sendStartAt date (if exists) or start date + // 3. if same type, sort by sendStartAt date (if exists) or start date const aTime = aSendStartTime || startOfDay(a.start).getTime(); const bTime = bSendStartTime || startOfDay(b.start).getTime(); return aTime - bTime; @@ -93,23 +160,46 @@ } function updateDateRange() { - const monthStart = startOfMonth(currentMonth); - const monthEnd = endOfMonth(currentMonth); + if (viewMode === 'week') { + // week view + let weekStart; + if (startFromToday) { + // start from today, show 7 days forward + weekStart = startOfDay(currentDate); + } else { + // start from beginning of the week containing currentDate + weekStart = startOfWeek(currentDate, { weekStartsOn: 0 }); + } + const weekEnd = addDays(weekStart, 6); - // Calculate first day of calendar (might be in previous month) - let calendarStart = monthStart; - const firstDayOfWeek = getDay(monthStart); - if (firstDayOfWeek > 0) { - calendarStart = subDays(monthStart, firstDayOfWeek); + start = weekStart; + end = endOfDay(weekEnd); + } else { + // month view + let monthStart; + if (startFromToday && isSameMonth(currentDate, new Date())) { + // if viewing current month and startFromToday is enabled, start from today + monthStart = startOfDay(new Date()); + } else { + monthStart = startOfMonth(currentDate); + } + const monthEnd = endOfMonth(currentDate); + + // calculate first day of calendar (might be in previous month) + let calendarStart = startOfMonth(currentDate); + const firstDayOfWeek = getDay(calendarStart); + if (firstDayOfWeek > 0) { + calendarStart = subDays(calendarStart, firstDayOfWeek); + } + + // calculate last day of calendar (might be in next month) + const lastDayOfMonth = endOfMonth(currentDate); + const lastDayOfWeek = getDay(lastDayOfMonth); + const calendarEnd = addDays(lastDayOfMonth, 6 - lastDayOfWeek); + + start = calendarStart; + end = calendarEnd; } - - // Calculate last day of calendar (might be in next month) - const lastDayOfMonth = endOfMonth(monthStart); - const lastDayOfWeek = getDay(lastDayOfMonth); - const calendarEnd = addDays(lastDayOfMonth, 6 - lastDayOfWeek); - - start = calendarStart; - end = calendarEnd; } /** @type * */ @@ -177,14 +267,14 @@ function calculateCampaignLayers(campaigns, calendarStart, calendarEnd) { const dayMap = new Map(); - // Generate days + // generate days let currentDay = new Date(calendarStart); while (currentDay < calendarEnd) { dayMap.set(currentDay.getTime(), []); currentDay = addDays(currentDay, 1); } - // Filter campaigns based on active filters + // filter campaigns based on active filters const filteredCampaigns = campaigns.filter((campaign) => { if (campaign.anonymizedAt || campaign.closedAt) { return activeFilters.COMPLETED; @@ -198,7 +288,7 @@ return activeFilters.ACTIVE; }); - // Map filtered campaigns to days + // map filtered campaigns to days filteredCampaigns.forEach((campaign) => { const campaignStart = startOfDay(campaign.start); const campaignEnd = startOfDay(campaign.end); @@ -215,14 +305,14 @@ } }); - // Sort campaigns for each day + // sort campaigns for each day const visibleDayMap = new Map(); dayMap.forEach((dayCampaigns, dayTime) => { const sortedCampaigns = sortCampaignsByPriority(dayCampaigns, new Date(Number(dayTime))); visibleDayMap.set(dayTime, { - campaigns: sortedCampaigns, // All campaigns are now visible + campaigns: sortedCampaigns, total: sortedCampaigns.length }); }); @@ -237,55 +327,110 @@ const dayMap = calculateCampaignLayers(calendarCampaigns, start, end); - // Create weeks array + // create weeks array weeks = []; let week = []; - let currentDate = new Date(start); + let currentDateIter = new Date(start); - while (currentDate <= end) { - // Get campaign data for this day - const dayTime = currentDate.getTime(); + while (currentDateIter <= end) { + // get campaign data for this day + const dayTime = currentDateIter.getTime(); const dayData = dayMap.get(dayTime) || { campaigns: [], total: 0 }; - // Sort campaigns + // sort campaigns const sortedCampaigns = sortByName(dayData.campaigns); - // Create day object + // determine if this day should be highlighted based on view mode + const isInCurrentPeriod = + viewMode === 'week' + ? true // in week view, all days are "current" + : isSameMonth(currentDateIter, currentDate); + + // determine if day is in next month (for visual separation when "start from today" is enabled) + const isNextMonth = + startFromToday && + viewMode === 'month' && + currentDateIter.getMonth() !== currentDate.getMonth(); + + // create day object const day = { - date: new Date(currentDate), - isToday: isSameDay(currentDate, new Date()), - isCurrentMonth: isSameMonth(currentDate, currentMonth), - campaigns: sortedCampaigns, // All campaigns are visible now + date: new Date(currentDateIter), + isToday: isSameDay(currentDateIter, new Date()), + isCurrentMonth: isInCurrentPeriod, + isNextMonth, + campaigns: sortedCampaigns, totalCampaigns: dayData.total }; week.push(day); - // Start a new week if we've filled one + // start a new week if we've filled one if (week.length === 7) { weeks.push(week); week = []; } - // Move to next day - currentDate = addDays(currentDate, 1); + // move to next day + currentDateIter = addDays(currentDateIter, 1); + } + + // for week view, we might have a partial week + if (week.length > 0) { + weeks.push(week); } isGeneratingCalendar = false; } - async function previousMonth() { + async function navigatePrevious() { isLoadingNewMonth = true; - currentMonth = subMonths(currentMonth, 1); + if (viewMode === 'week') { + currentDate = subWeeks(currentDate, 1); + } else { + currentDate = subMonths(currentDate, 1); + } updateDateRange(); await onChangeDate(); isLoadingNewMonth = false; await generateCalendarData(); } - async function nextMonth() { + async function navigateNext() { isLoadingNewMonth = true; - currentMonth = addMonths(currentMonth, 1); + if (viewMode === 'week') { + currentDate = addWeeks(currentDate, 1); + } else { + currentDate = addMonths(currentDate, 1); + } + updateDateRange(); + await onChangeDate(); + isLoadingNewMonth = false; + await generateCalendarData(); + } + + async function goToToday() { + isLoadingNewMonth = true; + currentDate = new Date(); + updateDateRange(); + await onChangeDate(); + isLoadingNewMonth = false; + await generateCalendarData(); + } + + async function handleViewModeChange(mode) { + if (mode === viewMode) return; + isLoadingNewMonth = true; + saveViewMode(mode); + updateDateRange(); + await onChangeDate(); + isLoadingNewMonth = false; + await generateCalendarData(); + } + + async function handleStartFromTodayChange(event) { + const checked = event.target.checked; + isLoadingNewMonth = true; + saveStartFromToday(checked); updateDateRange(); await onChangeDate(); isLoadingNewMonth = false; @@ -294,15 +439,60 @@ async function toggleFilter(key) { activeFilters[key] = !activeFilters[key]; + saveFilters(); await generateCalendarData(); } + /** + * builds the tooltip text for a campaign + * @param {*} campaign + */ + function buildTooltip(campaign) { + const status = campaign.isSelfManaged + ? 'Self-managed' + : campaign.end < new Date() + ? 'Completed' + : campaign.start <= new Date() + ? 'Active' + : 'Scheduled'; + + const dateRange = campaign.isSelfManaged + ? `${formatDateString(new Date(campaign.createdAt))} - ?` + : `${formatDateString(campaign.start)} - ${formatDateString(campaign.end)}`; + + let tooltip = `${campaign.name} - ${status}, ${dateRange}`; + + if (showCompany && campaign.company?.name) { + tooltip = `[${campaign.company.name}] ${tooltip}`; + } + + return tooltip; + } + + /** + * formats the header title based on view mode + */ + function getHeaderTitle() { + if (viewMode === 'week') { + const weekEnd = addDays(start, 6); + if (isSameMonth(start, weekEnd)) { + return `${format(start, 'MMMM d')} - ${format(weekEnd, 'd, yyyy')}`; + } else if (start.getFullYear() === weekEnd.getFullYear()) { + return `${format(start, 'MMM d')} - ${format(weekEnd, 'MMM d, yyyy')}`; + } else { + return `${format(start, 'MMM d, yyyy')} - ${format(weekEnd, 'MMM d, yyyy')}`; + } + } + return format(currentDate, 'MMMM yyyy'); + } + onMount(async () => { + loadSettings(); await generateCalendarData(); isInitialized = true; }); - $: if (calendarCampaigns) { + $: if (calendarCampaigns && isInitialized) { generateCalendarData(); } @@ -310,76 +500,129 @@
-
- -
- - -

- {format(currentMonth, 'MMMM yyyy')} -

- - -
- - -
- {#each [{ key: 'SCHEDULED', color: COLORS.SCHEDULED, label: 'Scheduled' }, { key: 'ACTIVE', color: COLORS.ACTIVE, label: 'Active' }, { key: 'COMPLETED', color: COLORS.COMPLETED, label: 'Completed' }, { key: 'SELF_MANAGED', color: COLORS.SELF_MANAGED, label: 'Self-managed' }] as item} -
+ + +
+ - {/each} + + + +

+ {getHeaderTitle()} +

+ + +
+ + +
+ {#each [{ key: 'SCHEDULED', color: COLORS.SCHEDULED, label: 'Scheduled' }, { key: 'ACTIVE', color: COLORS.ACTIVE, label: 'Active' }, { key: 'COMPLETED', color: COLORS.COMPLETED, label: 'Completed' }, { key: 'SELF_MANAGED', color: COLORS.SELF_MANAGED, label: 'Self-managed' }] as item} + + {/each} +
-
+
{#each ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as day} -
{day}
+
{day}
{/each}
@@ -394,49 +637,58 @@
{:else} -
+
{#each weeks as week, weekIndex}
{#each week as day}
{day.date.getDate()}
-
+
{#each day.campaigns as campaign} -
- {truncateText(campaign.name, 18)} +
+
+ {#if showCompany && campaign.company?.name} +
+ {truncateText(campaign.company.name, 22)} +
+ {/if} +
+ {truncateText(campaign.name, 24)} +
{/each} @@ -453,69 +705,64 @@ diff --git a/frontend/src/lib/utils/scrollbar.js b/frontend/src/lib/utils/scrollbar.js index f5177ca..c884a43 100644 --- a/frontend/src/lib/utils/scrollbar.js +++ b/frontend/src/lib/utils/scrollbar.js @@ -1,15 +1,23 @@ +// scrollbar utility classes using css variables for theme support +// these match the scrollbar styling defined in app.css + export const scrollBarClassesHorizontal = ` - [&::-webkit-scrollbar]:h-2 - [&::-webkit-scrollbar-track]:bg-gray-100 - [&::-webkit-scrollbar-thumb]:h-1 - [&::-webkit-scrollbar-thumb]:my-1 - [&::-webkit-scrollbar-thumb]:rounded-md - [&::-webkit-scrollbar-thumb]:bg-pc-dusty-light-blue + [&::-webkit-scrollbar]:h-1.5 + [&::-webkit-scrollbar-track]:bg-[var(--color-scrollbar-track)] + [&::-webkit-scrollbar-track]:rounded-full + [&::-webkit-scrollbar-thumb]:rounded-full + [&::-webkit-scrollbar-thumb]:bg-[var(--color-scrollbar-thumb)] + [&::-webkit-scrollbar-thumb:hover]:bg-[var(--color-scrollbar-thumb-hover)] `; export const scrollBarClassesVertical = ` - [&::-webkit-scrollbar]:w-2 - [&::-webkit-scrollbar-track]:bg-gray-100 - [&::-webkit-scrollbar-thumb]:rounded-md - [&::-webkit-scrollbar-thumb]:bg-pc-dusty-light-blue + [&::-webkit-scrollbar]:w-1.5 + [&::-webkit-scrollbar-track]:bg-[var(--color-scrollbar-track)] + [&::-webkit-scrollbar-track]:rounded-full + [&::-webkit-scrollbar-thumb]:rounded-full + [&::-webkit-scrollbar-thumb]:bg-[var(--color-scrollbar-thumb)] + [&::-webkit-scrollbar-thumb:hover]:bg-[var(--color-scrollbar-thumb-hover)] `; + +// combined classes for containers that may scroll in both directions +export const scrollBarClasses = `${scrollBarClassesVertical} ${scrollBarClassesHorizontal}`; diff --git a/frontend/src/routes/dashboard/+page.svelte b/frontend/src/routes/dashboard/+page.svelte index 626cb6b..b611c55 100644 --- a/frontend/src/routes/dashboard/+page.svelte +++ b/frontend/src/routes/dashboard/+page.svelte @@ -498,6 +498,7 @@ onChangeDate={refreshCalendarCampaings} bind:start={calendarStartDate} bind:end={calendarEndDate} + showCompany={!contextCompanyID} />