From 09f0bb46b26635efded66d252a16c8fe9a158e32 Mon Sep 17 00:00:00 2001 From: Danne Lundqvist Date: Wed, 13 Dec 2023 08:51:21 +0100 Subject: [PATCH] Feature/ele 262 create planning grid based on sketches (#39) * Created registry context, WiP as it needs splitting up * fix: fonts sizes and weights (#33) * fix: fonts sizes and weights * fingers crossed * remove post-url * chore: remove unused dep * fix: use resolved preset for settings (#34) * fix: use resolved preset for settings * chore: bump @ttab/elephant-ui @v0.0.12 * fix: use ext * chore: remove ignore after resolvedPreset fix (#35) * Get locale and timezone from user, use js intl formatting and remove date-fns * Moved PlanningHeader files closer to PlanningOverview where it is used * Renamed file from index to PlanningOverview * Added rudimentary grid view w columns * Movied view title and icon to view header * Date and layout switcher ui fixes * Renamed ViewHeader index file to component name * Refactored section and internal status indicator into their own resuable components * Improved grid view, and grid view items, styling and data * Refactor planning list to component, add SWR for caching api requests, handle local/UTC time * Create search index url with date params to cache planning list fetch correctly * Simplify date interval to use just beginning date for weeks as well * Extracted useRegistry hook into its own file * Removed as it doesn't really add anything any longer * Removed as it doesn't really add anything any longer * Renamed main component (PlanningOverview) to index.tsx * Removed commented out code * Added variant props type, more as model than out of necessity. Typescript adds code but no type safety here. * Change font weight --------- Co-authored-by: Gustav Larsson --- package-lock.json | 41 +++++- package.json | 3 +- src/components/DataItem/SectorBadge.tsx | 41 ++++++ src/components/DataItem/StatusIndicator.tsx | 37 ++++++ src/components/PlanningHeader/Datechanger.tsx | 18 --- src/components/PlanningHeader/Datepicker.tsx | 45 ------- src/components/PlanningHeader/index.tsx | 21 --- .../PlanningTable/Columns/Section.tsx | 11 +- .../PlanningTable/Columns/Title.tsx | 37 ++---- .../PlanningTable/data/settings.tsx | 28 ---- src/components/ViewHeader/ViewHeader.tsx | 36 +++++ src/components/ViewHeader/index.tsx | 19 --- src/components/index.tsx | 2 +- src/contexts/RegistryProvider.tsx | 99 ++++++++++++++ src/contexts/index.tsx | 1 + src/hooks/index.tsx | 2 + src/hooks/useRegistry.tsx | 16 +++ src/lib/datetime.ts | 50 +++++++ src/lib/getUserTimeZone.ts | 10 ++ src/lib/planning/search.ts | 13 +- src/main.tsx | 13 +- .../components/NavigationWrapper/index.tsx | 2 +- src/views/Editor/index.tsx | 14 +- src/views/PlanningOverview/PlanningGrid.tsx | 123 ++++++++++++++++++ .../PlanningOverview/PlanningGridColumn.tsx | 83 ++++++++++++ .../PlanningHeader/Datechanger.tsx | 52 ++++++++ .../PlanningHeader/Datepicker.tsx | 55 ++++++++ .../PlanningHeader/Filter.tsx | 0 .../PlanningHeader/LayoutSwitch.tsx | 3 +- .../PlanningOverview/PlanningHeader/index.tsx | 31 +++++ src/views/PlanningOverview/PlanningList.tsx | 50 +++++++ src/views/PlanningOverview/index.tsx | 86 ++++++------ 32 files changed, 802 insertions(+), 240 deletions(-) create mode 100644 src/components/DataItem/SectorBadge.tsx create mode 100644 src/components/DataItem/StatusIndicator.tsx delete mode 100644 src/components/PlanningHeader/Datechanger.tsx delete mode 100644 src/components/PlanningHeader/Datepicker.tsx delete mode 100644 src/components/PlanningHeader/index.tsx create mode 100644 src/components/ViewHeader/ViewHeader.tsx delete mode 100644 src/components/ViewHeader/index.tsx create mode 100644 src/contexts/RegistryProvider.tsx create mode 100644 src/hooks/useRegistry.tsx create mode 100644 src/lib/datetime.ts create mode 100644 src/lib/getUserTimeZone.ts create mode 100644 src/views/PlanningOverview/PlanningGrid.tsx create mode 100644 src/views/PlanningOverview/PlanningGridColumn.tsx create mode 100644 src/views/PlanningOverview/PlanningHeader/Datechanger.tsx create mode 100644 src/views/PlanningOverview/PlanningHeader/Datepicker.tsx rename src/{components => views/PlanningOverview}/PlanningHeader/Filter.tsx (100%) rename src/{components => views/PlanningOverview}/PlanningHeader/LayoutSwitch.tsx (86%) create mode 100644 src/views/PlanningOverview/PlanningHeader/index.tsx create mode 100644 src/views/PlanningOverview/PlanningList.tsx diff --git a/package-lock.json b/package-lock.json index 20642278..01d0b77a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,9 +22,9 @@ "class-variance-authority": "^0.7.0", "cookie-parser": "^1.4.6", "cors": "^2.8.5", - "date-fns": "^2.30.0", "express": "^4.18.2", "express-ws": "^5.0.2", + "get-user-locale": "^2.3.1", "html-entities": "^2.4.0", "jose": "^5.0.1", "lodash-es": "^4.17.21", @@ -35,6 +35,7 @@ "redis": "^4.6.10", "slate": "^0.100.0", "slate-react": "^0.100.0", + "swr": "^2.2.4", "uuid": "^9.0.1", "yjs": "^13.6.8", "zod": "^3.22.4" @@ -3514,6 +3515,14 @@ "@types/lodash": "*" } }, + "node_modules/@types/lodash.memoize": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/lodash.memoize/-/lodash.memoize-4.1.9.tgz", + "integrity": "sha512-glY1nQuoqX4Ft8Uk+KfJudOD7DQbbEDF6k9XpGncaohW3RW4eSWBlx6AA0fZCrh40tZcQNH4jS/Oc59J6Eq+aw==", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mime": { "version": "1.3.4", "dev": true, @@ -4586,6 +4595,11 @@ "url": "https://joebell.co.uk" } }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" + }, "node_modules/cliui": { "version": "8.0.1", "dev": true, @@ -6957,6 +6971,18 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-user-locale": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/get-user-locale/-/get-user-locale-2.3.1.tgz", + "integrity": "sha512-VEvcsqKYx7zhZYC1CjecrDC5ziPSpl1gSm0qFFJhHSGDrSC+x4+p1KojWC/83QX//j476gFhkVXP/kNUc9q+bQ==", + "dependencies": { + "@types/lodash.memoize": "^4.1.7", + "lodash.memoize": "^4.1.1" + }, + "funding": { + "url": "https://github.com/wojtekmaj/get-user-locale?sponsor=1" + } + }, "node_modules/glob": { "version": "7.2.3", "dev": true, @@ -8749,7 +8775,6 @@ }, "node_modules/lodash.memoize": { "version": "4.1.2", - "dev": true, "license": "MIT" }, "node_modules/lodash.merge": { @@ -10601,6 +10626,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.4.tgz", + "integrity": "sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ==", + "dependencies": { + "client-only": "^0.0.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "dev": true, diff --git a/package.json b/package.json index 8706c1d7..3d11b971 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "class-variance-authority": "^0.7.0", "cookie-parser": "^1.4.6", "cors": "^2.8.5", - "date-fns": "^2.30.0", "express": "^4.18.2", "express-ws": "^5.0.2", + "get-user-locale": "^2.3.1", "html-entities": "^2.4.0", "jose": "^5.0.1", "lodash-es": "^4.17.21", @@ -48,6 +48,7 @@ "redis": "^4.6.10", "slate": "^0.100.0", "slate-react": "^0.100.0", + "swr": "^2.2.4", "uuid": "^9.0.1", "yjs": "^13.6.8", "zod": "^3.22.4" diff --git a/src/components/DataItem/SectorBadge.tsx b/src/components/DataItem/SectorBadge.tsx new file mode 100644 index 00000000..cca0ea3c --- /dev/null +++ b/src/components/DataItem/SectorBadge.tsx @@ -0,0 +1,41 @@ +import { Badge } from '@ttab/elephant-ui' +import { cn } from '@ttab/elephant-ui/utils' + +export const SectorBadge = ({ color, value }: { color?: string, value: string }): JSX.Element => { + const sector = sectors.find((label) => label.value === value) + const chosenColor = color || sector?.color + + return +
+ {sector?.label} + +} + +// FIXME: This should not be hardcoded!!! +const sectors = [ + { + value: 'Utrikes', + label: 'Utrikes', + color: 'bg-[#BD6E11]' + }, + { + value: 'Inrikes', + label: 'Inrikes', + color: 'bg-[#DA90E1]' + }, + { + value: 'Sport', + label: 'Sport', + color: 'bg-[#6CA8DF]' + }, + { + value: 'Kultur och nöje', + label: 'Kultur & Nöje', + color: 'bg-[#12E1D4]' + }, + { + value: 'Ekonomi', + label: 'Ekonomi', + color: 'bg-[#FFB9B9]' + } +] diff --git a/src/components/DataItem/StatusIndicator.tsx b/src/components/DataItem/StatusIndicator.tsx new file mode 100644 index 00000000..59caae79 --- /dev/null +++ b/src/components/DataItem/StatusIndicator.tsx @@ -0,0 +1,37 @@ +import { + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent +} from '@ttab/elephant-ui' + +import { cn } from '@ttab/elephant-ui/utils' +import { cva } from 'class-variance-authority' + +export const StatusIndicator = ({ internal }: { internal: boolean }): JSX.Element => { + const status = cva('flex items-center h-2 w-2 rounded-full mx-4', { + variants: { + internal: { + true: 'border', + false: 'bg-[#5895FF]' + } + } + }) + + return ( + + + +
+ + + +

{internal ? 'Internal' : 'Public'}

+
+ + + + ) +} diff --git a/src/components/PlanningHeader/Datechanger.tsx b/src/components/PlanningHeader/Datechanger.tsx deleted file mode 100644 index 07e64ea9..00000000 --- a/src/components/PlanningHeader/Datechanger.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { ChevronLeft, ChevronRight } from '@ttab/elephant-ui/icons' -import { type PlanningHeaderProps } from '.' -import { DatePicker } from './Datepicker' - -export const DateChanger = ({ date, setDate }: PlanningHeaderProps): JSX.Element => ( - <> - setDate(new Date(date.setDate(date.getDate() - 1)))} - /> - - setDate(new Date(date.setDate(date.getDate() + 1)))} - /> - -) - diff --git a/src/components/PlanningHeader/Datepicker.tsx b/src/components/PlanningHeader/Datepicker.tsx deleted file mode 100644 index 2cfd9104..00000000 --- a/src/components/PlanningHeader/Datepicker.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { format } from 'date-fns' -import { sv } from 'date-fns/locale' -import { type Dispatch, type SetStateAction } from 'react' - -import { Calendar as CalendarIcon } from '@ttab/elephant-ui/icons' - -import { - Button, - Calendar, - Popover, - PopoverContent, - PopoverTrigger -} from '@ttab/elephant-ui' -import { cn } from '@ttab/elephant-ui/utils' - -interface DatePickerProps { - date: Date - setDate: Dispatch> -} - -export const DatePicker = ({ date, setDate }: DatePickerProps): JSX.Element => ( - - - - - - selectedDate && - setDate(selectedDate)} - initialFocus - /> - - -) diff --git a/src/components/PlanningHeader/index.tsx b/src/components/PlanningHeader/index.tsx deleted file mode 100644 index 99370b2f..00000000 --- a/src/components/PlanningHeader/index.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { CalendarSearch } from '@ttab/elephant-ui/icons' -import { DateChanger } from './Datechanger' -import { TabsGrid } from './LayoutSwitch' -import { Filter } from './Filter' -import { type Dispatch, type SetStateAction } from 'react' - -export interface PlanningHeaderProps { - date: Date - setDate: Dispatch> -} -export const PlanningHeader = ({ date, setDate }: PlanningHeaderProps): JSX.Element => ( -
- -

- Planning -

- - - -
-) diff --git a/src/components/PlanningTable/Columns/Section.tsx b/src/components/PlanningTable/Columns/Section.tsx index 8007d8ab..e1a91ee6 100644 --- a/src/components/PlanningTable/Columns/Section.tsx +++ b/src/components/PlanningTable/Columns/Section.tsx @@ -1,18 +1,11 @@ import { type ColumnDef } from '@tanstack/react-table' -import { Badge } from '@ttab/elephant-ui' -import { cn } from '@ttab/elephant-ui/utils' import { type Planning } from '../data/schema' -import { sectors } from '../data/settings' +import { SectorBadge } from '@/components/DataItem/SectorBadge' export const section: ColumnDef = { id: 'section', accessorFn: (data) => data._source['document.rel.sector.title'][0], cell: ({ row }) => { - const sector = sectors.find((label) => label.value === row.original._source['document.rel.sector.title'][0]) - - return sector && -
- {sector.label} - + return } } diff --git a/src/components/PlanningTable/Columns/Title.tsx b/src/components/PlanningTable/Columns/Title.tsx index a124424a..96dfdb76 100644 --- a/src/components/PlanningTable/Columns/Title.tsx +++ b/src/components/PlanningTable/Columns/Title.tsx @@ -1,37 +1,26 @@ import { type ColumnDef } from '@tanstack/react-table' -import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '@ttab/elephant-ui' -import { cn } from '@ttab/elephant-ui/utils' import { type Planning } from '../data/schema' +import { StatusIndicator } from '@/components/DataItem/StatusIndicator' export const title: ColumnDef = { id: 'title', accessorFn: (data) => data._source['document.title'][0], cell: ({ row }) => { - const status = row.original._source['document.meta.core_planning_item.data.public'][0] === 'true' + const internal = row.original._source['document.meta.core_planning_item.data.public'][0] !== 'true' const slugline = row.original._source['document.meta.core_assignment.meta.tt_slugline.value'] - const classNames = cn('flex items-center h-2 w-2 rounded-full mx-4', status - ? 'bg-[#5895FF]' - : 'border') return ( -
- - - -
- - -

{status ? 'Public' : 'Internal'}

-
- - - - {row.getValue('title')} - - {!!slugline?.length && ( - {slugline[0]} - )} -
+
+ + + + {row.getValue('title')} + + + {!!slugline?.length && ( + {slugline[0]} + )} +
) } } diff --git a/src/components/PlanningTable/data/settings.tsx b/src/components/PlanningTable/data/settings.tsx index ac500568..ef52c470 100644 --- a/src/components/PlanningTable/data/settings.tsx +++ b/src/components/PlanningTable/data/settings.tsx @@ -8,34 +8,6 @@ import { Video } from '@ttab/elephant-ui/icons' -export const sectors = [ - { - value: 'Utrikes', - label: 'Utrikes', - color: 'bg-[#BD6E11]' - }, - { - value: 'Inrikes', - label: 'Inrikes', - color: 'bg-[#DA90E1]' - }, - { - value: 'Sport', - label: 'Sport', - color: 'bg-[#6CA8DF]' - }, - { - value: 'Kultur och nöje', - label: 'Kultur & Nöje', - color: 'bg-[#12E1D4]' - }, - { - value: 'Ekonomi', - label: 'Ekonomi', - color: 'bg-[#FFB9B9]' - } -] - export const priorities = [ { label: 'High', diff --git a/src/components/ViewHeader/ViewHeader.tsx b/src/components/ViewHeader/ViewHeader.tsx new file mode 100644 index 00000000..a768a057 --- /dev/null +++ b/src/components/ViewHeader/ViewHeader.tsx @@ -0,0 +1,36 @@ +import { type ViewProps } from '@/types' +import { ViewFocus } from './ViewFocus' +import { useNavigation } from '@/hooks' +import { type LucideIcon } from '@ttab/elephant-ui/icons' + +interface ViewHeaderProps extends ViewProps { + title: string + icon?: LucideIcon + children?: JSX.Element | JSX.Element[] +} + +export const ViewHeader = ({ id, children, title, icon: Icon }: ViewHeaderProps): JSX.Element => { + const { state } = useNavigation() + + return ( +
+
+ {Icon !== undefined && + + } + +

+ {title} +

+ + {children} +
+ +
+ {state.content.length > 1 && + + } +
+
+ ) +} diff --git a/src/components/ViewHeader/index.tsx b/src/components/ViewHeader/index.tsx deleted file mode 100644 index 68b4481a..00000000 --- a/src/components/ViewHeader/index.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { type ViewProps } from '@/types' -import { ViewFocus } from './ViewFocus' -import { useNavigation } from '@/hooks' - -interface ViewHeaderProps extends ViewProps { - children?: JSX.Element | JSX.Element[] -} - -export const ViewHeader = ({ id, children }: ViewHeaderProps): JSX.Element => { - const { state } = useNavigation() - return ( -
- {children} - {state.content.length > 1 && - - } -
- ) -} diff --git a/src/components/index.tsx b/src/components/index.tsx index e74eb107..c0eae642 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -1,3 +1,3 @@ export { Link } from './Link/Link' -export { ViewHeader } from './ViewHeader' +export { ViewHeader } from './ViewHeader/ViewHeader' export { AppHeader } from './AppHeader' diff --git a/src/contexts/RegistryProvider.tsx b/src/contexts/RegistryProvider.tsx new file mode 100644 index 00000000..f9b191d7 --- /dev/null +++ b/src/contexts/RegistryProvider.tsx @@ -0,0 +1,99 @@ +import { + createContext, + useReducer, + useMemo, + type PropsWithChildren +} from 'react' +import { getUserLocale } from 'get-user-locale' +import { getUserTimeZone } from '@/lib/getUserTimeZone' + +export interface RegistryProviderState { + locale: string + timeZone: string +} + +interface RegistryProviderContext extends RegistryProviderState { + setLocale: (locale: string) => void + setTimeZone: (timeZone: string) => void +} + +type RegistryProviderActionType = 'SET_LOCALE' | 'SET_TIME_ZONE' +interface RegistryProviderAction { + type: RegistryProviderActionType + payload: unknown +} + +/** + * Initial state + * TODO: Fetch from future users settings stored in local storage or elsewhere + */ +const initialState: RegistryProviderState = { + locale: getUserLocale() || 'en-US', + timeZone: getUserTimeZone() || 'America/New_York' +} + +/** + * RegistryReducer + * + * @param state + * @param action + * @returns RegistryProviderState + */ +const reducer = (state: RegistryProviderState, action: RegistryProviderAction): RegistryProviderState => { + const { type, payload } = action + + switch (type) { + case 'SET_LOCALE': + if (typeof payload === 'string') { + return { ...state, locale: payload } + } + break + + case 'SET_TIME_ZONE': + if (typeof payload === 'string') { + return { ...state, timeZone: payload } + } + break + } + + return state +} + + +/** + * Registry context + */ +export const RegistryContext = createContext(initialState) + + +/** + * RegistryProvider + * + * @param children + * @returns JSX.Element + */ +const RegistryProvider = ({ children }: PropsWithChildren): JSX.Element => { + const [state, dispatch] = useReducer(reducer, initialState) + + // Memoize the context value to avoid unnecessary re-renders + const contextValue = useMemo((): RegistryProviderContext => { + return { + ...state, + setLocale: (locale: string) => dispatch({ + type: 'UPDATE_LOCALE' as RegistryProviderActionType, + payload: locale + }), + setTimeZone: (timeZone: string) => dispatch({ + type: 'UPDATE_TIME_ZONE' as RegistryProviderActionType, + payload: timeZone + }) + } + }, [state]) + + return + {children} + +} + + +export { RegistryProvider } diff --git a/src/contexts/index.tsx b/src/contexts/index.tsx index aaec46c2..f87eeada 100644 --- a/src/contexts/index.tsx +++ b/src/contexts/index.tsx @@ -2,3 +2,4 @@ export { SessionProvider, SessionProviderContext } from './SessionProvider' export { ApiProvider, ApiProviderContext } from './ApiProvider' export { ThemeProvider, ThemeProviderContext } from './ThemeProvider' export { WebSocketProvider, WebSocketProviderContext } from './WebSocketProvider' +export { RegistryProvider, RegistryContext } from './RegistryProvider' diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index 4fddc816..f2712076 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -4,3 +4,5 @@ export { useHistory } from '@/navigation/hooks/useHistory' export { useResize } from '@/navigation/hooks/useResize' export { useSession } from './useSession' export { useQuery } from './useQuery' +export { useApi } from './useApi' +export { useRegistry } from './useRegistry' diff --git a/src/hooks/useRegistry.tsx b/src/hooks/useRegistry.tsx new file mode 100644 index 00000000..ed909a64 --- /dev/null +++ b/src/hooks/useRegistry.tsx @@ -0,0 +1,16 @@ +import { useContext } from 'react' +import { type RegistryProviderState, RegistryContext } from '../contexts/RegistryProvider' + +/** + * Registry hook + * + * @returns RegistryProviderState + */ +export const useRegistry = (): RegistryProviderState => { + const context = useContext(RegistryContext) + + if (!context) { + throw new Error('useRegistry must be used within a RegistryProvider') + } + return context +} diff --git a/src/lib/datetime.ts b/src/lib/datetime.ts new file mode 100644 index 00000000..afaf3b68 --- /dev/null +++ b/src/lib/datetime.ts @@ -0,0 +1,50 @@ +/** + * Format a local date/time to ISO format but in the UTC timezone. + * + * @param localDate + * @param locale + * @returns string + */ +export function convertToISOStringInUTC(localDate: Date, locale: string): string { + return convertToISOStringInTimeZone(localDate, locale, 'UTC') +} + +/** + * Format a date/time to ISO format but in the local timezone. + * + * @param localDate + * @param locale + * @param timeZone + * @returns string + */ +export function convertToISOStringInTimeZone(localDate: Date, locale: string, timeZone: string): string { + return localDate.toLocaleString(locale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + timeZone + }) +} + +/** + * Return start time and end time of local date. I.e a date with the local date/time + * "2024-12-28 12:10:43" will return "2024-12-28 00:00:00" and "2024-12-28 23:59:59". + * + * @param localDate + * @returns {Date, Date} + */ +export function getDateTimeBoundaries(localDate: Date): { startTime: Date, endTime: Date } { + const startTime = new Date(localDate) + const endTime = new Date(localDate) + + startTime.setHours(0, 0, 0, 0) + endTime.setHours(23, 59, 59, 999) + + return { + startTime, + endTime + } +} diff --git a/src/lib/getUserTimeZone.ts b/src/lib/getUserTimeZone.ts new file mode 100644 index 00000000..f48112a0 --- /dev/null +++ b/src/lib/getUserTimeZone.ts @@ -0,0 +1,10 @@ +export const getUserTimeZone = (): string | undefined => { + // Create a DateTimeFormat object with an undefined locale + const dateTimeFormat = new Intl.DateTimeFormat(undefined, { timeZoneName: 'long' }) + + // Retrieve the resolved options, which includes the timeZone + const timeZone = dateTimeFormat.resolvedOptions().timeZone + + // Return timeZone or specifically undefined as timeZone _can_ be privacy protected by some users + return timeZone || undefined +} diff --git a/src/lib/planning/search.ts b/src/lib/planning/search.ts index 33afcfb2..1ce44792 100644 --- a/src/lib/planning/search.ts +++ b/src/lib/planning/search.ts @@ -5,7 +5,8 @@ interface SaerchPlanningParams { skip?: number size?: number where?: { - startDate?: string | Date + start?: string | Date + end?: string | Date } sort?: { start?: 'asc' | 'desc' @@ -14,7 +15,8 @@ interface SaerchPlanningParams { } export const search = async (endpoint: URL, jwt: JWT, params?: SaerchPlanningParams): Promise => { - const startDate = params?.where?.startDate ? new Date(params.where.startDate) : new Date() + const start = params?.where?.start ? new Date(params.where.start) : new Date() + const end = params?.where?.end ? new Date(params.where.end) : new Date() const sort: Array> = [] if (params?.sort?.start && ['asc', 'desc'].includes(params.sort.start)) { @@ -32,8 +34,11 @@ export const search = async (endpoint: URL, jwt: JWT, params?: SaerchPlanningPar bool: { must: [ { - term: { - 'document.meta.core_assignment.data.start_date': startDate.toISOString() + range: { + 'document.meta.core_assignment.data.start_date': { + gte: start.toISOString(), + lte: end.toISOString() + } } } ] diff --git a/src/main.tsx b/src/main.tsx index 58a67fdd..7c53512c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { App } from './App.tsx' import { ThemeProvider, ApiProvider, SessionProvider } from '@/contexts' import { NavigationProvider } from '@/navigation/components' import { banner } from './lib/banner.ts' +import { RegistryProvider } from './contexts/RegistryProvider.tsx' banner() @@ -19,11 +20,13 @@ ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - - - + + + + + + + diff --git a/src/navigation/components/NavigationWrapper/index.tsx b/src/navigation/components/NavigationWrapper/index.tsx index 629c67cd..25e986aa 100644 --- a/src/navigation/components/NavigationWrapper/index.tsx +++ b/src/navigation/components/NavigationWrapper/index.tsx @@ -6,7 +6,7 @@ import { cva } from 'class-variance-authority' // TODO: Implement use of @container queries through @tailwindcss/container-queries -const section = cva('p-2', { +const section = cva('', { variants: { active: { true: 'border-t-green-500 border-t-4', diff --git a/src/views/Editor/index.tsx b/src/views/Editor/index.tsx index 18fb99e7..ae15f006 100644 --- a/src/views/Editor/index.tsx +++ b/src/views/Editor/index.tsx @@ -1,7 +1,7 @@ import { ViewHeader } from '@/components' import { useApi } from '@/hooks/useApi' import { YjsEditor, withCursors, withYHistory, withYjs } from '@slate-yjs/core' -import { PenLine } from '@ttab/elephant-ui/icons' +import { PenBoxIcon } from '@ttab/elephant-ui/icons' import { TextbitEditable } from '@ttab/textbit' import '@ttab/textbit/dist/esm/index.css' import { useEffect, useMemo, useState } from 'react' @@ -93,16 +93,8 @@ const Editor = (props: ViewProps): JSX.Element => { return ( <> - -
- -

- Editor -

-
-
+ +
{ /* @ts-expect-error yjsEditor needs more refinement */} diff --git a/src/views/PlanningOverview/PlanningGrid.tsx b/src/views/PlanningOverview/PlanningGrid.tsx new file mode 100644 index 00000000..ae821267 --- /dev/null +++ b/src/views/PlanningOverview/PlanningGrid.tsx @@ -0,0 +1,123 @@ +import { type SearchIndexResponse } from '@/lib/index/search' +import { Planning } from '@/lib/planning' +import { type Planning as PlanningType } from '@/components/PlanningTable/data/schema' + +import { cn } from '@ttab/elephant-ui/utils' +import { cva, type VariantProps } from 'class-variance-authority' +import { PlanningGridColumn } from './PlanningGridColumn' +import { useSession, useApi, useRegistry } from '@/hooks' +import { convertToISOStringInTimeZone, convertToISOStringInUTC, getDateTimeBoundaries } from '@/lib/datetime' +import useSWR from 'swr' +import { useMemo } from 'react' + +type PlanningsByDate = Record + +interface PlanningGridProps { + startDate: Date + endDate: Date +} + +export const PlanningGrid = ({ startDate, endDate }: PlanningGridProps): JSX.Element => { + const { indexUrl } = useApi() + const { jwt } = useSession() + const { locale, timeZone } = useRegistry() + const { startTime } = getDateTimeBoundaries(startDate) + const { endTime } = getDateTimeBoundaries(endDate) + + // Create url to base SWR caching on + const searchUrl = useMemo(() => { + const start = convertToISOStringInUTC(startTime, locale) + const end = convertToISOStringInUTC(endTime, locale) + const searchUrl = new URL(indexUrl) + + searchUrl.search = new URLSearchParams({ start, end }).toString() + return searchUrl + }, [startTime, endTime, indexUrl, locale]) + + const { data } = useSWR(searchUrl.href, async (): Promise => { + if (!jwt) { + return + } + + const result = await Planning.search(indexUrl, jwt, { + size: 500, + where: { + start: searchUrl.searchParams.get('start') as string, + end: searchUrl.searchParams.get('end') as string + }, + sort: { + start: 'asc' + } + }) + + return (result?.ok) ? structureByDate(result, startTime, endTime, locale, timeZone) : undefined + }) + + type GridVariantsProps = VariantProps + type GridSize = Extract<1 | 2 | 3 | 4 | 5 | 6 | 7, GridVariantsProps['size']> + + const gridVariants = cva('grid grid-cols-1', { + variants: { + size: { + 1: 'grid-cols-1', + 2: 'grid-cols-2', + 3: 'grid-cols-3', + 4: 'grid-cols-4', + 5: 'grid-cols-5', + 6: 'grid-cols-6', + 7: 'grid-cols-7' + } + } + }) + + if (data === undefined) { + return <> + } + + const gridProps = { + size: (Object.keys(data || {}).length || 1) as GridSize + } + + return ( +
+ {Object.keys(data).sort((dt1, dt2) => { return dt1 > dt2 ? 1 : -1 }).map((key) => ( + + ))} +
+ ) +} + + +function structureByDate(result: SearchIndexResponse, startTime: Date, endTime: Date, locale: string, timeZone: string): PlanningsByDate | undefined { + const plannings: PlanningsByDate = {} + + if (!Array.isArray(result?.hits)) { + return + } + + for (const item of result.hits) { + // A planning item can have assignments outside of the wanted period. Filter and sort + // so that we have an ordered list of assignment dates within the wanted period + const assignmentDatesInInterval = item._source['document.meta.core_assignment.data.start'].map(strDate => { + return new Date(strDate) + }).filter((date) => { + return date >= startTime && date <= endTime + }).sort((dt1, dt2) => { + return dt1 > dt2 ? 1 : -1 + }) + + const localStart = convertToISOStringInTimeZone( + assignmentDatesInInterval[0], + locale, + timeZone + ) + const date = localStart.substring(0, 10) + if (!Array.isArray(plannings[date])) { + plannings[date] = [] + } + + plannings[date].push(item) + } + + return plannings +} diff --git a/src/views/PlanningOverview/PlanningGridColumn.tsx b/src/views/PlanningOverview/PlanningGridColumn.tsx new file mode 100644 index 00000000..a3ff60bf --- /dev/null +++ b/src/views/PlanningOverview/PlanningGridColumn.tsx @@ -0,0 +1,83 @@ + +import { type Planning as PlanningType } from '@/components/PlanningTable/data/schema' +import { useRegistry } from '@/hooks' +import { SectorBadge } from '@/components/DataItem/SectorBadge' +import { StatusIndicator } from '@/components/DataItem/StatusIndicator' + +interface PlanningGridColumnProps { + date: Date + items: PlanningType[] +} + +export const PlanningGridColumn = ({ date, items }: PlanningGridColumnProps): JSX.Element => { + const { locale, timeZone } = useRegistry() + + const [weekday, day] = new Intl.DateTimeFormat(locale, { + weekday: 'short', + day: 'numeric', + timeZone + }).format(date).split(' ') + + return ( +
+
+
{weekday}
+
{day}
+
+ +
+ {items.map(item => { + const internal = item._source['document.meta.core_planning_item.data.public'][0] !== 'true' + const title = item._source['document.title'][0] + const slugLine = item._source['document.meta.core_assignment.meta.tt_slugline.value'] + const startTime = getPublishTime( + item._source['document.meta.core_assignment.data.publish'], + locale, + timeZone + ) + + return
+
+
+ +
+ +
{title}
+ + {!!startTime && +
{startTime}
+ } +
+ +
+ {slugLine} + +
+
+ })} +
+
+ ) +} + +function getPublishTime(assignmentPublishTimes: string[], locale: string, timeZone: string): string | undefined { + if (!Array.isArray(assignmentPublishTimes)) { + return + } + + const startTimes = assignmentPublishTimes.filter(dt => { + return !!dt + }).sort((dt1, dt2) => { + return dt1 >= dt2 ? 1 : -1 + }) + + if (!startTimes.length) { + return + } + + return new Intl.DateTimeFormat(locale, { + hour: '2-digit', + minute: '2-digit', + timeZone + }).format(new Date(startTimes[0])) +} diff --git a/src/views/PlanningOverview/PlanningHeader/Datechanger.tsx b/src/views/PlanningOverview/PlanningHeader/Datechanger.tsx new file mode 100644 index 00000000..dd3fb3c6 --- /dev/null +++ b/src/views/PlanningOverview/PlanningHeader/Datechanger.tsx @@ -0,0 +1,52 @@ +import { ChevronLeft, ChevronRight } from '@ttab/elephant-ui/icons' +import { DatePicker } from './Datepicker' +import { + type Dispatch, + type SetStateAction +} from 'react' + +interface DateChangerProps { + startDate: Date + setStartDate: Dispatch> + endDate?: Date + setEndDate?: Dispatch> +} + +// FIXME: Implement handling of date intervals, commented out at the moment + +export const DateChanger = ({ + startDate, + setStartDate, + endDate, + setEndDate +}: DateChangerProps): JSX.Element => { + const steps = !!endDate && !!setEndDate ? 7 : 1 + + return ( +
+ setStartDate(decrementDate(startDate, steps))} + /> + + + + {/* {!!endDate && !!setEndDate && + + } */} + + setStartDate(incrementDate(startDate, steps))} + /> +
+ ) +} + +function decrementDate(date: Date, steps: number): Date { + return new Date(date.setDate(date.getDate() - steps)) +} + +function incrementDate(date: Date, steps: number): Date { + return new Date(date.setDate(date.getDate() + steps)) +} diff --git a/src/views/PlanningOverview/PlanningHeader/Datepicker.tsx b/src/views/PlanningOverview/PlanningHeader/Datepicker.tsx new file mode 100644 index 00000000..f7b45cb3 --- /dev/null +++ b/src/views/PlanningOverview/PlanningHeader/Datepicker.tsx @@ -0,0 +1,55 @@ +import { type Dispatch, type SetStateAction } from 'react' + +import { Calendar as CalendarIcon } from '@ttab/elephant-ui/icons' + +import { + Button, + Calendar, + Popover, + PopoverContent, + PopoverTrigger +} from '@ttab/elephant-ui' +import { cn } from '@ttab/elephant-ui/utils' +import { useRegistry } from '@/hooks' + +interface DatePickerProps { + date: Date + setDate: Dispatch> +} + +export const DatePicker = ({ date, setDate }: DatePickerProps): JSX.Element => { + const { locale, timeZone } = useRegistry() + const formattedDate = new Intl.DateTimeFormat(locale, { + weekday: 'short', + month: 'short', + day: 'numeric', + timeZone + }).format(date) + + return ( + + + + + + selectedDate && + setDate(selectedDate)} + initialFocus + /> + + + + ) +} diff --git a/src/components/PlanningHeader/Filter.tsx b/src/views/PlanningOverview/PlanningHeader/Filter.tsx similarity index 100% rename from src/components/PlanningHeader/Filter.tsx rename to src/views/PlanningOverview/PlanningHeader/Filter.tsx diff --git a/src/components/PlanningHeader/LayoutSwitch.tsx b/src/views/PlanningOverview/PlanningHeader/LayoutSwitch.tsx similarity index 86% rename from src/components/PlanningHeader/LayoutSwitch.tsx rename to src/views/PlanningOverview/PlanningHeader/LayoutSwitch.tsx index 686624dd..e3857491 100644 --- a/src/components/PlanningHeader/LayoutSwitch.tsx +++ b/src/views/PlanningOverview/PlanningHeader/LayoutSwitch.tsx @@ -2,7 +2,7 @@ import { TabsList, TabsTrigger } from '@ttab/elephant-ui' import { Grid2x2, List } from '@ttab/elephant-ui/icons' export const TabsGrid = (): JSX.Element => ( - + @@ -11,4 +11,3 @@ export const TabsGrid = (): JSX.Element => ( ) - diff --git a/src/views/PlanningOverview/PlanningHeader/index.tsx b/src/views/PlanningOverview/PlanningHeader/index.tsx new file mode 100644 index 00000000..86f81031 --- /dev/null +++ b/src/views/PlanningOverview/PlanningHeader/index.tsx @@ -0,0 +1,31 @@ +import { DateChanger } from './Datechanger' +import { TabsGrid } from './LayoutSwitch' +import { Filter } from './Filter' +import { + type Dispatch, + type SetStateAction +} from 'react' + +export interface PlanningHeaderProps { + tab: string + startDate: Date + setStartDate: Dispatch> + endDate?: Date + setEndDate?: Dispatch> +} + +export const PlanningHeader = ({ tab, startDate, setStartDate, endDate, setEndDate }: PlanningHeaderProps): JSX.Element => { + return <> + + + {tab === 'list' && + } + + {tab === 'grid' && + } + + + +} diff --git a/src/views/PlanningOverview/PlanningList.tsx b/src/views/PlanningOverview/PlanningList.tsx new file mode 100644 index 00000000..8d6280c1 --- /dev/null +++ b/src/views/PlanningOverview/PlanningList.tsx @@ -0,0 +1,50 @@ +import { useSession, useApi, useRegistry } from '@/hooks' +import { type SearchIndexResponse } from '@/lib/index/search' +import { Planning } from '@/lib/planning' +import { PlanningTable } from '@/components/PlanningTable' +import { columns } from '@/components/PlanningTable/Columns' + +import { convertToISOStringInUTC, getDateTimeBoundaries } from '@/lib/datetime' +import useSWR from 'swr' +import { useMemo } from 'react' + +export const PlanningList = ({ date }: { date: Date }): JSX.Element => { + const { locale } = useRegistry() + const { jwt } = useSession() + const { indexUrl } = useApi() + const { startTime, endTime } = getDateTimeBoundaries(date) + + // Create url to base SWR caching on + const searchUrl = useMemo(() => { + const start = convertToISOStringInUTC(startTime, locale) + const end = convertToISOStringInUTC(endTime, locale) + const searchUrl = new URL(indexUrl) + + searchUrl.search = new URLSearchParams({ start, end }).toString() + return searchUrl + }, [startTime, endTime, indexUrl, locale]) + + + const { data } = useSWR(searchUrl.href, async (): Promise => { + if (!jwt) { + return + } + + const { startTime, endTime } = getDateTimeBoundaries(date) + return await Planning.search(indexUrl, jwt, { + size: 100, + where: { + start: convertToISOStringInUTC(startTime, locale), + end: convertToISOStringInUTC(endTime, locale) + } + }) + }) + + return ( + <> + {data?.ok === true && + + } + + ) +} diff --git a/src/views/PlanningOverview/index.tsx b/src/views/PlanningOverview/index.tsx index 4e5a75b6..e3c0755d 100644 --- a/src/views/PlanningOverview/index.tsx +++ b/src/views/PlanningOverview/index.tsx @@ -1,18 +1,13 @@ +import { useEffect, useState } from 'react' import { type ViewMetadata, type ViewProps } from '@/types' import { ViewHeader } from '@/components' -import { useSession } from '@/hooks' -import { useApi } from '@/hooks/useApi' -import { type SearchIndexResponse } from '@/lib/index/search' -import { Planning } from '@/lib/planning' -import { useEffect, useState } from 'react' +import { CalendarDaysIcon } from '@ttab/elephant-ui/icons' +import { PlanningHeader } from './PlanningHeader' +import { Tabs, TabsContent } from '@ttab/elephant-ui' + +import { PlanningGrid } from './PlanningGrid' +import { PlanningList } from './PlanningList' -import { PlanningHeader } from '@/components/PlanningHeader' -import { PlanningTable } from '@/components/PlanningTable' -import { columns } from '@/components/PlanningTable/Columns' -import { - Tabs, - TabsContent -} from '@ttab/elephant-ui' const meta: ViewMetadata = { name: 'PlanningOverview', @@ -27,51 +22,44 @@ const meta: ViewMetadata = { } export const PlanningOverview = (props: ViewProps): JSX.Element => { - const { jwt } = useSession() - const { indexUrl } = useApi() - const [result, setResult] = useState() - const [date, setDate] = useState(new Date()) + const [startDate, setStartDate] = useState(new Date()) + const [endDate, setEndDate] = useState(getEndDate(startDate)) + const [currentTab, setCurrentTab] = useState('list') useEffect(() => { - if (!jwt) { - return - } - - const args = { - size: 500, - where: { - startDate: date.toISOString().replace(/T.*$/, 'T00:00:00Z') - } - } - - Planning.search(indexUrl, jwt, args) - .then(result => { - setResult(result) - }) - .catch(ex => { - console.log(ex) - }) - }, [indexUrl, jwt, date]) + setEndDate(getEndDate(startDate)) + }, [startDate]) return ( - - - + + + + -
- {result?.ok === true && - <> - - - - - Grid - - - } + +
+ + + + + + +
) } PlanningOverview.meta = meta + +function getEndDate(startDate: Date): Date { + const endDate = new Date() + endDate.setDate(startDate.getDate() + 6) + return endDate +}