Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: streak warning prompt for timezone #4071

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 36 additions & 2 deletions packages/shared/src/components/Icon.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ComponentProps, ReactElement } from 'react';
import React from 'react';
import type { ComponentProps, ReactElement, ReactNode } from 'react';
import React, { Children } from 'react';
import classNames from 'classnames';

export enum IconSize {
Expand Down Expand Up @@ -62,4 +62,38 @@ const Icon = ({
);
};

export /**
* Icon wrapper so we can use more then single element inside the icon
* prop on different components. Wrapper automatically applies icon
* props as size to all children.
*/
const IconWrapper = ({
size,
wrapperClassName,
children,
...rest
}: Omit<IconProps, 'className'> & {
wrapperClassName?: string;
children: ReactNode;
}): ReactElement => {
return (
<div className={wrapperClassName}>
{Children.map(children, (child) => {
if (React.isValidElement(child)) {
// so that className is no exposed from outside since components
// like Button override it for icons
const { className } = rest as { className: string };

return React.cloneElement<Props>(child as ReactElement, {
size,
className: classNames(child.props.className, className),
});
}

return child;
})}
</div>
);
};

export default Icon;
6 changes: 2 additions & 4 deletions packages/shared/src/components/icons/Warning/filled.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 2 additions & 4 deletions packages/shared/src/components/icons/Warning/outlined.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions packages/shared/src/components/modals/Prompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export function PromptElement(props: Partial<ModalProps>): ReactElement {
cancelButton = {},
okButton = {},
className = {},
shouldCloseOnOverlayClick,
},
} = prompt;
return (
Expand All @@ -46,6 +47,7 @@ export function PromptElement(props: Partial<ModalProps>): ReactElement {
overlayClassName="!z-max"
isDrawerOnMobile
drawerProps={{ displayCloseButton: false, appendOnRoot: true }}
shouldCloseOnOverlayClick={shouldCloseOnOverlayClick}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

adding support to disable outside click on prompt

{...props}
>
<Modal.Body>
Expand Down
14 changes: 12 additions & 2 deletions packages/shared/src/components/streak/ReadingStreakButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import classnames from 'classnames';
import { ReadingStreakPopup } from './popup';
import type { ButtonIconPosition } from '../buttons/Button';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import { ReadingStreakIcon } from '../icons';
import { ReadingStreakIcon, WarningIcon } from '../icons';
import { SimpleTooltip } from '../tooltips';
import type { UserStreak } from '../../graphql/users';
import { useViewSize, ViewSize } from '../../hooks';
Expand All @@ -17,6 +17,8 @@ import ConditionalWrapper from '../ConditionalWrapper';
import type { TooltipPosition } from '../tooltips/BaseTooltipContainer';
import { useAuthContext } from '../../contexts/AuthContext';
import { isSameDayInTimezone } from '../../lib/timezones';
import { IconWrapper } from '../Icon';
import { useStreakTimezoneOk } from '../../hooks/streaks/useStreakTimezoneOk';

interface ReadingStreakButtonProps {
streak: UserStreak;
Expand Down Expand Up @@ -77,6 +79,7 @@ export function ReadingStreakButton({
const hasReadToday =
streak?.lastViewAt &&
isSameDayInTimezone(new Date(streak.lastViewAt), new Date(), user.timezone);
const isTimezoneOk = useStreakTimezoneOk();

const handleToggle = useCallback(() => {
setShouldShowStreaks((state) => !state);
Expand Down Expand Up @@ -118,7 +121,14 @@ export function ReadingStreakButton({
id="reading-streak-header-button"
type="button"
iconPosition={iconPosition}
icon={<ReadingStreakIcon secondary={hasReadToday} />}
icon={
<IconWrapper wrapperClassName="relative flex items-center gap-2">
<ReadingStreakIcon secondary={hasReadToday} />
{!isTimezoneOk && (
<WarningIcon className="text-raw-cheese-40" secondary />
Copy link
Contributor Author

Choose a reason for hiding this comment

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

we want this specific color

)}
</IconWrapper>
}
variant={
isLaptop || isMobile ? ButtonVariant.Tertiary : ButtonVariant.Float
}
Expand Down
116 changes: 107 additions & 9 deletions packages/shared/src/components/streak/popup/ReadingStreakPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { addDays, subDays } from 'date-fns';
import { useQuery } from '@tanstack/react-query';
import classNames from 'classnames';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { StreakSection } from './StreakSection';
import { DayStreak, Streak } from './DayStreak';
import { generateQueryKey, RequestKey, StaleTime } from '../../../lib/query';
Expand All @@ -13,15 +14,31 @@ import { useAuthContext } from '../../../contexts/AuthContext';
import { useActions, useViewSize, ViewSize } from '../../../hooks';
import { ActionType } from '../../../graphql/actions';
import { Button, ButtonVariant } from '../../buttons/Button';
import { SettingsIcon } from '../../icons';
import { SettingsIcon, WarningIcon } from '../../icons';
import StreakReminderSwitch from '../StreakReminderSwitch';
import ReadingStreakSwitch from '../ReadingStreakSwitch';
import { useToggle } from '../../../hooks/useToggle';
import { ToggleWeekStart } from '../../widgets/ToggleWeekStart';
import { isWeekend, DayOfWeek } from '../../../lib/date';
import { DEFAULT_TIMEZONE, isSameDayInTimezone } from '../../../lib/timezones';
import {
DEFAULT_TIMEZONE,
getTimezoneOffsetLabel,
isSameDayInTimezone,
} from '../../../lib/timezones';
import { SimpleTooltip } from '../../tooltips';
import { isTesting } from '../../../lib/constants';
import {
timezoneMismatchIgnoreKey,
useStreakTimezoneOk,
} from '../../../hooks/streaks/useStreakTimezoneOk';
import { usePrompt } from '../../../hooks/usePrompt';
import usePersistentContext from '../../../hooks/usePersistentContext';
import { useLogContext } from '../../../contexts/LogContext';
import {
LogEvent,
StreakTimezonePromptAction,
TargetId,
} from '../../../lib/log';

const getStreak = ({
value,
Expand Down Expand Up @@ -82,10 +99,13 @@ interface ReadingStreakPopupProps {
fullWidth?: boolean;
}

const timezoneSettingsHref = '/account/notifications?s=timezone';
Copy link
Contributor Author

Choose a reason for hiding this comment

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

just in case we want to do something special when user comes from timezone prompt in the future


export function ReadingStreakPopup({
streak,
fullWidth,
}: ReadingStreakPopupProps): ReactElement {
const router = useRouter();
const isMobile = useViewSize(ViewSize.MobileL);
const { user } = useAuthContext();
const { completeAction } = useActions();
Expand All @@ -95,6 +115,11 @@ export function ReadingStreakPopup({
staleTime: StaleTime.Default,
});
const [showStreakConfig, toggleShowStreakConfig] = useToggle(false);
const isTimezoneOk = useStreakTimezoneOk();
const { showPrompt } = usePrompt();
const [timezoneMismatchIgnore, setTimezoneMismatchIgnore] =
usePersistentContext(timezoneMismatchIgnoreKey, '');
const { logEvent } = useLogContext();

const streaks = useMemo(() => {
const today = new Date();
Expand Down Expand Up @@ -159,16 +184,89 @@ export function ReadingStreakPopup({
forceLoad={!isTesting}
content={
<div className="flex text-center">
We are showing your reading streak in your selected timezone.
<br />
Click to adjust your timezone if needed or traveling.
{isTimezoneOk ? (
<>
We are showing your reading streak in your selected
timezone.
<br />
Click to adjust your timezone if needed or traveling.
</>
) : (
<>Click for more info</>
)}
</div>
}
>
<div className="m-auto flex justify-center font-normal !text-text-quaternary underline decoration-raw-pepper-10 tablet:m-0 tablet:justify-start">
<Link href="/account/notifications?s=timezone">
{user.timezone || DEFAULT_TIMEZONE}
</Link>
<div className="m-auto flex items-center tablet:m-0">
{!isTimezoneOk && (
<WarningIcon className="text-raw-cheese-40" secondary />
)}
<div className="flex justify-center font-normal !text-text-quaternary underline decoration-raw-pepper-10 tablet:m-0 tablet:justify-start">
<Link
onClick={async (event) => {
const deviceTimezone =
Intl.DateTimeFormat().resolvedOptions().timeZone;
const eventExtra = {
device_timezone: deviceTimezone,
user_timezone: user.timezone,
timezone_ok: isTimezoneOk,
timezone_ignore: timezoneMismatchIgnore,
};

logEvent({
event_name: LogEvent.Click,
target_type: TargetId.StreakTimezoneLabel,
extra: JSON.stringify(eventExtra),
});

if (isTimezoneOk) {
return;
}

event.preventDefault();

const promptResult = await showPrompt({
title: 'Streak timezone mismatch',
description: `We detected your current timezone setting ${getTimezoneOffsetLabel(
user?.timezone,
)} does not match your current device timezone ${getTimezoneOffsetLabel(
deviceTimezone,
)}. You can update your timezone in settings.`,
okButton: {
title: 'Go to settings',
},
cancelButton: {
title: 'Ignore',
},
shouldCloseOnOverlayClick: false,
});

logEvent({
event_name: LogEvent.Click,
target_type: TargetId.StreakTimezoneMismatchPrompt,
extra: JSON.stringify({
...eventExtra,
action: promptResult
? StreakTimezonePromptAction.Settings
: StreakTimezonePromptAction.Ignore,
}),
});

if (!promptResult) {
setTimezoneMismatchIgnore(deviceTimezone);

return;
}

router.push(timezoneSettingsHref);
}}
href={timezoneSettingsHref}
>
{isTimezoneOk
? user.timezone || DEFAULT_TIMEZONE
: 'Timezone mismatch'}
</Link>
</div>
</div>
</SimpleTooltip>
</div>
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/graphql/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export enum ActionType {
DigestConfig = 'digest_config',
StreakMilestone = 'streak_milestone',
FetchedSmartTitle = 'fetched_smart_title',
StreakTimezoneMismatch = 'streak_timezone_mismatch',
}

export interface Action {
Expand Down
73 changes: 73 additions & 0 deletions packages/shared/src/hooks/streaks/useStreakTimezoneOk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { getTimezoneOffset } from 'date-fns-tz';
import { useEffect, useMemo } from 'react';
import { useAuthContext } from '../../contexts/AuthContext';
import { DEFAULT_TIMEZONE } from '../../lib/timezones';
import usePersistentContext from '../usePersistentContext';
import { useActions } from '../useActions';
import { ActionType } from '../../graphql/actions';
import { useLogContext } from '../../contexts/LogContext';
import { LogEvent } from '../../lib/log';

export const timezoneMismatchIgnoreKey = 'timezoneMismatchIgnore';

export const useStreakTimezoneOk = (): boolean => {
const { user, isLoggedIn } = useAuthContext();
const { checkHasCompleted, isActionsFetched, completeAction } = useActions();
const { logEvent } = useLogContext();

const [ignoredTimezone] = usePersistentContext(timezoneMismatchIgnoreKey, '');
const deviceTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;

const isTimezoneOk = useMemo(() => {
if (ignoredTimezone === deviceTimezone) {
return true;
}

if (!isLoggedIn) {
return true;
}

return (
getTimezoneOffset(user?.timezone || DEFAULT_TIMEZONE) ===
getTimezoneOffset(Intl.DateTimeFormat().resolvedOptions().timeZone)
);
}, [deviceTimezone, ignoredTimezone, isLoggedIn, user?.timezone]);

// once off check to see how many users with timezone mismatches we have in the wild
useEffect(() => {
Comment on lines +36 to +37
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can also log this every time but for now it felt it was too much because for users that had the wrong timezone it would log every time they open the app

if (isTimezoneOk) {
return;
}

if (!isActionsFetched) {
return;
}

if (checkHasCompleted(ActionType.StreakTimezoneMismatch)) {
return;
}

logEvent({
event_name: LogEvent.StreakTimezoneMismatch,
extra: JSON.stringify({
device_timezone: deviceTimezone,
user_timezone: user?.timezone,
timezone_ok: isTimezoneOk,
timezone_ignore: ignoredTimezone,
}),
});

completeAction(ActionType.StreakTimezoneMismatch);
}, [
isTimezoneOk,
ignoredTimezone,
isActionsFetched,
checkHasCompleted,
completeAction,
logEvent,
deviceTimezone,
user?.timezone,
]);

return isTimezoneOk;
};
1 change: 1 addition & 0 deletions packages/shared/src/hooks/usePrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type PromptOptions = {
description?: string;
buttons?: string;
};
shouldCloseOnOverlayClick?: boolean;
};

type Prompt = {
Expand Down
Loading
Loading