diff --git a/.env.test b/.env.test new file mode 100644 index 000000000..294497791 --- /dev/null +++ b/.env.test @@ -0,0 +1,6 @@ +PORT=3002 +NEXT_PUBLIC_BASE_URL=http://localhost:3002 +NEXTAUTH_URL=http://localhost:3002 +SECRET_PASSWORD=abcdefghijklmnopqrstuvwxyz1234567890 +DATABASE_URL=postgres://postgres:postgres@localhost:5432/rallly +SUPPORT_EMAIL=support@rallly.co \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19cf25d09..1a7dca149 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,9 +58,7 @@ jobs: - name: Set environment variables run: | - echo "DATABASE_URL=postgresql://postgres:password@localhost:5432/db" >> $GITHUB_ENV - echo "SECRET_PASSWORD=abcdefghijklmnopqrstuvwxyz1234567890" >> $GITHUB_ENV - echo "SUPPORT_EMAIL=support@rallly.co" >> $GITHUB_ENV + echo "DATABASE_URL=postgresql://postgres:password@localhost:5432/rallly" >> $GITHUB_ENV - name: Install dependencies run: yarn install --frozen-lockfile @@ -68,7 +66,7 @@ jobs: - name: Run db run: | docker pull postgres:14.2 - docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=password -e POSTGRES_DB=db postgres:14.2 + docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=password -e POSTGRES_DB=rallly postgres:14.2 yarn wait-on --timeout 60000 tcp:localhost:5432 - name: Deploy migrations diff --git a/apps/web/declarations/next-auth.d.ts b/apps/web/declarations/next-auth.d.ts new file mode 100644 index 000000000..42e5183c4 --- /dev/null +++ b/apps/web/declarations/next-auth.d.ts @@ -0,0 +1,38 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import type { TimeFormat } from "@rallly/database"; +import { extend } from "lodash"; +import NextAuth, { DefaultSession, DefaultUser } from "next-auth"; +import { DefaultJWT, JWT } from "next-auth/jwt"; + +declare module "next-auth" { + /** + * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context + */ + interface Session { + user: { + id: string; + name?: string | null; + email?: string | null; + timeZone?: string | null; + timeFormat?: TimeFormat | null; + locale?: string | null; + weekStart?: number | null; + }; + } + + interface User extends DefaultUser { + locale?: string | null; + timeZone?: string | null; + timeFormat?: TimeFormat | null; + weekStart?: number | null; + } +} + +declare module "next-auth/jwt" { + interface JWT extends DefaultJWT { + locale?: string | null; + timeZone?: string | null; + timeFormat?: TimeFormat | null; + weekStart?: number | null; + } +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index fd36f9494..4f11a03dc 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,5 @@ /// /// -/// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index 6d3565478..7ac379b5d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,11 +11,12 @@ "lint:tsc": "tsc --noEmit", "i18n:scan": "i18next-scanner --config i18next-scanner.config.js", "prettier": "prettier --write ./src", - "test": "cross-env PORT=3001 playwright test", + "test": "playwright test", "test:codegen": "playwright codegen http://localhost:3000", "docker:start": "./scripts/docker-start.sh" }, "dependencies": { + "@auth/prisma-adapter": "^1.0.3", "@floating-ui/react-dom-interactions": "^0.13.3", "@headlessui/react": "^1.7.7", "@hookform/resolvers": "^3.3.1", @@ -38,6 +39,7 @@ "class-variance-authority": "^0.6.0", "cmdk": "^0.2.0", "color-hash": "^2.0.2", + "cookie": "^0.5.0", "crypto": "^1.0.1", "dayjs": "^1.11.10", "i18next": "^22.4.9", @@ -49,6 +51,7 @@ "lodash": "^4.17.21", "micro": "^10.0.1", "nanoid": "^4.0.0", + "next-auth": "^4.23.2", "next-i18next": "^13.0.3", "next-seo": "^5.15.0", "php-serialize": "^4.1.1", diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 184cdf6e3..f480b3281 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -4,7 +4,7 @@ import path from "path"; const ci = process.env.CI === "true"; -dotenv.config({ path: path.resolve(__dirname, "../../", ".env") }); +dotenv.config({ path: path.resolve(__dirname, "../../", ".env.test") }); // Use process.env.PORT by default and fallback to port 3000 const PORT = process.env.PORT || 3000; @@ -12,8 +12,6 @@ const PORT = process.env.PORT || 3000; // Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port const baseURL = `http://localhost:${PORT}`; -process.env.NEXT_PUBLIC_BASE_URL = baseURL; - // Reference: https://playwright.dev/docs/test-configuration const config: PlaywrightTestConfig = { // Artifacts folder where screenshots, videos, and traces are stored. @@ -31,6 +29,9 @@ const config: PlaywrightTestConfig = { timeout: 120 * 1000, reuseExistingServer: !ci, }, + expect: { + timeout: 10000, // 10 seconds + }, reporter: [ [ci ? "github" : "list"], ["html", { open: !ci ? "on-failure" : "never" }], diff --git a/apps/web/src/components/auth/auth-forms.tsx b/apps/web/src/components/auth/auth-forms.tsx index a04e09f88..50499e4b1 100644 --- a/apps/web/src/components/auth/auth-forms.tsx +++ b/apps/web/src/components/auth/auth-forms.tsx @@ -1,6 +1,8 @@ import { trpc } from "@rallly/backend"; import { Button } from "@rallly/ui/button"; import Link from "next/link"; +import { useRouter } from "next/router"; +import { signIn, useSession } from "next-auth/react"; import { Trans, useTranslation } from "next-i18next"; import React from "react"; import { useForm } from "react-hook-form"; @@ -13,32 +15,22 @@ import { TextInput } from "../text-input"; export const useDefaultEmail = createGlobalState(""); -const VerifyCode: React.FunctionComponent<{ +const verifyCode = async (options: { email: string; token: string }) => { + const res = await fetch( + "/api/auth/callback/email?" + new URLSearchParams(options), + ); + return res.status === 200; +}; + +export const VerifyCode: React.FunctionComponent<{ email: string; onSubmit: (code: string) => Promise; - onResend: () => Promise; onChange: () => void; -}> = ({ onChange, onSubmit, email, onResend }) => { +}> = ({ onChange, onSubmit, email }) => { const { register, handleSubmit, setError, formState } = useForm<{ code: string; }>(); const { t } = useTranslation(); - const [resendStatus, setResendStatus] = React.useState< - "ok" | "busy" | "disabled" - >("ok"); - - const handleResend = async () => { - setResendStatus("busy"); - try { - await onResend(); - setResendStatus("disabled"); - setTimeout(() => { - setResendStatus("ok"); - }, 1000 * 30); - } catch { - setResendStatus("ok"); - } - }; return (
@@ -110,184 +102,18 @@ const VerifyCode: React.FunctionComponent<{ > {t("continue")} -
); }; -type RegisterFormData = { - name: string; - email: string; -}; - -export const RegisterForm: React.FunctionComponent<{ - onClickLogin?: React.MouseEventHandler; - onRegistered?: () => void; -}> = ({ onClickLogin, onRegistered }) => { - const [defaultEmail, setDefaultEmail] = useDefaultEmail(); - const { t } = useTranslation(); - const { register, handleSubmit, getValues, setError, formState } = - useForm({ - defaultValues: { email: defaultEmail }, - }); - const queryClient = trpc.useContext(); - const requestRegistration = trpc.auth.requestRegistration.useMutation(); - const authenticateRegistration = - trpc.auth.authenticateRegistration.useMutation(); - const [token, setToken] = React.useState(); - const posthog = usePostHog(); - if (token) { - return ( - { - const res = await authenticateRegistration.mutateAsync({ - token, - code, - }); - - if (!res.user) { - throw new Error("Failed to authenticate user"); - } - - queryClient.invalidate(); - - onRegistered?.(); - posthog?.identify(res.user.id, { - email: res.user.email, - name: res.user.name, - }); - posthog?.capture("register"); - }} - onResend={async () => { - const values = getValues(); - await requestRegistration.mutateAsync({ - email: values.email, - name: values.name, - }); - }} - onChange={() => setToken(undefined)} - email={getValues("email")} - /> - ); - } - - return ( -
{ - const res = await requestRegistration.mutateAsync({ - email: data.email, - name: data.name, - }); - - if (!res.ok) { - switch (res.reason) { - case "userAlreadyExists": - setError("email", { - message: t("userAlreadyExists"), - }); - break; - case "emailNotAllowed": - setError("email", { - message: t("emailNotAllowed"), - }); - } - } else { - setToken(res.token); - } - })} - > -
{t("createAnAccount")}
-

- {t("stepSummary", { - current: 1, - total: 2, - })} -

-
- - - {formState.errors.name?.message ? ( -
- {formState.errors.name.message} -
- ) : null} -
-
- - - {formState.errors.email?.message ? ( -
- {formState.errors.email.message} -
- ) : null} -
- -
- { - setDefaultEmail(getValues("email")); - onClickLogin?.(e); - }} - /> - ), - }} - /> -
-
- ); -}; - export const LoginForm: React.FunctionComponent<{ onClickRegister?: ( e: React.MouseEvent, email: string, ) => void; - onAuthenticated?: () => void; -}> = ({ onAuthenticated, onClickRegister }) => { +}> = ({ onClickRegister }) => { const { t } = useTranslation(); const [defaultEmail, setDefaultEmail] = useDefaultEmail(); @@ -297,58 +123,44 @@ export const LoginForm: React.FunctionComponent<{ defaultValues: { email: defaultEmail }, }); - const requestLogin = trpc.auth.requestLogin.useMutation(); - const authenticateLogin = trpc.auth.authenticateLogin.useMutation(); - + const session = useSession(); const queryClient = trpc.useContext(); - const [token, setToken] = React.useState(); + const [email, setEmail] = React.useState(); const posthog = usePostHog(); + const router = useRouter(); + const callbackUrl = (router.query.callbackUrl as string) ?? "/"; - if (token) { + const sendVerificationEmail = (email: string) => { + return signIn("email", { + redirect: false, + email, + callbackUrl, + }); + }; + if (email) { return ( { - const res = await authenticateLogin.mutateAsync({ - code, - token, + const success = await verifyCode({ + email, + token: code, }); - - if (!res.user) { + if (!success) { throw new Error("Failed to authenticate user"); } else { - onAuthenticated?.(); queryClient.invalidate(); - posthog?.identify(res.user.id, { - email: res.user.email, - name: res.user.name, - }); - posthog?.capture("login"); - } - }} - onResend={async () => { - const values = getValues(); - const res = await requestLogin.mutateAsync({ - email: values.email, - }); - - if (res.ok) { - setToken(res.token); - } else { - switch (res.reason) { - case "emailNotAllowed": - setError("email", { - message: t("emailNotAllowed"), - }); - break; - case "userNotFound": - setError("email", { - message: t("userNotFound"), - }); - break; + const s = await session.update(); + if (s?.user) { + posthog?.identify(s.user.id, { + email: s.user.email, + name: s.user.name, + }); } + posthog?.capture("login"); + router.push(callbackUrl); } }} - onChange={() => setToken(undefined)} + onChange={() => setEmail(undefined)} email={getValues("email")} /> ); @@ -356,26 +168,15 @@ export const LoginForm: React.FunctionComponent<{ return (
{ - const res = await requestLogin.mutateAsync({ - email: data.email, - }); + onSubmit={handleSubmit(async ({ email }) => { + const res = await sendVerificationEmail(email); - if (res.ok) { - setToken(res.token); + if (res?.error) { + setError("email", { + message: t("userNotFound"), + }); } else { - switch (res.reason) { - case "emailNotAllowed": - setError("email", { - message: t("emailNotAllowed"), - }); - break; - case "userNotFound": - setError("email", { - message: t("userNotFound"), - }); - break; - } + setEmail(email); } })} > diff --git a/apps/web/src/components/clock.tsx b/apps/web/src/components/clock.tsx index 2c9cf9078..3bd87c03d 100644 --- a/apps/web/src/components/clock.tsx +++ b/apps/web/src/components/clock.tsx @@ -1,4 +1,3 @@ -import { trpc } from "@rallly/backend"; import { GlobeIcon } from "@rallly/icons"; import { cn } from "@rallly/ui"; import { @@ -19,36 +18,12 @@ import soft from "timezone-soft"; import { TimeFormatPicker } from "@/components/time-format-picker"; import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select"; import { Trans } from "@/components/trans"; +import { usePreferences } from "@/contexts/preferences"; import { useDayjs } from "@/utils/dayjs"; export const TimePreferences = () => { - const { timeZone, timeFormat } = useDayjs(); - const queryClient = trpc.useContext(); - - const { data } = trpc.userPreferences.get.useQuery(); - - const updatePreferences = trpc.userPreferences.update.useMutation({ - onMutate: (newPreferences) => { - queryClient.userPreferences.get.setData(undefined, (oldPreferences) => { - if (!oldPreferences) { - return null; - } - return { - ...oldPreferences, - timeFormat: newPreferences.timeFormat ?? oldPreferences?.timeFormat, - timeZone: newPreferences.timeZone ?? oldPreferences?.timeZone ?? null, - weekStart: newPreferences.weekStart ?? oldPreferences?.weekStart, - }; - }); - }, - onSuccess: () => { - queryClient.userPreferences.get.invalidate(); - }, - }); - - if (data === undefined) { - return null; - } + const { preferences, updatePreferences } = usePreferences(); + const { timeFormat, timeZone } = useDayjs(); return (
@@ -57,11 +32,9 @@ export const TimePreferences = () => { { - updatePreferences.mutate({ - timeZone: newTimeZone, - }); + updatePreferences({ timeZone: newTimeZone }); }} />
@@ -70,11 +43,9 @@ export const TimePreferences = () => { { - updatePreferences.mutate({ - timeFormat: newTimeFormat, - }); + updatePreferences({ timeFormat: newTimeFormat }); }} /> diff --git a/apps/web/src/components/featurebase.tsx b/apps/web/src/components/featurebase.tsx index 38be6b20f..02cb230bb 100644 --- a/apps/web/src/components/featurebase.tsx +++ b/apps/web/src/components/featurebase.tsx @@ -1,4 +1,3 @@ -import { trpc } from "@rallly/backend"; import { HelpCircleIcon } from "@rallly/icons"; import { cn } from "@rallly/ui"; import { Button } from "@rallly/ui/button"; @@ -7,6 +6,7 @@ import Script from "next/script"; import React from "react"; import { Trans } from "@/components/trans"; +import { useUser } from "@/components/user-provider"; import { isFeedbackEnabled } from "@/utils/constants"; const FeaturebaseScript = () => ( @@ -63,7 +63,7 @@ export const FeaturebaseChangelog = ({ className }: { className?: string }) => { }; export const FeaturebaseIdentify = () => { - const { data: user } = trpc.whoami.get.useQuery(); + const { user } = useUser(); React.useEffect(() => { if (user?.isGuest !== false || !isFeedbackEnabled) return; diff --git a/apps/web/src/components/layouts/new-poll-layout.tsx b/apps/web/src/components/layouts/new-poll-layout.tsx deleted file mode 100644 index 56b3d9a9d..000000000 --- a/apps/web/src/components/layouts/new-poll-layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from "react"; - -import { UserProvider } from "@/components/user-provider"; - -import { NextPageWithLayout } from "../../types"; - -export const NewPollLayout = ({ children }: React.PropsWithChildren) => { - return {children}; -}; - -export const getNewPolLayout: NextPageWithLayout["getLayout"] = - function getLayout(page) { - return {page}; - }; diff --git a/apps/web/src/components/layouts/poll-layout.tsx b/apps/web/src/components/layouts/poll-layout.tsx index 2ec41264f..bdd056a94 100644 --- a/apps/web/src/components/layouts/poll-layout.tsx +++ b/apps/web/src/components/layouts/poll-layout.tsx @@ -31,6 +31,7 @@ import { TopBar, TopBarTitle, } from "@/components/layouts/standard-layout/top-bar"; +import { LoginLink } from "@/components/login-link"; import { PageDialog, PageDialogDescription, @@ -157,7 +158,7 @@ const AdminControls = () => { const router = useRouter(); return ( -
+
{router.asPath !== pollLink ? ( ) : ( @@ -179,10 +180,9 @@ export const StandardLayout: React.FunctionComponent<{ hideNav?: boolean; }> = ({ children, hideNav, ...rest }) => { const key = hideNav ? "no-nav" : "nav"; - return ( - +
@@ -222,7 +222,7 @@ export const StandardLayout: React.FunctionComponent<{ ) : null} - + ); }; diff --git a/apps/web/src/components/login-link.tsx b/apps/web/src/components/login-link.tsx new file mode 100644 index 000000000..cd1b61577 --- /dev/null +++ b/apps/web/src/components/login-link.tsx @@ -0,0 +1,24 @@ +import Link, { LinkProps } from "next/link"; +import { useRouter } from "next/router"; +import React from "react"; + +export const LoginLink = React.forwardRef< + HTMLAnchorElement, + React.PropsWithChildren & { className?: string }> +>(function LoginLink({ children, ...props }, ref) { + const router = useRouter(); + return ( + { + e.preventDefault(); + props.onClick?.(e); + router.push("/login?callbackUrl=" + encodeURIComponent(router.asPath)); + }} + > + {children} + + ); +}); diff --git a/apps/web/src/components/page-dialog.tsx b/apps/web/src/components/page-dialog.tsx index 1f22acceb..df4c8a42e 100644 --- a/apps/web/src/components/page-dialog.tsx +++ b/apps/web/src/components/page-dialog.tsx @@ -8,9 +8,7 @@ export const PageDialog = (
{props.icon ? ( -

- -

+ ) : null} {props.children}
@@ -19,16 +17,16 @@ export const PageDialog = ( }; export const PageDialogContent = (props: React.PropsWithChildren) => { - return
{props.children}
; + return
{props.children}
; }; export const PageDialogHeader = (props: React.PropsWithChildren) => { - return
{props.children}
; + return
{props.children}
; }; export const PageDialogFooter = (props: React.PropsWithChildren) => { return ( -
+
{props.children}
); diff --git a/apps/web/src/components/poll/notifications-toggle.tsx b/apps/web/src/components/poll/notifications-toggle.tsx index c0363c9e7..f41a69ef4 100644 --- a/apps/web/src/components/poll/notifications-toggle.tsx +++ b/apps/web/src/components/poll/notifications-toggle.tsx @@ -2,7 +2,7 @@ import { trpc } from "@rallly/backend"; import { BellOffIcon, BellRingIcon } from "@rallly/icons"; import { Button } from "@rallly/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@rallly/ui/tooltip"; -import { useRouter } from "next/router"; +import { signIn } from "next-auth/react"; import { useTranslation } from "next-i18next"; import * as React from "react"; @@ -31,7 +31,6 @@ const NotificationsToggle: React.FunctionComponent = () => { const posthog = usePostHog(); - const router = useRouter(); const watch = trpc.polls.watch.useMutation({ onSuccess: () => { // TODO (Luke Vella) [2023-04-08]: We should have a separate query for getting watchers @@ -70,8 +69,7 @@ const NotificationsToggle: React.FunctionComponent = () => { className="flex items-center gap-2 px-2.5" onClick={async () => { if (user.isGuest) { - // TODO (Luke Vella) [2023-06-06]: Open Login Modal - router.push("/login"); + signIn(); return; } // toggle diff --git a/apps/web/src/components/register-link.tsx b/apps/web/src/components/register-link.tsx new file mode 100644 index 000000000..b81f74d2f --- /dev/null +++ b/apps/web/src/components/register-link.tsx @@ -0,0 +1,26 @@ +import Link, { LinkProps } from "next/link"; +import { useRouter } from "next/router"; +import React from "react"; + +export const RegisterLink = React.forwardRef< + HTMLAnchorElement, + React.PropsWithChildren & { className?: string }> +>(function RegisterLink({ children, ...props }, ref) { + const router = useRouter(); + return ( + { + e.preventDefault(); + props.onClick?.(e); + router.push( + "/register?callbackUrl=" + encodeURIComponent(router.asPath), + ); + }} + > + {children} + + ); +}); diff --git a/apps/web/src/components/settings/change-email-form.tsx b/apps/web/src/components/settings/change-email-form.tsx deleted file mode 100644 index dab0a929b..000000000 --- a/apps/web/src/components/settings/change-email-form.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, -} from "@rallly/ui/form"; -import { useForm } from "react-hook-form"; - -import { TextInput } from "@/components/text-input"; -import { Trans } from "@/components/trans"; -import { useUser } from "@/components/user-provider"; - -export const ChangeEmailForm = () => { - const { user } = useUser(); - - const form = useForm<{ - email: string; - }>({ - defaultValues: { - email: user.isGuest ? "" : user.email, - }, - }); - - if (user.isGuest) { - return null; - } - - return ( - - ( - - - - - - - - - )} - /> - {/*
- -
*/} - - ); -}; diff --git a/apps/web/src/components/settings/date-time-preferences.tsx b/apps/web/src/components/settings/date-time-preferences.tsx index 1e5fd77c9..67bcecb44 100644 --- a/apps/web/src/components/settings/date-time-preferences.tsx +++ b/apps/web/src/components/settings/date-time-preferences.tsx @@ -1,5 +1,4 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { trpc } from "@rallly/backend"; import { Button } from "@rallly/ui/button"; import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form"; import { @@ -16,6 +15,7 @@ import { z } from "zod"; import { TimeFormatPicker } from "@/components/time-format-picker"; import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select"; import { Trans } from "@/components/trans"; +import { usePreferences } from "@/contexts/preferences"; import { useDayjs } from "@/utils/dayjs"; const formSchema = z.object({ @@ -27,42 +27,25 @@ const formSchema = z.object({ type FormData = z.infer; const DateTimePreferencesForm = () => { - const { data: userPreferences } = trpc.userPreferences.get.useQuery(); + const { timeFormat, weekStart, timeZone, locale } = useDayjs(); + const { preferences, updatePreferences } = usePreferences(); const form = useForm({ resolver: zodResolver(formSchema), + defaultValues: { + timeFormat: preferences.timeFormat ?? timeFormat, + weekStart: preferences.weekStart ?? weekStart, + timeZone: preferences.timeZone ?? timeZone, + }, }); const { handleSubmit, formState } = form; - const queryClient = trpc.useContext(); - const update = trpc.userPreferences.update.useMutation({ - onSuccess: () => { - queryClient.userPreferences.get.invalidate(); - }, - }); - const deleteUserPreferences = trpc.userPreferences.delete.useMutation({ - onSuccess: () => { - queryClient.userPreferences.get.invalidate(); - }, - }); - - const { - timeFormat: localeTimeFormat, - weekStart: localeWeekStart, - timeZone, - locale, - } = useDayjs(); - - if (userPreferences === undefined) { - return null; - } - return (
{ - await update.mutateAsync(data); + updatePreferences(data); form.reset(data); })} > @@ -70,7 +53,6 @@ const DateTimePreferencesForm = () => { { return ( @@ -88,7 +70,6 @@ const DateTimePreferencesForm = () => { { return ( @@ -106,7 +87,6 @@ const DateTimePreferencesForm = () => { { return ( @@ -144,12 +124,17 @@ const DateTimePreferencesForm = () => { > - {userPreferences !== null ? ( + {preferences.timeFormat || preferences.weekStart ? (