Kai charting (#3420)

* ui: chart btn

* ui: chats list modal fixes, signal editing visually, map charting response

* ui: support readable errors for kai

* ui: add support for response limiting
This commit is contained in:
Delirium 2025-05-21 16:19:39 +02:00 committed by GitHub
parent 24a220bc51
commit f3f7992c0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 555 additions and 160 deletions

View file

@ -1,16 +1,15 @@
import React from 'react';
import { useModal } from 'App/components/Modal';
import { MessagesSquare, Trash } from 'lucide-react';
import ChatHeader from './components/ChatHeader';
import { PANEL_SIZES } from 'App/constants/panelSizes';
import ChatLog from './components/ChatLog';
import IntroSection from './components/IntroSection';
import { useQuery } from '@tanstack/react-query';
import { kaiService } from 'App/services';
import { toast } from 'react-toastify';
import { useStore } from 'App/mstore';
import { observer } from 'mobx-react-lite';
import { useHistory, useLocation } from 'react-router-dom';
import ChatsModal from './components/ChatsModal';
function KaiChat() {
const { userStore, projectsStore } = useStore();
@ -99,7 +98,9 @@ function KaiChat() {
};
return (
<div className="w-full mx-auto" style={{ maxWidth: PANEL_SIZES.maxWidth }}>
<div className={'w-full rounded-lg overflow-hidden border shadow'}>
<div
className={'w-full rounded-lg overflow-hidden border shadow relative'}
>
<ChatHeader
chatTitle={chatTitle}
openChats={openChats}
@ -133,69 +134,4 @@ function KaiChat() {
);
}
function ChatsModal({
onSelect,
projectId,
}: {
onSelect: (threadId: string, title: string) => void;
projectId: string;
}) {
const {
data = [],
isPending,
refetch,
} = useQuery({
queryKey: ['kai', 'chats', projectId],
queryFn: () => kaiService.getKaiChats(projectId),
staleTime: 1000 * 60,
});
const onDelete = async (id: string) => {
try {
await kaiService.deleteKaiChat(projectId, id);
} catch (e) {
toast.error("Something wen't wrong. Please try again later.");
}
refetch();
};
return (
<div className={'h-screen w-full flex flex-col gap-2 p-4'}>
<div className={'flex items-center font-semibold text-lg gap-2'}>
<MessagesSquare size={16} />
<span>Chats</span>
</div>
{isPending ? (
<div className="animate-pulse text-disabled-text">Loading chats...</div>
) : (
<div className="flex flex-col overflow-y-auto -mx-4 px-4">
{data.map((chat) => (
<div
key={chat.thread_id}
className="flex items-center relative group min-h-8"
>
<div
style={{ width: 270 - 28 - 4 }}
className="rounded-l pl-2 h-full w-full hover:bg-active-blue flex items-center"
>
<div
onClick={() => onSelect(chat.thread_id, chat.title)}
className="cursor-pointer hover:underline truncate"
>
{chat.title}
</div>
</div>
<div
onClick={() => onDelete(chat.thread_id)}
className="cursor-pointer opacity-0 group-hover:opacity-100 rounded-r h-full px-2 flex items-center group-hover:bg-active-blue"
>
<Trash size={14} className="text-disabled-text" />
</div>
</div>
))}
</div>
)}
</div>
);
}
export default observer(KaiChat);

View file

@ -3,7 +3,7 @@ import AiService from '@/services/AiService';
export default class KaiService extends AiService {
getKaiChats = async (
projectId: string,
): Promise<{ title: string; thread_id: string }[]> => {
): Promise<{ title: string; thread_id: string; datetime: string }[]> => {
const r = await this.client.get(`/kai/${projectId}/chats`);
if (!r.ok) {
throw new Error('Failed to fetch chats');
@ -35,6 +35,7 @@ export default class KaiService extends AiService {
feedback: boolean | null;
supports_visualization: boolean;
chart: string;
chart_data: string;
}[]
> => {
const r = await this.client.get(`/kai/${projectId}/chats/${threadId}`);
@ -79,4 +80,46 @@ export default class KaiService extends AiService {
const data = await r.json();
return data;
};
getMsgChart = async (
messageId: string,
projectId: string,
): Promise<{ filters: any[]; chart: string; eventsOrder: string }> => {
const r = await this.client.get(
`/kai/${projectId}/chats/data/${messageId}`,
);
if (!r.ok) {
throw new Error('Failed to fetch chart data');
}
const data = await r.json();
return data;
};
saveChartData = async (
messageId: string,
projectId: string,
chartData: any,
) => {
const r = await this.client.post(
`/kai/${projectId}/chats/data/${messageId}`,
{
chart_data: JSON.stringify(chartData),
},
);
if (!r.ok) {
throw new Error('Failed to save chart data');
}
const data = await r.json();
return data;
};
checkUsage = async (): Promise<{ total: number; used: number }> => {
const r = await this.client.get(`/kai/usage`);
if (!r.ok) {
throw new Error('Failed to fetch usage');
}
const data = await r.json();
return data;
};
}

View file

@ -2,17 +2,25 @@ import { makeAutoObservable, runInAction } from 'mobx';
import { BotChunk, ChatManager } from './SocketManager';
import { kaiService as aiService, kaiService } from 'App/services';
import { toast } from 'react-toastify';
import Widget from 'App/mstore/types/widget';
export interface Message {
text: string;
isUser: boolean;
messageId: string;
/** filters to get chart */
chart: string;
/** chart data */
chart_data: string;
supports_visualization: boolean;
feedback: boolean | null;
duration: number;
}
export interface SentMessage extends Omit<Message, 'duration' | 'feedback' | 'chart' | 'supports_visualization'> {
export interface SentMessage
extends Omit<
Message,
'duration' | 'feedback' | 'chart' | 'supports_visualization'
> {
replace: boolean;
}
@ -22,10 +30,16 @@ class KaiStore {
messages: Array<Message> = [];
queryText = '';
loadingChat = false;
replacing = false;
replacing: string | null = null;
usage = {
total: 0,
used: 0,
percent: 0,
};
constructor() {
makeAutoObservable(this);
this.checkUsage();
}
get lastHumanMessage() {
@ -80,9 +94,9 @@ class KaiStore {
this.messages.push(message);
};
editMessage = (text: string) => {
editMessage = (text: string, messageId: string) => {
this.setQueryText(text);
this.setReplacing(true);
this.setReplacing(messageId);
};
replaceAtIndex = (message: Message, index: number) => {
@ -115,6 +129,7 @@ class KaiStore {
feedback: m.feedback,
chart: m.chart,
supports_visualization: m.supports_visualization,
chart_data: m.chart_data,
};
}),
);
@ -137,6 +152,7 @@ class KaiStore {
console.error('No token found');
return;
}
this.checkUsage();
this.chatManager = new ChatManager({ ...settings, token });
this.chatManager.setOnMsgHook({
msgCallback: (msg) => {
@ -173,7 +189,9 @@ class KaiStore {
feedback: null,
chart: '',
supports_visualization: msg.supports_visualization,
chart_data: '',
};
this.bumpUsage();
this.addMessage(msgObj);
this.setProcessingStage(null);
}
@ -187,13 +205,18 @@ class KaiStore {
}
};
setReplacing = (replacing: boolean) => {
setReplacing = (replacing: string | null) => {
this.replacing = replacing;
};
bumpUsage = () => {
this.usage.used += 1;
this.usage.percent = (this.usage.used / this.usage.total) * 100;
};
sendMessage = (message: string) => {
if (this.chatManager) {
this.chatManager.sendMessage(message, this.replacing);
this.chatManager.sendMessage(message, !!this.replacing);
}
if (this.replacing) {
console.log(
@ -219,6 +242,7 @@ class KaiStore {
duration: 0,
supports_visualization: false,
chart: '',
chart_data: '',
});
};
@ -273,6 +297,64 @@ class KaiStore {
this.chatManager = null;
}
};
getMessageChart = async (msgId: string, projectId: string) => {
this.setProcessingStage({
content: 'Generating visualization...',
stage: 'chart',
messageId: msgId,
duration: 0,
type: 'chunk',
supports_visualization: false,
});
try {
const filters = await kaiService.getMsgChart(msgId, projectId);
const data = {
metricId: undefined,
dashboardId: undefined,
widgetId: undefined,
metricOf: undefined,
metricType: undefined,
metricFormat: undefined,
viewType: undefined,
name: 'Kai Visualization',
series: [
{
name: 'Kai Visualization',
filter: {
eventsOrder: filters.eventsOrder,
filters: filters.filters,
},
},
],
};
const metric = new Widget().fromJson(data);
return metric;
} catch (e) {
console.error(e);
throw new Error('Failed to generate visualization');
} finally {
this.setProcessingStage(null);
}
};
getParsedChart = (data: string) => {
const parsedData = JSON.parse(data);
return new Widget().fromJson(parsedData);
};
checkUsage = async () => {
try {
const { total, used } = await kaiService.checkUsage();
this.usage = {
total,
used,
percent: (used / total) * 100,
};
} catch (e) {
console.error(e);
}
};
}
export const kaiStore = new KaiStore();

View file

@ -1,4 +1,5 @@
import io from 'socket.io-client';
import { toast } from 'react-toastify';
export class ChatManager {
socket: ReturnType<typeof io>;
@ -41,6 +42,9 @@ export class ChatManager {
console.log('Disconnected from server');
});
socket.on('error', (err) => {
if (err.message) {
toast.error(err.message);
}
console.error('Socket error:', err);
});

View file

@ -1,6 +1,7 @@
import React from 'react';
import { Icon } from 'UI';
import { MessagesSquare, ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
function ChatHeader({
openChats = () => {},
@ -11,6 +12,7 @@ function ChatHeader({
openChats?: () => void;
chatTitle: string | null;
}) {
const { t } = useTranslation();
return (
<div
className={
@ -20,17 +22,21 @@ function ChatHeader({
<div className={'flex-1'}>
{goBack ? (
<div
className={'flex items-center gap-2 font-semibold cursor-pointer'}
className={
'w-fit flex items-center gap-2 font-semibold cursor-pointer'
}
onClick={goBack}
>
<ArrowLeft size={14} />
<div>Back</div>
<div>{t('Back')}</div>
</div>
) : null}
</div>
<div className={'flex items-center gap-2 mx-auto max-w-[80%]'}>
{chatTitle ? (
<div className="font-semibold text-xl whitespace-nowrap truncate">{chatTitle}</div>
<div className="font-semibold text-xl whitespace-nowrap truncate">
{chatTitle}
</div>
) : (
<>
<Icon name={'kai_colored'} size={18} />
@ -38,14 +44,14 @@ function ChatHeader({
</>
)}
</div>
<div className={'flex-1 justify-end flex items-center gap-2'}>
<div
className={
'font-semibold cursor-pointer flex items-center gap-2 flex-1 justify-end'
}
className="font-semibold w-fit cursor-pointer flex items-center gap-2"
onClick={openChats}
>
<MessagesSquare size={14} />
<div>Chats</div>
<div>{t('Chats')}</div>
</div>
</div>
</div>
);

View file

@ -1,55 +1,109 @@
import React from 'react'
import { Button, Input } from "antd";
import { SendHorizonal, OctagonX } from "lucide-react";
import { kaiStore } from "../KaiStore";
import { observer } from "mobx-react-lite";
import React from 'react';
import { Button, Input, Tooltip } from 'antd';
import { SendHorizonal, OctagonX } from 'lucide-react';
import { kaiStore } from '../KaiStore';
import { observer } from 'mobx-react-lite';
import Usage from './Usage';
function ChatInput({ isLoading, onSubmit, threadId }: { isLoading?: boolean, onSubmit: (str: string) => void, threadId: string }) {
const inputRef = React.useRef<Input>(null);
function ChatInput({
isLoading,
onSubmit,
threadId,
}: {
isLoading?: boolean;
onSubmit: (str: string) => void;
threadId: string;
}) {
const inputRef = React.useRef<typeof Input>(null);
const usage = kaiStore.usage;
const limited = usage.percent >= 100;
const inputValue = kaiStore.queryText;
const isProcessing = kaiStore.processingStage !== null
const isProcessing = kaiStore.processingStage !== null;
const setInputValue = (text: string) => {
kaiStore.setQueryText(text)
}
kaiStore.setQueryText(text);
};
const submit = () => {
if (limited) {
return;
}
if (isProcessing) {
const settings = { projectId: '2325', userId: '0', threadId, };
void kaiStore.cancelGeneration(settings)
const settings = { projectId: '2325', userId: '0', threadId };
void kaiStore.cancelGeneration(settings);
} else {
if (inputValue.length > 0) {
onSubmit(inputValue)
setInputValue('')
}
onSubmit(inputValue);
setInputValue('');
}
}
};
const cancelReplace = () => {
setInputValue('');
kaiStore.setReplacing(null);
};
React.useEffect(() => {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.focus();
}
}, [inputValue])
}, [inputValue]);
const isReplacing = kaiStore.replacing !== null;
return (
<div className="relative">
<Input
onPressEnter={submit}
onKeyDown={(e) => {
if (e.key === 'Escape') {
cancelReplace();
}
}}
ref={inputRef}
placeholder={'Ask anything about your product and users...'}
size={'large'}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
suffix={
<>
{isReplacing ? (
<Tooltip title={'Cancel Editing'}>
<Button
onClick={cancelReplace}
icon={<OctagonX size={16} />}
type={'text'}
size={'small'}
shape={'circle'}
disabled={limited}
/>
</Tooltip>
) : null}
<Tooltip title={'Send message'}>
<Button
loading={isLoading}
onClick={submit}
icon={isProcessing ? <OctagonX size={16} /> : <SendHorizonal size={16} />}
disabled={limited}
icon={
isProcessing ? (
<OctagonX size={16} />
) : (
<SendHorizonal size={16} />
)
}
type={'text'}
size={'small'}
shape={'circle'}
/>
</Tooltip>
</>
}
/>
)
<div className="absolute ml-1 top-2 -right-11">
<Usage />
</div>
</div>
);
}
export default observer(ChatInput)
export default observer(ChatInput);

View file

@ -1,6 +1,6 @@
import React from 'react';
import ChatInput from './ChatInput';
import { ChatMsg, ChatNotice } from './ChatMsg';
import ChatMsg, { ChatNotice } from './ChatMsg';
import { Loader } from 'UI';
import { kaiStore } from '../KaiStore';
import { observer } from 'mobx-react-lite';
@ -61,17 +61,14 @@ function ChatLog({
>
<div className={'flex flex-col gap-4 w-2/3 min-h-max'}>
{messages.map((msg, index) => (
<React.Fragment key={msg.messageId ?? index}>
<ChatMsg
key={index}
text={msg.text}
isUser={msg.isUser}
userName={userLetter}
messageId={msg.messageId}
duration={msg.duration}
feedback={msg.feedback}
siteId={projectId}
message={msg}
canEdit={processingStage === null && msg.isUser && index === lastHumanMsgInd}
/>
</React.Fragment>
))}
{processingStage ? (
<ChatNotice

View file

@ -1,5 +1,6 @@
import React from 'react';
import { Icon, CopyButton } from 'UI';
import { observer } from 'mobx-react-lite';
import cn from 'classnames';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@ -10,36 +11,49 @@ import {
ListRestart,
FileDown,
Clock,
ChartLine,
} from 'lucide-react';
import { Button, Tooltip } from 'antd';
import { kaiStore } from '../KaiStore';
import { kaiStore, Message } from '../KaiStore';
import { toast } from 'react-toastify';
import { durationFormatted } from 'App/date';
import WidgetChart from '@/components/Dashboard/components/WidgetChart';
import Widget from 'App/mstore/types/widget';
import { useTranslation } from 'react-i18next';
export function ChatMsg({
function ChatMsg({
userName,
siteId,
canEdit,
message,
}: {
message: Message;
userName?: string;
canEdit?: boolean;
siteId: string;
}) {
const { t } = useTranslation();
const [metric, setMetric] = React.useState<Widget | null>(null);
const [loadingChart, setLoadingChart] = React.useState(false);
const {
text,
isUser,
userName,
messageId,
duration,
feedback,
siteId,
canEdit,
}: {
text: string;
isUser: boolean;
messageId: string;
userName?: string;
duration?: number;
feedback: boolean | null;
siteId: string;
canEdit?: boolean;
}) {
supports_visualization,
chart_data,
} = message;
const isEditing = kaiStore.replacing && messageId === kaiStore.replacing;
const [isProcessing, setIsProcessing] = React.useState(false);
const bodyRef = React.useRef<HTMLDivElement>(null);
const onRetry = () => {
kaiStore.editMessage(text);
const onEdit = () => {
kaiStore.editMessage(text, messageId);
};
const onCancelEdit = () => {
kaiStore.setQueryText('');
kaiStore.setReplacing(null);
}
const onFeedback = (feedback: 'like' | 'dislike', messageId: string) => {
kaiStore.sendMsgFeedback(feedback, messageId, siteId);
};
@ -74,6 +88,25 @@ export function ChatMsg({
setIsProcessing(false);
});
};
React.useEffect(() => {
if (chart_data) {
const metric = kaiStore.getParsedChart(chart_data);
setMetric(metric);
}
}, [chart_data]);
const getChart = async () => {
try {
setLoadingChart(true);
const metric = await kaiStore.getMessageChart(messageId, siteId);
setMetric(metric);
} catch (e) {
toast.error(e.message);
} finally {
setLoadingChart(false);
}
};
return (
<div
className={cn(
@ -84,7 +117,7 @@ export function ChatMsg({
{isUser ? (
<div
className={
'rounded-full bg-main text-white min-w-8 min-h-8 flex items-center justify-center sticky top-0'
'rounded-full bg-main text-white min-w-8 min-h-8 flex items-center justify-center sticky top-0 shadow'
}
>
<span className={'font-semibold'}>{userName}</span>
@ -92,28 +125,54 @@ export function ChatMsg({
) : (
<div
className={
'rounded-full bg-white shadow min-w-8 min-h-8 flex items-center justify-center sticky top-0'
'rounded-full bg-gray-lightest shadow min-w-8 min-h-8 flex items-center justify-center sticky top-0'
}
>
<Icon name={'kai_colored'} size={18} />
</div>
)}
<div className={'mt-1 flex flex-col'}>
<div className="markdown-body" data-openreplay-obscured ref={bodyRef}>
<div
className={cn(
'markdown-body',
isEditing ? 'border-l border-l-main pl-2' : '',
)}
data-openreplay-obscured
ref={bodyRef}
>
<Markdown remarkPlugins={[remarkGfm]}>{text}</Markdown>
</div>
{metric ? (
<div className="p-2 border-gray-light rounded-lg shadow">
<WidgetChart metric={metric} isPreview />
</div>
) : null}
{isUser ? (
canEdit ? (
<>
<div
onClick={onRetry}
className={
'ml-auto flex items-center gap-2 px-2 rounded-lg border border-gray-medium text-sm cursor-pointer hover:border-main hover:text-main w-fit'
}
onClick={onEdit}
className={cn(
'ml-auto flex items-center gap-2 px-2',
'rounded-lg border border-gray-medium text-sm cursor-pointer',
'hover:border-main hover:text-main w-fit',
canEdit && !isEditing ? '' : 'hidden',
)}
>
<ListRestart size={16} />
<div>Edit</div>
<div>{t('Edit')}</div>
</div>
) : null
<div
onClick={onCancelEdit}
className={cn(
'ml-auto flex items-center gap-2 px-2',
'rounded-lg border border-gray-medium text-sm cursor-pointer',
'hover:border-main hover:text-main w-fit',
isEditing ? '' : 'hidden',
)}
>
<div>{t('Cancel')}</div>
</div>
</>
) : (
<div className={'flex items-center gap-2'}>
{duration ? <MsgDuration duration={duration} /> : null}
@ -132,6 +191,15 @@ export function ChatMsg({
>
<ThumbsDown size={16} />
</IconButton>
{supports_visualization ? (
<IconButton
tooltip="Visualize this answer"
onClick={getChart}
processing={loadingChart}
>
<ChartLine size={16} />
</IconButton>
) : null}
<CopyButton
getHtml={() => bodyRef.current?.innerHTML}
content={text}
@ -215,3 +283,5 @@ function MsgDuration({ duration }: { duration: number }) {
</div>
);
}
export default observer(ChatMsg);

View file

@ -0,0 +1,136 @@
import React from 'react';
import { splitByDate } from '../utils';
import { useQuery } from '@tanstack/react-query';
import { MessagesSquare, Trash } from 'lucide-react';
import { kaiService } from 'App/services';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { kaiStore } from '../KaiStore';
import { observer } from 'mobx-react-lite';
function ChatsModal({
onSelect,
projectId,
}: {
onSelect: (threadId: string, title: string) => void;
projectId: string;
}) {
const { t } = useTranslation();
const { usage } = kaiStore;
const {
data = [],
isPending,
refetch,
} = useQuery({
queryKey: ['kai', 'chats', projectId],
queryFn: () => kaiService.getKaiChats(projectId),
staleTime: 1000 * 60,
});
React.useEffect(() => {
kaiStore.checkUsage();
}, []);
const datedCollections = React.useMemo(() => {
return data.length ? splitByDate(data) : [];
}, [data.length]);
const onDelete = async (id: string) => {
try {
await kaiService.deleteKaiChat(projectId, id);
} catch (e) {
toast.error("Something wen't wrong. Please try again later.");
}
refetch();
};
return (
<div className={'h-screen w-full flex flex-col gap-2 p-4'}>
<div className={'flex items-center font-semibold text-lg gap-2'}>
<MessagesSquare size={16} />
<span>{t('Chats')}</span>
</div>
{usage.percent > 80 ? (
<div className="text-red text-sm">
{t('You have used {{used}} out of {{total}} daily requests', {
used: usage.used,
total: usage.total,
})}
</div>
) : null}
{isPending ? (
<div className="animate-pulse text-disabled-text">{t('Loading chats')}...</div>
) : (
<div className="overflow-y-auto flex flex-col gap-2">
{datedCollections.map((col) => (
<ChatCollection
data={col.entries}
date={col.date}
onSelect={onSelect}
onDelete={onDelete}
/>
))}
</div>
)}
</div>
);
}
function ChatCollection({
data,
onSelect,
onDelete,
date,
}: {
data: { title: string; thread_id: string }[];
onSelect: (threadId: string, title: string) => void;
onDelete: (threadId: string) => void;
date: string;
}) {
return (
<div>
<div className="text-disabled-text">{date}</div>
<ChatsList data={data} onSelect={onSelect} onDelete={onDelete} />
</div>
);
}
function ChatsList({
data,
onSelect,
onDelete,
}: {
data: { title: string; thread_id: string }[];
onSelect: (threadId: string, title: string) => void;
onDelete: (threadId: string) => void;
}) {
return (
<div className="flex flex-col gap-1 -mx-4 px-4">
{data.map((chat) => (
<div
key={chat.thread_id}
className="flex items-center relative group min-h-7"
>
<div
style={{ width: 270 - 28 - 4 }}
className="rounded-l pl-2 min-h-7 h-full w-full hover:bg-active-blue flex items-center"
>
<div
onClick={() => onSelect(chat.thread_id, chat.title)}
className="cursor-pointer hover:underline truncate"
>
{chat.title}
</div>
</div>
<div
onClick={() => onDelete(chat.thread_id)}
className="cursor-pointer opacity-0 group-hover:opacity-100 rounded-r min-h-7 h-full px-2 flex items-center group-hover:bg-active-blue"
>
<Trash size={14} className="text-disabled-text" />
</div>
</div>
))}
</div>
);
}
export default observer(ChatsModal);

View file

@ -0,0 +1,31 @@
import React from 'react';
import { kaiStore } from '../KaiStore';
import { observer } from 'mobx-react-lite';
import { Progress, Tooltip } from 'antd';
const getUsageColor = (percent: number) => {
return 'disabled-text';
};
function Usage() {
const { usage } = kaiStore;
const color = getUsageColor(usage.percent);
if (usage.total === 0) {
return null;
}
return (
<div>
<Tooltip title={`Daily response limit (${usage.used}/${usage.total})`}>
<Progress
percent={usage.percent}
strokeColor={usage.percent < 99 ? 'var(--color-main)' : 'var(--color-red)'}
showInfo={false}
type="circle"
size={24}
/>
</Tooltip>
</div>
);
}
export default observer(Usage);

View file

@ -0,0 +1,36 @@
import { DateTime } from 'luxon';
type DatedEntry = {
date: string;
entries: { datetime: string }[];
}
export function splitByDate(entries: { datetime: string }[]) {
const today = DateTime.now().startOf('day');
const yesterday = today.minus({ days: 1 });
const result: DatedEntry[] = [
{ date: 'Today', entries: [] },
{ date: 'Yesterday', entries: [] },
];
entries.forEach((ent) => {
const entryDate = DateTime.fromISO(ent.datetime).startOf('day');
if (entryDate.toMillis() === today.toMillis()) {
result[0].entries.push(ent);
} else if (entryDate.toMillis() === yesterday.toMillis()) {
result[1].entries.push(ent);
} else {
const date = entryDate.toFormat('dd LLL, yyyy')
const existingEntry = result.find((r) => r.date === date);
if (existingEntry) {
existingEntry.entries.push(ent);
} else {
result.push({ entries: [ent], date });
}
}
});
return result;
}