Skip to content
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
23 changes: 22 additions & 1 deletion src/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getSlimBiases } from '@uxcore/api/biases';
import { getOurProjects } from '@uxcore/api/our-projects';
import { GlobalContext as UXCoreGlobalContext } from '@uxcore/components/Context/GlobalContext';
import { NewUpdateModalContainer } from '@uxcore/components/NewUpdateModal';
Expand Down Expand Up @@ -42,6 +43,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) {
const [selectedTitle, setSelectedTitle] = useState<string>('');
const [updatedUsername, setUpdatedUsername] = useState<string>('');
const [ourProjectsModalData, setOurProjectsModalData] = useState<any>(null);
const [uxCoreData, setUxCoreData] = useState<any>(null);

const isIndexingOn = process.env.NEXT_PUBLIC_INDEXING === 'on';
const isProduction = process.env.NEXT_PUBLIC_ENV === 'prod';
Expand Down Expand Up @@ -276,6 +278,24 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) {
};
}, [isUxcoreRoute, router.locale]);

const isUxcatRoute = router.pathname.startsWith('/uxcat');

useEffect(() => {
if (!isUxcatRoute || uxCoreData) return;
let cancelled = false;
(async () => {
try {
const data = await getSlimBiases();
if (!cancelled) setUxCoreData(data);
} catch (err) {
console.warn('[uxcat-biases] fetch failed:', err);
}
})();
return () => {
cancelled = true;
};
}, [isUxcatRoute, uxCoreData]);

const uxcoreContextValue = useMemo(
() => ({
accountData,
Expand All @@ -289,7 +309,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) {
setUpdatedUsername,
ourProjectsModalData,
setOurProjectsModalData,
uxCoreData: null,
uxCoreData,
uxcgLocalizedData: null,
uxcgData: null,
}),
Expand All @@ -299,6 +319,7 @@ function AppContent({ Component, pageProps: { session, ...pageProps } }: TApp) {
selectedTitle,
updatedUsername,
ourProjectsModalData,
uxCoreData,
],
);

Expand Down
44 changes: 44 additions & 0 deletions src/uxcore/api/biases.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,52 @@
let cachedBiases: any = null;
let cachedSlimBiases: any = null;
const LOCALES = ['en', 'ru', 'hy'];
const PAGE_SIZE = 100;
const TOTAL_ITEMS_EXPECTED = 105;

const SLIM_FIELDS = [
'number',
'title',
'description',
'slug',
// test-result.tsx JSON.parses this to pick recommended reading — omitting it
// makes that page throw once the bias list is populated.
'mentionedQuestionsIds',
];
const SLIM_LOCALES = ['en', 'ru'];

// UXCAT renders bias titles/descriptions from the shared bias list, but the
// full payload (SEO, OG images, usage sections) is ~30x larger than what those
// screens read. Fetch only the fields UXCAT needs.
export const getSlimBiases = async () => {
if (cachedSlimBiases) return cachedSlimBiases;

const fieldsQuery = SLIM_FIELDS.map(
(field, index) => `fields[${index}]=${field}`,
).join('&');

const allData = { en: [], ru: [], hy: [] };

for (const locale of SLIM_LOCALES) {
let page = 1;
while (true) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor robustness nit: this while (true) only terminates on an empty page or a short page (json.data.length < PAGE_SIZE). getStrapiBiases below guards the same kind of loop with fetched < TOTAL_ITEMS_EXPECTED so a Strapi pagination bug (e.g. always returning exactly 100 items) can't spin forever. Worth adding an equivalent upper bound here too, since this runs client-side on every /uxcat page load.

const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/biases?locale=${locale}&sort=number&pagination[pageSize]=${PAGE_SIZE}&pagination[page]=${page}&${fieldsQuery}`;
const res = await fetch(url);
const json = await res.json();

if (!json.data || json.data.length === 0) break;

allData[locale].push(...json.data);

if (json.data.length < PAGE_SIZE) break;
page++;
}
}

cachedSlimBiases = allData;
return allData;
};

export const getStrapiBiases = async () => {
if (cachedBiases) return cachedBiases;

Expand Down
34 changes: 18 additions & 16 deletions src/uxcore/components/AddToCalendar/AddToCalendar.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import CalendarItems from '@uxcore/components/CalendarItems';
import Modal from '@uxcore/components/Modal';
import calendar from '@uxcore/data/uxcat/calendar';
import { useClickOutside } from '@uxcore/hooks/useClickOutside';
import useMobile from '@uxcore/hooks/useMobile';
import { getEventWindow, toICalUTC } from '@uxcore/lib/ics';
import type { TRouter } from '@uxcore/local-types/global';
import cn from 'classnames';
import { useRouter } from 'next/router';
import { FC, useState } from 'react';

import type { TRouter } from '@uxcore/local-types/global';

import { useClickOutside } from '@uxcore/hooks/useClickOutside';
import useMobile from '@uxcore/hooks/useMobile';

import calendar from '@uxcore/data/uxcat/calendar';

import CalendarItems from '@uxcore/components/CalendarItems';
import Modal from '@uxcore/components/Modal';

import styles from './AddToCalendar.module.scss';

type AddToCalendarProps = {
Expand All @@ -33,18 +30,23 @@ const AddToCalendar: FC<AddToCalendarProps> = ({
const currentLocale = locale === 'ru' ? 'ru' : 'en';

const { addToCalendar, title, description } = calendar[currentLocale];
const calendarDescription = `${description} ${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`;
const testUrl = `${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`;
const calendarDescription = `${description} ${testUrl}`;

const eventWindow = getEventWindow(startTime);
if (!eventWindow) return null;

const event = {
title: title,
startTime: startTime?.toString(),
title,
start: eventWindow.start,
end: eventWindow.end,
description: calendarDescription,
url: `${process.env.NEXT_PUBLIC_DOMAIN}/uxcat/start-test`,
url: testUrl,
};

const googleCalendarUrl = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(event.title)}&dates=${event.startTime}&details=${encodeURIComponent(calendarDescription)}`;
const googleCalendarUrl = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(title)}&dates=${toICalUTC(eventWindow.start)}/${toICalUTC(eventWindow.end)}&details=${encodeURIComponent(calendarDescription)}`;

const outlookCalendarUrl = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent(event.title)}&body=${encodeURIComponent(calendarDescription)}&startdt=${event.startTime}`;
const outlookCalendarUrl = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent(title)}&body=${encodeURIComponent(calendarDescription)}&startdt=${eventWindow.start.toISOString()}&enddt=${eventWindow.end.toISOString()}`;

return (
<>
Expand Down
13 changes: 8 additions & 5 deletions src/uxcore/components/CalendarItems/CalendarItems.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { buildICS, CalendarEvent, downloadICS } from '@uxcore/lib/ics';
import Image from 'next/image';
import { FC } from 'react';
import ICalendarLink from 'react-icalendar-link';

import styles from './CalendarItems.module.scss';

type CalendarItemsProps = {
event: any;
event: CalendarEvent;
googleCalendarUrl: string;
outlookCalendarUrl: string;
};
Expand All @@ -17,8 +17,11 @@ const CalendarItems: FC<CalendarItemsProps> = ({
return (
<div className={styles.body}>
<div className={styles.iconAndLink}>
{/*@ts-ignore*/}
<ICalendarLink event={event} className={styles.apple} isCrappyIE>
<button
type="button"
className={styles.apple}
onClick={() => downloadICS(buildICS(event), 'uxcat-reminder.ics')}
>
<Image
src={'/assets/calendar/apple.png'}
alt={'apple calendar'}
Expand All @@ -27,7 +30,7 @@ const CalendarItems: FC<CalendarItemsProps> = ({
height={24}
/>
Apple
</ICalendarLink>
</button>
</div>
<div className={styles.iconAndLink}>
<a
Expand Down
8 changes: 8 additions & 0 deletions src/uxcore/components/Modal/Modal.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
position: relative;
z-index: 80;
border-radius: 4px;
// Without a ceiling the wrapper grows past the viewport and the centered
// overlay clips it at both ends, putting the header and footer out of reach.
max-height: calc(100vh - 40px);
animation: wrapperIn 0.2s ease-out;

&.wrapperClosing {
Expand All @@ -44,6 +47,7 @@
.header {
width: 100%;
display: flex;
flex-shrink: 0;
justify-content: space-between;
padding-top: 13px;
padding-bottom: 8px;
Expand Down Expand Up @@ -74,6 +78,10 @@

.body {
padding: 16px 28px;
flex: 1 1 auto;
// min-height:0 lets this flex child shrink below its content height —
// otherwise overflow:auto never engages and nothing scrolls.
min-height: 0;
overflow: auto;

// Slim styled scrollbar so the inside of the modal does not look
Expand Down
20 changes: 8 additions & 12 deletions src/uxcore/components/QuestionAnalyse/QuestionAnalyse.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import cn from 'classnames';
import { useRouter } from 'next/router';
import { FC, useContext, useEffect, useState } from 'react';

import { StrapiBiasType } from '@uxcore/local-types/data';
import { TRouter } from '@uxcore/local-types/global';

import { mergeBiasesLocalization } from '@uxcore/lib/helpers';

import questionAnalyseData from '@uxcore/data/uxcat/questionAnalyse';

import ContentParser from '@uxcore/components/ContentParser';
import { GlobalContext } from '@uxcore/components/Context/GlobalContext';
import Modal from '@uxcore/components/Modal';
import questionAnalyseData from '@uxcore/data/uxcat/questionAnalyse';
import { mergeBiasesLocalization } from '@uxcore/lib/helpers';
import { StrapiBiasType } from '@uxcore/local-types/data';
import { TRouter } from '@uxcore/local-types/global';
import cn from 'classnames';
import { useRouter } from 'next/router';
import { FC, useContext, useEffect, useState } from 'react';

import styles from './QuestionAnalyse.module.scss';

Expand Down Expand Up @@ -73,7 +69,7 @@ const QuestionAnalyse: FC<QuestionAnalyseProps> = ({
),
);
}
}, []);
}, [uxCoreData]);

return (
<Modal
Expand Down
133 changes: 133 additions & 0 deletions src/uxcore/lib/ics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
const CRLF = '\r\n';
const PRODID = '-//KeepSimple//UXCAT//EN';
const DEFAULT_DURATION_MS = 30 * 60 * 1000;
const MAX_LINE_OCTETS = 74;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Small mismatch: the comment on foldLine (and the PR description) says folding happens "at 75 octets," but MAX_LINE_OCTETS is 74. Not a bug — it's on the conservative side (first physical line could actually hold 75 per RFC 5545 §3.1, only continuation lines need the -1 for the leading space) — but worth a one-line comment explaining the off-by-one is intentional, or bumping the first line to 75, so a future reader doesn't "fix" it into a real bug.


export type CalendarEvent = {
title: string;
start: Date;
end?: Date;
description?: string;
url?: string;
};

const pad = (value: number) => String(value).padStart(2, '0');

// nextTestTime arrives as an epoch timestamp, and `new Date('1758...')` parses
// as Invalid Date — numeric strings have to be coerced to a number first.
export const parseEventDate = (
value?: string | number | Date | null,
): Date | null => {
if (value === null || value === undefined || value === '') return null;

const raw = value instanceof Date ? value : String(value).trim();
const normalized =
typeof raw === 'string' && /^\d+$/.test(raw) ? Number(raw) : raw;

const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
};

export const getEventWindow = (value?: string | number | Date | null) => {
const start = parseEventDate(value);
if (!start) return null;

return { start, end: new Date(start.getTime() + DEFAULT_DURATION_MS) };
};

// RFC 5545 UTC date-time: YYYYMMDDTHHMMSSZ
export const toICalUTC = (date: Date) =>
[
date.getUTCFullYear(),
pad(date.getUTCMonth() + 1),
pad(date.getUTCDate()),
'T',
pad(date.getUTCHours()),
pad(date.getUTCMinutes()),
pad(date.getUTCSeconds()),
'Z',
].join('');

// RFC 5545 §3.3.11
const escapeText = (value: string) =>
value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r?\n/g, '\\n');

// RFC 5545 §3.1 — content lines are folded at 75 octets, and Cyrillic copy
// blows past that in half the characters, so fold by byte length not length.
const foldLine = (line: string) => {
const encoder = new TextEncoder();
if (encoder.encode(line).length <= MAX_LINE_OCTETS) return line;

const chunks: string[] = [];
let current = '';
let currentOctets = 0;

for (const char of line) {
const octets = encoder.encode(char).length;
if (currentOctets + octets > MAX_LINE_OCTETS) {
chunks.push(current);
current = '';
currentOctets = 0;
}
current += char;
currentOctets += octets;
}
chunks.push(current);

return chunks.join(`${CRLF} `);
};

export const buildICS = ({
title,
start,
end,
description,
url,
}: CalendarEvent) => {
const startUTC = toICalUTC(start);
const endUTC = toICalUTC(
end ?? new Date(start.getTime() + DEFAULT_DURATION_MS),
);

const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
`PRODID:${PRODID}`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
// Deterministic UID so re-downloading updates the event instead of
// stacking duplicates in the user's calendar.
`UID:uxcat-${startUTC}@keepsimple.io`,
`DTSTAMP:${toICalUTC(new Date())}`,
`DTSTART:${startUTC}`,
`DTEND:${endUTC}`,
`SUMMARY:${escapeText(title)}`,
];

if (description) lines.push(`DESCRIPTION:${escapeText(description)}`);
// URL is a URI value, not TEXT — it must not be backslash-escaped.
if (url) lines.push(`URL:${url}`);

lines.push('END:VEVENT', 'END:VCALENDAR');

return `${lines.map(foldLine).join(CRLF)}${CRLF}`;
};

export const downloadICS = (content: string, filename: string) => {
const blob = new Blob([content], { type: 'text/calendar;charset=utf-8' });
const objectUrl = URL.createObjectURL(blob);

const link = document.createElement('a');
link.href = objectUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);

setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
};
Loading
Loading