diff --git a/apps/admin-x-framework/src/api/posts.ts b/apps/admin-x-framework/src/api/posts.ts index f7fd7b511e9..8499902a6a8 100644 --- a/apps/admin-x-framework/src/api/posts.ts +++ b/apps/admin-x-framework/src/api/posts.ts @@ -12,6 +12,8 @@ export type Post = { slug: string; title: string; uuid: string; + created_at?: string; + updated_at?: string; feature_image?: string; count?: { clicks?: number; @@ -37,28 +39,41 @@ export interface PostsResponseType { const dataType = 'PostsResponseType'; +/** + * Lists posts from the Admin API with the provided search params. + */ export const useBrowsePosts = createQuery({ dataType, path: '/posts/' }); +/** + * Fetches a single post by id from the Admin API. + */ export const getPost = createQueryWithId({ dataType, path: id => `/posts/${id}/` }); -// This endpoints returns a csv file +/** + * Requests a CSV export of posts. + */ export const usePostsExports = createQuery({ dataType, path: '/posts/export/' }); +/** + * Deletes a post by id. + */ export const useDeletePost = createMutation({ method: 'DELETE', path: id => `/posts/${id}/` }); -// Search index endpoints for efficient search +/** + * Queries posts through the search index endpoint for faster text search. + */ export const useSearchIndexPosts = createQuery({ dataType, path: '/search-index/posts/' diff --git a/apps/admin/src/layout/app-sidebar/nav-content.tsx b/apps/admin/src/layout/app-sidebar/nav-content.tsx index 77da92d921b..4be9daa7768 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.tsx +++ b/apps/admin/src/layout/app-sidebar/nav-content.tsx @@ -16,15 +16,20 @@ import NavSubMenu from "./nav-sub-menu"; import { useMemberCount } from "./hooks/use-member-count"; import { useNavigationExpanded } from "./hooks/use-navigation-preferences"; import { NavCustomViews } from "./nav-custom-views"; +import { useIsActiveLink } from "./use-is-active-link"; import { useEmberRouting } from "@/ember-bridge"; import { useFeatureFlag } from "@/hooks/use-feature-flag"; +/** + * Main admin sidebar navigation content including posts, calendar, and resource links. + */ function NavContent({ ...props }: React.ComponentProps) { const { data: currentUser } = useCurrentUser(); const [postsExpanded, setPostsExpanded] = useNavigationExpanded('posts'); const memberCount = useMemberCount(); const routing = useEmberRouting(); const commentModerationEnabled = useFeatureFlag('commentModeration'); + const isCalendarRouteActive = useIsActiveLink({path: '/posts/calendar'}); const showTags = currentUser && canManageTags(currentUser); const showMembers = currentUser && canManageMembers(currentUser); @@ -90,6 +95,16 @@ function NavContent({ ...props }: React.ComponentProps) { + + + Calendar + + + {}} />, // @TODO: add back to dashboard click handle children: [ + { + path: 'posts/calendar', + lazy: lazyComponent(() => import('@views/ContentCalendar/content-calendar')) + }, { // Post Analytics path: 'posts/analytics/:postId', diff --git a/apps/posts/src/views/ContentCalendar/content-calendar.tsx b/apps/posts/src/views/ContentCalendar/content-calendar.tsx new file mode 100644 index 00000000000..93084f8947e --- /dev/null +++ b/apps/posts/src/views/ContentCalendar/content-calendar.tsx @@ -0,0 +1,702 @@ +import MainLayout from '@components/layout/main-layout'; +import React, {useMemo, useState} from 'react'; +import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, EmptyIndicator, LoadingIndicator, LucideIcon, cn} from '@tryghost/shade'; +import {CalendarDay, CalendarMonth, CalendarPostOrder, CalendarPostStatus, CalendarTypeFilter, buildCalendarGrid, formatMonthLabel, getCalendarDateRangeFilter, getLegendStatusesForType, getNowMonthInTimezone, shiftCalendarMonth} from './utils/calendar'; +import {Post, useBrowsePosts} from '@tryghost/admin-x-framework/api/posts'; +import {getSiteTimezone} from '@src/utils/get-site-timezone'; +import {isAuthorOrContributor, isContributorUser, useBrowseUsers} from '@tryghost/admin-x-framework/api/users'; +import {useBrowseSettings} from '@tryghost/admin-x-framework/api/settings'; +import {useBrowseTags} from '@tryghost/admin-x-framework/api/tags'; +import {useCurrentUser} from '@tryghost/admin-x-framework/api/current-user'; +import {useSearchParams} from '@tryghost/admin-x-framework'; + +const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MAX_POSTS_PER_DAY = 3; +const DEFAULT_TYPE_VALUE: CalendarTypeFilter = 'scheduled'; +const ALL_FILTER_VALUE = '__all__'; +const DEFAULT_ORDER_VALUE = '__default_order__'; +const FILTER_QUERY_PARAMS = ['type', 'visibility', 'author', 'tag', 'order'] as const; + +type CalendarVisibilityFilter = 'public' | 'members' | '[paid,tiers]' | null; +type CalendarOrderFilter = 'published_at asc' | 'updated_at desc' | null; +type CalendarQueryParam = typeof FILTER_QUERY_PARAMS[number]; +type FilterOption = {label: string; value: string}; + +// TODO: Replace LegacyFilterSelect with a Shade Select once the legacy admin UI is retired. +// The ember-* classes (for example 'ember-view' and 'ember-basic-dropdown-trigger') are +// intentionally preserved for visual parity with current admin filter controls. +/** + * Dropdown wrapper that mimics legacy Ghost filter select styling and behavior. + */ +const LegacyFilterSelect = ({ + value, + options, + onValueChange, + ariaLabel, + disabled +}: { + value: string; + options: FilterOption[]; + onValueChange: (nextValue: string) => void; + ariaLabel: string; + disabled?: boolean; +}) => { + const [isOpen, setIsOpen] = useState(false); + const selectedOption = options.find(option => option.value === value); + + return ( + + +
+ + {selectedOption?.label ?? ''} + + + arrow-down-small + + +
+
+ + {options.map((option) => { + return ( + onValueChange(option.value)} + > + {option.label} + + ); + })} + +
+ ); +}; + +const TYPE_OPTIONS: Array<{label: string; value: CalendarTypeFilter}> = [ + {label: 'All posts', value: 'all'}, + {label: 'Draft posts', value: 'draft'}, + {label: 'Published posts', value: 'published'}, + {label: 'Scheduled posts', value: 'scheduled'}, + {label: 'Featured posts', value: 'featured'} +]; + +const VISIBILITY_OPTIONS: Array<{label: string; value: Exclude}> = [ + {label: 'Public', value: 'public'}, + {label: 'Members-only', value: 'members'}, + {label: 'Paid members-only', value: '[paid,tiers]'} +]; + +const ORDER_OPTIONS: Array<{label: string; value: Exclude}> = [ + {label: 'Oldest first', value: 'published_at asc'}, + {label: 'Recently updated', value: 'updated_at desc'} +]; + +/** + * Checks whether a URL value maps to a supported calendar type filter. + */ +const isCalendarTypeFilter = (value: string | null): value is CalendarTypeFilter => { + return Boolean(value && TYPE_OPTIONS.some(option => option.value === value)); +}; + +/** + * Checks whether a URL value maps to a supported visibility filter. + */ +const isCalendarVisibilityFilter = (value: string | null): value is Exclude => { + return Boolean(value && VISIBILITY_OPTIONS.some(option => option.value === value)); +}; + +/** + * Checks whether a URL value maps to a supported explicit sort order. + */ +const isCalendarOrderFilter = (value: string | null): value is Exclude => { + return Boolean(value && ORDER_OPTIONS.some(option => option.value === value)); +}; + +/** + * Converts the selected type UI value into the status NQL clause. + */ +const getStatusFilterForType = (type: CalendarTypeFilter): string => { + switch (type) { + case 'draft': + return 'draft'; + case 'published': + return 'published'; + case 'scheduled': + return 'scheduled'; + default: + return '[draft,scheduled,published]'; + } +}; + +/** + * Builds the base NQL filter for the calendar from the current UI selections. + */ +const buildCalendarFilter = ({ + type, + visibility, + tag, + author, + currentUserSlug, + shouldForceCurrentUser +}: { + type: CalendarTypeFilter; + visibility: CalendarVisibilityFilter; + tag: string | null; + author: string | null; + currentUserSlug: string | null; + shouldForceCurrentUser: boolean; +}) => { + const parts = [`status:${getStatusFilterForType(type)}`]; + + if (type === 'featured') { + parts.push('featured:true'); + } + + if (visibility) { + parts.push(`visibility:${visibility}`); + } + + if (tag) { + parts.push(`tag:${tag}`); + } + + if (shouldForceCurrentUser && currentUserSlug) { + parts.push(`authors:${currentUserSlug}`); + } else if (author) { + parts.push(`authors:${author}`); + } + + return parts.join('+'); +}; + +const POST_STATUS_STYLES: Record = { + published: { + label: 'Published', + itemClass: 'border-zinc-300 text-zinc-800 bg-gray/30 text-gray hover:border-black/30', + badgeClass: 'border-zinc-300 text-zinc-700 bg-gray/30 text-gray' + }, + scheduled: { + label: 'Scheduled', + itemClass: 'border-green/30 bg-green/10 text-green hover:border-green/60', + badgeClass: 'border-green/30 bg-green/10 text-green' + }, + draft: { + label: 'Draft', + itemClass: 'border-amber-200 bg-amber-50/80 text-amber-900 hover:border-black/30', + badgeClass: 'border-amber-300 bg-amber-100 text-amber-800' + }, + unknown: { + label: 'Other', + itemClass: 'border-border bg-background text-foreground hover:border-foreground/30', + badgeClass: 'border-border bg-muted text-muted-foreground' + } +}; + +interface MonthNavigationProps { + monthLabel: string; + monthOffset: number; + setMonthOffset: React.Dispatch>; +} + +/** + * Month navigation controls for previous/current/next calendar views. + */ +const MonthNavigation: React.FC = ({monthLabel, monthOffset, setMonthOffset}) => { + return ( +
+ + + + {monthLabel} + + +
+ ); +}; + +interface FilterBarProps { + authorOptions: FilterOption[]; + tagOptions: FilterOption[]; + legendStatuses: CalendarPostStatus[]; + hasActiveFilters: boolean; + selectedType: CalendarTypeFilter; + selectedVisibility: CalendarVisibilityFilter; + selectedAuthor: string | null; + selectedTag: string | null; + selectedOrder: CalendarOrderFilter; + setFilterParam: (key: CalendarQueryParam, value: string | null) => void; + clearFilters: () => void; + isCurrentUserContributor: boolean; + isCurrentUserAuthorOrContributor: boolean; + isAuthorsLoading: boolean; + isTagsLoading: boolean; +} + +/** + * Calendar filter row with type, visibility, author, tag, and ordering controls. + */ +const FilterBar: React.FC = ({ + authorOptions, + tagOptions, + legendStatuses, + hasActiveFilters, + selectedType, + selectedVisibility, + selectedAuthor, + selectedTag, + selectedOrder, + setFilterParam, + clearFilters, + isCurrentUserContributor, + isCurrentUserAuthorOrContributor, + isAuthorsLoading, + isTagsLoading +}) => { + const hasActiveTypeFilter = selectedType !== DEFAULT_TYPE_VALUE && selectedType !== 'all'; + + return ( +
+
+ { + setFilterParam('type', value === DEFAULT_TYPE_VALUE ? null : value); + }} + /> +
+ {!isCurrentUserContributor && ( +
+ { + setFilterParam('visibility', value === ALL_FILTER_VALUE ? null : value); + }} + /> +
+ )} + {!isCurrentUserAuthorOrContributor && ( +
+ { + setFilterParam('author', value === ALL_FILTER_VALUE ? null : value); + }} + /> +
+ )} + {!isCurrentUserContributor && ( +
+ { + setFilterParam('tag', value === ALL_FILTER_VALUE ? null : value); + }} + /> +
+ )} +
+ { + setFilterParam('order', value === DEFAULT_ORDER_VALUE ? null : value); + }} + /> +
+
+ ); +}; + +interface CalendarGridProps { + calendarDays: CalendarDay[]; + siteTimezone: string; + month: CalendarMonth; + calendarOrder: CalendarPostOrder; + posts: Post[]; +} + +/** + * 7-column calendar grid that renders per-day post cards and overflow counts. + */ +const CalendarGrid: React.FC = ({calendarDays, siteTimezone, month, calendarOrder, posts}) => { + return ( +
+
+
+ {WEEKDAYS.map(day => ( +
+ {day} +
+ ))} +
+
+ {calendarDays.map((day) => { + const visiblePosts = day.posts.slice(0, MAX_POSTS_PER_DAY); + const hiddenPostCount = day.posts.length - visiblePosts.length; + + return ( +
+
{day.dayNumber}
+ {visiblePosts.length > 0 && ( + + )} + {hiddenPostCount > 0 && ( +
+ +{hiddenPostCount} more +
+ )} +
+ ); + })} +
+
+
+ ); +}; + +/** + * Posts app calendar view with URL-synced filters and role-aware defaults. + */ +const ContentCalendar: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const settings = useBrowseSettings(); + const currentUserQuery = useCurrentUser(); + const currentUser = currentUserQuery.data; + const siteTimezone = useMemo(() => getSiteTimezone(settings.data?.settings ?? []), [settings.data?.settings]); + const [monthOffset, setMonthOffset] = useState(0); + const month = useMemo(() => shiftCalendarMonth(getNowMonthInTimezone(siteTimezone), monthOffset), [siteTimezone, monthOffset]); + const monthLabel = useMemo(() => formatMonthLabel(month), [month]); + const selectedTypeParam = searchParams.get('type'); + const selectedVisibilityParam = searchParams.get('visibility'); + const selectedOrderParam = searchParams.get('order'); + const selectedType: CalendarTypeFilter = isCalendarTypeFilter(selectedTypeParam) ? selectedTypeParam : DEFAULT_TYPE_VALUE; + const selectedVisibility: CalendarVisibilityFilter = isCalendarVisibilityFilter(selectedVisibilityParam) ? selectedVisibilityParam : null; + const selectedAuthor = searchParams.get('author'); + const selectedTag = searchParams.get('tag'); + const selectedOrder: CalendarOrderFilter = isCalendarOrderFilter(selectedOrderParam) ? selectedOrderParam : null; + const isCurrentUserContributor = currentUser ? isContributorUser(currentUser) : false; + const isCurrentUserAuthorOrContributor = currentUser ? isAuthorOrContributor(currentUser) : false; + const defaultOrder: CalendarPostOrder = selectedType === 'draft' ? 'updated_at desc' : 'published_at desc'; + const calendarOrder: CalendarPostOrder = selectedOrder ?? defaultOrder; + const dateRangeFilter = useMemo(() => getCalendarDateRangeFilter(month), [month]); + const calendarFilter = useMemo(() => buildCalendarFilter({ + type: selectedType, + visibility: selectedVisibility, + tag: selectedTag, + author: selectedAuthor, + currentUserSlug: currentUser?.slug ?? null, + shouldForceCurrentUser: isCurrentUserAuthorOrContributor + }), [selectedType, selectedVisibility, selectedTag, selectedAuthor, currentUser?.slug, isCurrentUserAuthorOrContributor]); + const postQueryFilter = useMemo(() => { + return `${calendarFilter}+${dateRangeFilter}`; + }, [calendarFilter, dateRangeFilter]); + const authorsQuery = useBrowseUsers({ + enabled: !isCurrentUserAuthorOrContributor, + searchParams: { + limit: 'all', + include: 'roles' + } + }); + const tagsQuery = useBrowseTags({ + enabled: !isCurrentUserContributor, + filter: { + visibility: '[public,internal]' + }, + searchParams: { + limit: 'all', + order: 'name asc' + } + }); + const authorOptions = useMemo(() => { + const options = [{ + label: 'All authors', + value: ALL_FILTER_VALUE + }]; + const authors = [...(authorsQuery.data?.users ?? [])] + .sort((a, b) => a.name.localeCompare(b.name)) + .map(author => ({ + label: author.name, + value: author.slug + })); + options.push(...authors); + + if (selectedAuthor && !options.some(option => option.value === selectedAuthor)) { + options.push({ + label: selectedAuthor, + value: selectedAuthor + }); + } + + return options; + }, [authorsQuery.data?.users, selectedAuthor]); + const tagOptions = useMemo(() => { + const options = [{ + label: 'All tags', + value: ALL_FILTER_VALUE + }]; + const tags = [...(tagsQuery.data?.tags ?? [])] + .sort((a, b) => a.name.localeCompare(b.name)) + .map(tag => ({ + label: tag.name, + value: tag.slug + })); + options.push(...tags); + + if (selectedTag && !options.some(option => option.value === selectedTag)) { + options.push({ + label: selectedTag, + value: selectedTag + }); + } + + return options; + }, [tagsQuery.data?.tags, selectedTag]); + const legendStatuses = useMemo(() => getLegendStatusesForType(selectedType), [selectedType]); + const hasActiveTypeFilter = selectedType !== DEFAULT_TYPE_VALUE && selectedType !== 'all'; + const hasActiveFilters = hasActiveTypeFilter || Boolean(selectedVisibility) || Boolean(selectedAuthor) || Boolean(selectedTag) || Boolean(selectedOrder); + + /** + * Writes a single calendar filter value to URL search params. + */ + const setFilterParam = (key: CalendarQueryParam, value: string | null) => { + const nextSearchParams = new URLSearchParams(searchParams); + + if (value) { + nextSearchParams.set(key, value); + } else { + nextSearchParams.delete(key); + } + + setSearchParams(nextSearchParams, {replace: true}); + }; + + /** + * Clears all calendar filter query params and resets type to "all". + */ + const clearFilters = () => { + const nextSearchParams = new URLSearchParams(searchParams); + + FILTER_QUERY_PARAMS.forEach((key) => { + nextSearchParams.delete(key); + }); + nextSearchParams.set('type', 'all'); + + setSearchParams(nextSearchParams, {replace: true}); + }; + + const {data, isError, isLoading} = useBrowsePosts({ + searchParams: { + filter: postQueryFilter, + limit: 'all', + order: calendarOrder + } + }); + + const posts = useMemo(() => data?.posts ?? [], [data?.posts]); + const calendarDays = useMemo(() => buildCalendarGrid({ + month, + posts, + timeZone: siteTimezone, + order: calendarOrder + }), [month, posts, siteTimezone, calendarOrder]); + + return ( + +
+
+
+
+
+
+ Posts + + Calendar +
+

+ Calendar +

+
+ +
+ +
+ +
+
+
+
+
+ {isLoading ? ( +
+ +
+ ) : isError ? ( +
+

Error loading calendar

+

Please reload the page to try again.

+ +
+ ) : ( + <> +
+ {posts.length > 0 && ( +
+ {legendStatuses.map((status) => { + const style = POST_STATUS_STYLES[status]; + + return ( + + {style.label} + + ); + })} +
+ )} + +
+ {posts.length === 0 ? ( +
+ + Show all posts + + ) : ( + + )} + title={hasActiveFilters ? 'No posts match the current filter' : 'No posts to display yet'} + > + + +
+ ) : ( + + )} + + )} +
+
+
+
+ ); +}; + +export default ContentCalendar; diff --git a/apps/posts/src/views/ContentCalendar/utils/calendar.ts b/apps/posts/src/views/ContentCalendar/utils/calendar.ts new file mode 100644 index 00000000000..56a3dace396 --- /dev/null +++ b/apps/posts/src/views/ContentCalendar/utils/calendar.ts @@ -0,0 +1,295 @@ +import {Post} from '@tryghost/admin-x-framework/api/posts'; + +export interface CalendarMonth { + year: number; + month: number; +} + +export type CalendarTypeFilter = 'all' | 'draft' | 'published' | 'scheduled' | 'featured'; +export type CalendarPostStatus = 'draft' | 'scheduled' | 'published' | 'unknown'; +export type CalendarPostOrder = 'published_at asc' | 'published_at desc' | 'updated_at desc'; + +export interface CalendarPost { + id: string; + title: string; + occursAt: string; + status: CalendarPostStatus; + updatedAt?: string; +} + +export interface CalendarDay { + dateKey: string; + dayNumber: number; + inCurrentMonth: boolean; + posts: CalendarPost[]; +} + +const CALENDAR_DAY_COUNT = 42; + +/** + * Pads numeric month/day values to two digits for YYYY-MM-DD formatting. + */ +const pad = (value: number) => value.toString().padStart(2, '0'); + +/** + * Normalizes a potentially out-of-range month into a valid year/month pair. + */ +const normalizeMonth = (year: number, month: number): CalendarMonth => { + const normalizedYear = year + Math.floor((month - 1) / 12); + const normalizedMonth = ((month - 1) % 12 + 12) % 12 + 1; + + return { + year: normalizedYear, + month: normalizedMonth + }; +}; + +/** + * Extracts calendar date parts in a specific timezone. + */ +const getDatePartsInTimezone = (date: Date, timeZone: string) => { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit' + }).formatToParts(date); + + const year = Number(parts.find(part => part.type === 'year')?.value ?? 0); + const month = Number(parts.find(part => part.type === 'month')?.value ?? 1); + const day = Number(parts.find(part => part.type === 'day')?.value ?? 1); + + return {year, month, day}; +}; + +/** + * Builds a stable YYYY-MM-DD key for a timestamp in the provided timezone. + */ +export const getDateKeyInTimezone = (dateInput: string, timeZone: string) => { + const date = new Date(dateInput); + if (Number.isNaN(date.getTime())) { + throw new RangeError('Invalid dateInput passed to getDateKeyInTimezone'); + } + + const {year, month, day} = getDatePartsInTimezone(date, timeZone); + + return `${year}-${pad(month)}-${pad(day)}`; +}; + +/** + * Returns the current month as seen in a specific timezone. + */ +export const getNowMonthInTimezone = (timeZone: string, now = new Date()): CalendarMonth => { + const {year, month} = getDatePartsInTimezone(now, timeZone); + + return {year, month}; +}; + +/** + * Moves a month forwards/backwards while preserving a normalized result. + */ +export const shiftCalendarMonth = (month: CalendarMonth, offset: number): CalendarMonth => { + return normalizeMonth(month.year, month.month + offset); +}; + +/** + * Returns the set of post-status badges to show in the calendar legend. + */ +export const getLegendStatusesForType = (type: CalendarTypeFilter): CalendarPostStatus[] => { + switch (type) { + case 'draft': + return ['draft']; + case 'published': + return ['published']; + case 'scheduled': + return ['scheduled']; + default: + return ['published', 'scheduled', 'draft']; + } +}; + +/** + * Builds a month-scoped date filter with a one-month buffer on each side. + * The resulting NQL clause matches posts by published, updated, or created timestamps. + */ +export const getCalendarDateRangeFilter = (month: CalendarMonth): string => { + const rangeStart = new Date(Date.UTC(month.year, month.month - 2, 1, 0, 0, 0, 0)).toISOString(); + const rangeEnd = new Date(Date.UTC(month.year, month.month + 1, 0, 23, 59, 59, 999)).toISOString(); + + const rangeForField = (field: string) => { + return `${field}:>='${rangeStart}'+${field}:<='${rangeEnd}'`; + }; + + return `(${rangeForField('published_at')},${rangeForField('updated_at')},${rangeForField('created_at')})`; +}; + +/** + * Parses a date string into epoch milliseconds and safely falls back to zero. + */ +const getTimestamp = (value?: string) => { + if (!value) { + return 0; + } + + const timestamp = new Date(value).getTime(); + + return Number.isNaN(timestamp) ? 0 : timestamp; +}; + +/** + * Sorts calendar posts by the selected calendar order option. + */ +const sortCalendarPosts = (posts: CalendarPost[], order: CalendarPostOrder): CalendarPost[] => { + return posts.sort((a, b) => { + if (order === 'updated_at desc') { + const aUpdatedAt = getTimestamp(a.updatedAt || a.occursAt); + const bUpdatedAt = getTimestamp(b.updatedAt || b.occursAt); + + return bUpdatedAt - aUpdatedAt; + } + + if (order === 'published_at asc') { + return getTimestamp(a.occursAt) - getTimestamp(b.occursAt); + } + + return getTimestamp(b.occursAt) - getTimestamp(a.occursAt); + }); +}; + +/** + * Groups posts by local day key after mapping each post to a calendar occurrence date. + */ +const mapPostsByDay = (posts: Post[], timeZone: string, order: CalendarPostOrder): Map => { + const postsByDay = new Map(); + + const mappedPosts: Array = posts.map((post) => { + const status = getPostStatus(post.status); + const occursAt = getPostOccurrenceDate(post, status); + + if (!occursAt) { + return null; + } + + const calendarPost: CalendarPost = { + id: post.id, + title: post.title, + occursAt, + status + }; + + if (post.updated_at) { + calendarPost.updatedAt = post.updated_at; + } + + return calendarPost; + }); + + const items = sortCalendarPosts(mappedPosts + .filter((post): post is CalendarPost => Boolean(post)), order); + + for (const post of items) { + const key = getDateKeyInTimezone(post.occursAt, timeZone); + const existingItems = postsByDay.get(key) ?? []; + existingItems.push(post); + postsByDay.set(key, existingItems); + } + + return postsByDay; +}; + +/** + * Normalizes API post status into known calendar statuses. + */ +const getPostStatus = (status?: string): CalendarPostStatus => { + if (status === 'draft' || status === 'scheduled' || status === 'published') { + return status; + } + + return 'unknown'; +}; + +/** + * Selects the date field used to place a post on the calendar grid. + */ +const getPostOccurrenceDate = (post: Post, status: CalendarPostStatus): string | undefined => { + if (status === 'draft') { + return post.updated_at || post.created_at || post.published_at; + } + + return post.published_at || post.updated_at || post.created_at; +}; + +interface BuildCalendarGridArgs { + month: CalendarMonth; + posts: Post[]; + timeZone: string; + order?: CalendarPostOrder; +} + +/** + * Builds a fixed 6-week calendar grid for the selected month. + */ +export const buildCalendarGrid = ({month, posts, timeZone, order = 'published_at desc'}: BuildCalendarGridArgs): CalendarDay[] => { + const firstDayOfMonth = new Date(Date.UTC(month.year, month.month - 1, 1)); + const firstDayOffset = firstDayOfMonth.getUTCDay(); + const daysInCurrentMonth = new Date(Date.UTC(month.year, month.month, 0)).getUTCDate(); + const daysInPreviousMonth = new Date(Date.UTC(month.year, month.month - 1, 0)).getUTCDate(); + const postsByDay = mapPostsByDay(posts, timeZone, order); + const days: CalendarDay[] = []; + + for (let i = 0; i < CALENDAR_DAY_COUNT; i += 1) { + let dayNumber = 0; + let dateMonth = month; + let inCurrentMonth = true; + + if (i < firstDayOffset) { + dayNumber = daysInPreviousMonth - firstDayOffset + i + 1; + dateMonth = shiftCalendarMonth(month, -1); + inCurrentMonth = false; + } else if (i < firstDayOffset + daysInCurrentMonth) { + dayNumber = i - firstDayOffset + 1; + } else { + dayNumber = i - (firstDayOffset + daysInCurrentMonth) + 1; + dateMonth = shiftCalendarMonth(month, 1); + inCurrentMonth = false; + } + + const dateKey = `${dateMonth.year}-${pad(dateMonth.month)}-${pad(dayNumber)}`; + + days.push({ + dateKey, + dayNumber, + inCurrentMonth, + posts: postsByDay.get(dateKey) ?? [] + }); + } + + return days; +}; + +/** + * Formats a month object as a readable month/year label. + */ +export const formatMonthLabel = (month: CalendarMonth) => { + return new Intl.DateTimeFormat('en-US', { + month: 'long', + year: 'numeric', + timeZone: 'UTC' + }).format(new Date(Date.UTC(month.year, month.month - 1, 1))); +}; + +/** + * Formats a timestamp as a localized time string for calendar item metadata. + */ +export const formatPostTime = (dateInput: string, timeZone: string) => { + const date = new Date(dateInput); + if (Number.isNaN(date.getTime())) { + throw new RangeError('Invalid dateInput passed to formatPostTime'); + } + + return new Intl.DateTimeFormat('en-US', { + timeZone, + hour: 'numeric', + minute: '2-digit' + }).format(date); +}; diff --git a/apps/posts/test/unit/utils/content-calendar.test.ts b/apps/posts/test/unit/utils/content-calendar.test.ts new file mode 100644 index 00000000000..f407bd6add9 --- /dev/null +++ b/apps/posts/test/unit/utils/content-calendar.test.ts @@ -0,0 +1,122 @@ +import {Post} from '@tryghost/admin-x-framework/api/posts'; +import {buildCalendarGrid, formatPostTime, getDateKeyInTimezone, shiftCalendarMonth} from '@src/views/ContentCalendar/utils/calendar'; + +/** + * Creates a minimal post fixture for calendar utility tests. + */ +const createPost = (overrides: Partial = {}): Post => { + return { + id: 'post-id', + url: 'https://example.com/post/', + slug: 'post', + title: 'Post title', + uuid: 'post-uuid', + ...overrides + }; +}; + +describe('content calendar utils', () => { + it('builds date keys in a provided timezone', () => { + const result = getDateKeyInTimezone('2026-02-01T01:30:00.000Z', 'America/Los_Angeles'); + + expect(result).toBe('2026-01-31'); + }); + + it('throws a RangeError for invalid date key inputs', () => { + expect(() => getDateKeyInTimezone('invalid-date', 'UTC')).toThrow(new RangeError('Invalid dateInput passed to getDateKeyInTimezone')); + }); + + it('throws a RangeError for invalid post time inputs', () => { + expect(() => formatPostTime('invalid-date', 'UTC')).toThrow(new RangeError('Invalid dateInput passed to formatPostTime')); + }); + + it('shifts months across year boundaries', () => { + expect(shiftCalendarMonth({year: 2026, month: 1}, -1)).toEqual({year: 2025, month: 12}); + expect(shiftCalendarMonth({year: 2026, month: 12}, 1)).toEqual({year: 2027, month: 1}); + }); + + it('returns a 6-week calendar grid and groups published, scheduled, and draft posts by day', () => { + const grid = buildCalendarGrid({ + month: {year: 2026, month: 2}, + timeZone: 'UTC', + posts: [ + createPost({id: 'scheduled-late', status: 'scheduled', published_at: '2026-02-15T15:00:00.000Z'}), + createPost({id: 'published-early', status: 'published', published_at: '2026-02-15T10:00:00.000Z'}), + createPost({id: 'draft-early', status: 'draft', updated_at: '2026-02-15T08:00:00.000Z'}), + createPost({id: 'next-month', status: 'published', published_at: '2026-03-01T08:00:00.000Z'}) + ] + }); + + const feb15 = grid.find(day => day.dateKey === '2026-02-15'); + const march01 = grid.find(day => day.dateKey === '2026-03-01'); + + expect(grid).toHaveLength(42); + expect(feb15?.posts.map(post => post.id)).toEqual(['scheduled-late', 'published-early', 'draft-early']); + expect(feb15?.posts.map(post => post.status)).toEqual(['scheduled', 'published', 'draft']); + expect(march01?.posts.map(post => post.id)).toEqual(['next-month']); + }); + + it('supports ordering by published_at ascending', () => { + const grid = buildCalendarGrid({ + month: {year: 2026, month: 2}, + timeZone: 'UTC', + order: 'published_at asc', + posts: [ + createPost({id: 'late', status: 'scheduled', published_at: '2026-02-15T15:00:00.000Z'}), + createPost({id: 'early', status: 'published', published_at: '2026-02-15T10:00:00.000Z'}) + ] + }); + + const feb15 = grid.find(day => day.dateKey === '2026-02-15'); + + expect(feb15?.posts.map(post => post.id)).toEqual(['early', 'late']); + }); + + it('uses created_at fallback for draft posts', () => { + const grid = buildCalendarGrid({ + month: {year: 2026, month: 2}, + timeZone: 'UTC', + posts: [ + createPost({id: 'draft-with-created', status: 'draft', created_at: '2026-02-10T12:00:00.000Z'}) + ] + }); + + const feb10 = grid.find(day => day.dateKey === '2026-02-10'); + + expect(feb10?.posts.map(post => post.id)).toEqual(['draft-with-created']); + }); + + it('prefers updated_at over created_at for draft posts', () => { + const grid = buildCalendarGrid({ + month: {year: 2026, month: 2}, + timeZone: 'UTC', + posts: [ + createPost({ + id: 'draft-with-both-dates', + status: 'draft', + updated_at: '2026-02-12T12:00:00.000Z', + created_at: '2026-02-10T12:00:00.000Z' + }) + ] + }); + + const feb12 = grid.find(day => day.dateKey === '2026-02-12'); + + expect(feb12?.posts.map(post => post.id)).toEqual(['draft-with-both-dates']); + }); + + it('ignores posts that do not have any usable date fields', () => { + const grid = buildCalendarGrid({ + month: {year: 2026, month: 2}, + timeZone: 'UTC', + posts: [ + createPost({id: 'draft-without-date', status: 'draft'}), + createPost({id: 'published-without-date', status: 'published'}) + ] + }); + + const postsCount = grid.reduce((count, day) => count + day.posts.length, 0); + + expect(postsCount).toBe(0); + }); +}); diff --git a/compose.dev.yaml b/compose.dev.yaml index 14ad1419ffe..938fcfe40cd 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -15,7 +15,7 @@ services: volumes: - mysql-data:/var/lib/mysql healthcheck: - test: ["CMD", "mysql", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-root}", "-e", "SELECT 1"] + test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-root}", "--silent"] interval: 1s retries: 120 timeout: 5s