diff --git a/package-lock.json b/package-lock.json index 13175a2..d4afa26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@radix-ui/react-slot": "^1.1.0", + "date-fns": "^3.6.0", "framer-motion": "^10.16.16", "reablocks": "^8.4.1", "react-markdown": "^9.0.1", @@ -62,7 +63,6 @@ "vite-plugin-checker": "^0.6.4", "vite-plugin-css-injected-by-js": "^3.5.0", "vite-plugin-dts": "^3.7.3", - "vite-plugin-static-copy": "^1.0.4", "vite-plugin-svgr": "^4.2.0", "vite-tsconfig-paths": "^4.3.2", "vitest": "^1.4.0" @@ -17050,24 +17050,6 @@ } } }, - "node_modules/vite-plugin-static-copy": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-1.0.6.tgz", - "integrity": "sha512-3uSvsMwDVFZRitqoWHj0t4137Kz7UynnJeq1EZlRW7e25h2068fyIZX4ORCCOAkfp1FklGxJNVJBkBOD+PZIew==", - "dev": true, - "dependencies": { - "chokidar": "^3.5.3", - "fast-glob": "^3.2.11", - "fs-extra": "^11.1.0", - "picocolors": "^1.0.0" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0" - } - }, "node_modules/vite-plugin-svgr": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-4.2.0.tgz", diff --git a/package.json b/package.json index a433b28..27f31d9 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "typings": "dist/index.d.ts", "dependencies": { "@radix-ui/react-slot": "^1.1.0", + "date-fns": "^3.6.0", "framer-motion": "^10.16.16", "reablocks": "^8.4.1", "react-markdown": "^9.0.1", @@ -87,7 +88,6 @@ "vite-plugin-checker": "^0.6.4", "vite-plugin-css-injected-by-js": "^3.5.0", "vite-plugin-dts": "^3.7.3", - "vite-plugin-static-copy": "^1.0.4", "vite-plugin-svgr": "^4.2.0", "vite-tsconfig-paths": "^4.3.2", "vitest": "^1.4.0" diff --git a/src/SessionListItem.tsx b/src/SessionListItem.tsx index 0f35902..d885471 100644 --- a/src/SessionListItem.tsx +++ b/src/SessionListItem.tsx @@ -45,6 +45,7 @@ export const SessionListItem: FC = ({ deleteIcon = }) => ( = ({ const contextValue = useMemo(() => ({ sessions, activeSession, + theme, activeSessionId: internalActiveSessionID, selectSession: handleSelectSession, deleteSession: handleDeleteSession, createSession: handleCreateNewSession }), [ + theme, sessions, activeSession, internalActiveSessionID, @@ -176,10 +178,7 @@ export const Sessions: FC = ({ })}> <> void; deleteSession?: (sessionId: string) => void; diff --git a/src/SessionsList.tsx b/src/SessionsList.tsx index 9ab9461..b6090a1 100644 --- a/src/SessionsList.tsx +++ b/src/SessionsList.tsx @@ -1,25 +1,12 @@ -import { FC } from 'react'; +import { FC, Fragment, useContext, useMemo } from 'react'; import { SessionListItem } from './SessionListItem'; import { Session } from './types'; import { List, ListItem, Button, cn } from 'reablocks'; import { ChatTheme } from './theme'; +import { groupSessionsByDate } from './utils'; +import { SessionsContext } from './SessionsContext'; interface SessionsListProps { - /** - * Sessions to display. - */ - sessions: Session[]; - - /** - * ID of the currently active session. - */ - activeSessionId?: string; - - /** - * Theme to use for the sessions list. - */ - theme?: ChatTheme; - /** * Text to show for the new session button. */ @@ -42,14 +29,14 @@ interface SessionsListProps { } export const SessionsList: FC = ({ - sessions, - theme, newSessionText = 'New Session', - activeSessionId, onSelectSession, onDeleteSession, onCreateNewSession }) => { + const { theme, activeSessionId, sessions } = useContext(SessionsContext); + const groups = useMemo(() => groupSessionsByDate(sessions), [sessions]); + return ( @@ -62,15 +49,22 @@ export const SessionsList: FC = ({ {newSessionText} - {sessions?.map((session) => ( - + {Object.keys(groups).map(k => ( + + + {k} + + {groups[k].map(s => ( + + ))} + ))} ); diff --git a/src/theme.ts b/src/theme.ts index 61f1f67..07a2e4b 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -4,6 +4,7 @@ export interface ChatTheme { sessions: { base: string; create: string; + group: string; session: { base: string; delete: string; @@ -30,10 +31,11 @@ export const chatTheme: ChatTheme = { base: 'text-white', empty: 'text-center flex-1', sessions: { - base: '', + base: 'overflow-auto', + group: 'text-xs text-gray-400 mt-4', create: 'mb-4', session: { - base: 'mb-4', + base: '', delete: 'w-4 h-4' } }, diff --git a/src/types.ts b/src/types.ts index b3f5e92..f93e2e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,15 +17,15 @@ export interface Conversation { question: string; response?: string; sources?: ConversationSource[]; - files: string[]; // TODO + files?: string[]; // TODO user?: User; // TODO } export interface Session { id: string; - title: string; - createdAt: Date; - updatedAt: Date; + title?: string; + createdAt?: Date; + updatedAt?: Date; conversations: Conversation[]; } diff --git a/src/utils.spec.ts b/src/utils.spec.ts new file mode 100644 index 0000000..a109a5f --- /dev/null +++ b/src/utils.spec.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest'; +import { groupSessionsByDate } from './utils'; +import { Session } from './types'; +import { subDays, subWeeks, subMonths, subYears } from 'date-fns'; + +describe('groupSessionsByDate', () => { + const createSession = (daysAgo: number): Session => ({ + id: `session-${daysAgo}`, + createdAt: subDays(new Date(), daysAgo), + updatedAt: subDays(new Date(), daysAgo), + name: `Test Session ${daysAgo} days ago`, + conversations: [], + }); + + it('groups sessions correctly', () => { + const sessions: Session[] = [ + createSession(0), // Today + createSession(1), // Yesterday + createSession(3), // Last Week + createSession(20), // Last Month + createSession(60), // A few months ago + createSession(400) // Last Year + ]; + + const grouped = groupSessionsByDate(sessions); + + expect(Object.keys(grouped)).toEqual(['Today', 'Yesterday', 'Last Week', 'Last Month', expect.stringMatching(/^[A-Z][a-z]+$/), 'Last Year']); + expect(grouped['Today'].length).toBe(1); + expect(grouped['Yesterday'].length).toBe(1); + expect(grouped['Last Week'].length).toBe(1); + expect(grouped['Last Month'].length).toBe(1); + expect(Object.values(grouped).flat().length).toBe(sessions.length); + }); + + it('handles empty input', () => { + const grouped = groupSessionsByDate([]); + expect(Object.keys(grouped)).toHaveLength(0); + }); + + it('groups multiple sessions in the same category', () => { + const sessions: Session[] = [ + createSession(0), + createSession(0), + createSession(1), + createSession(1), + ]; + + const grouped = groupSessionsByDate(sessions); + + expect(grouped['Today'].length).toBe(2); + expect(grouped['Yesterday'].length).toBe(2); + }); + + it('sorts groups in the correct order', () => { + const sessions: Session[] = [ + createSession(400), // Last Year + createSession(0), // Today + createSession(60), // A few months ago + createSession(1), // Yesterday + ]; + + const grouped = groupSessionsByDate(sessions); + const groupOrder = Object.keys(grouped); + + expect(groupOrder[0]).toBe('Today'); + expect(groupOrder[1]).toBe('Yesterday'); + expect(groupOrder[groupOrder.length - 1]).toBe('Last Year'); + }); +}); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..61b44fd --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,77 @@ +import { format, isToday, isYesterday, isThisWeek, isThisMonth, isThisYear, parseISO } from 'date-fns'; +import { Session } from './types'; + +interface GroupedSessions { + [key: string]: Session[]; +} + +const sortOrder = [ + 'Today', + 'Yesterday', + 'Last Week', + 'Last Month', + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + 'Last Year' +]; + +export function groupSessionsByDate(sessions: Session[]): GroupedSessions { + const grouped: GroupedSessions = {}; + + sessions.forEach(session => { + const createdAt = new Date(session.createdAt); + + if (isToday(createdAt)) { + if (!grouped['Today']) grouped['Today'] = []; + grouped['Today'].push(session); + } else if (isYesterday(createdAt)) { + if (!grouped['Yesterday']) grouped['Yesterday'] = []; + grouped['Yesterday'].push(session); + } else if (isThisWeek(createdAt)) { + if (!grouped['Last Week']) grouped['Last Week'] = []; + grouped['Last Week'].push(session); + } else if (isThisMonth(createdAt)) { + if (!grouped['Last Month']) grouped['Last Month'] = []; + grouped['Last Month'].push(session); + } else if (isThisYear(createdAt)) { + const monthName = format(createdAt, 'MMMM'); + if (!grouped[monthName]) grouped[monthName] = []; + grouped[monthName].push(session); + } else { + if (!grouped['Last Year']) grouped['Last Year'] = []; + grouped['Last Year'].push(session); + } + }); + + // Remove empty groups + Object.keys(grouped).forEach(key => { + if (grouped[key].length === 0) { + delete grouped[key]; + } + }); + + // Sort groups + const sortedGroups = Object.keys(grouped).sort((a, b) => + sortOrder.indexOf(a) - sortOrder.indexOf(b) + ); + + const sortedGrouped: GroupedSessions = {}; + sortedGroups.forEach(key => { + // Sort sessions within each group by createdAt date (most recent first) + sortedGrouped[key] = grouped[key].sort((a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + }); + + return sortedGrouped; +} diff --git a/stories/Basic.stories.tsx b/stories/Basic.stories.tsx index fae2f4d..aa380f5 100644 --- a/stories/Basic.stories.tsx +++ b/stories/Basic.stories.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Meta } from '@storybook/react'; import { Sessions, Session } from '../src'; import { Card } from 'reablocks'; +import { subDays, subWeeks, subMonths, subYears } from 'date-fns'; export default { title: 'Examples', @@ -164,3 +165,74 @@ export const UndeleteableSessions = () => { ); }; + +export const SessionGrouping = () => { + const createSessionWithDate = (id: string, title: string, daysAgo: number): Session => ({ + id, + title, + createdAt: subDays(new Date(), daysAgo), + updatedAt: subDays(new Date(), daysAgo), + conversations: [ + { id: `${id}-1`, question: 'Sample question', response: 'Sample response', createdAt: subDays(new Date(), daysAgo), updatedAt: subDays(new Date(), daysAgo) }, + ], + }); + + const sessionsWithVariousDates: Session[] = [ + createSessionWithDate('1', 'Today Session', 0), + createSessionWithDate('2', 'Yesterday Session', 1), + createSessionWithDate('2', 'Yesterday Session 2', 1), + createSessionWithDate('3', 'Last Week Session', 6), + createSessionWithDate('4', 'Two Weeks Ago Session', 14), + createSessionWithDate('5', 'Last Month Session', 32), + createSessionWithDate('6', 'Two Months Ago Session', 65), + createSessionWithDate('7', 'Six Months Ago Session', 180), + createSessionWithDate('8', 'Last Year Session', 370), + createSessionWithDate('9', 'Two Years Ago Session', 740), + ]; + + return ( +
+ {}} + /> +
+ ); +}; + +export const HundredSessions = () => { + const generateFakeSessions = (count: number): Session[] => { + return Array.from({ length: count }, (_, index) => ({ + id: `session-${index + 1}`, + title: `Session ${index + 1}`, + createdAt: subDays(new Date(), index), + updatedAt: subDays(new Date(), index), + conversations: [ + { + id: `conv-${index}-1`, + question: `Question for session ${index + 1}`, + response: `Response for session ${index + 1}`, + createdAt: subDays(new Date(), index), + updatedAt: subDays(new Date(), index) + } + ] + })); + }; + + const hundredSessions = generateFakeSessions(100); + + return ( +
+ {}} + /> +
+ ); +}; diff --git a/tsconfig.json b/tsconfig.json index 3ee3e11..7fb99f3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,8 @@ "module": "ESNext", "types": [ "vite/client", - "vitest/globals" + "vitest/globals", + "vite-plugin-svgr/client" ], "lib": ["dom", "dom.iterable", "esnext"], "jsx": "react-jsx", @@ -38,4 +39,4 @@ "types": ["node"], "include": ["src/**/*", "stories/**/*"], "exclude": ["node_modules", "dist"] -} \ No newline at end of file +} diff --git a/vite.config.ts b/vite.config.ts index bfe506e..aeee388 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,6 @@ import { resolve } from 'path'; import external from 'rollup-plugin-peer-deps-external'; import dts from 'vite-plugin-dts'; import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'; -import { viteStaticCopy } from 'vite-plugin-static-copy'; import path from 'path'; export default defineConfig(({ mode }) => @@ -26,18 +25,6 @@ export default defineConfig(({ mode }) => }), checker({ typescript: true - }), - viteStaticCopy({ - targets: [ - { - src: 'src/**/*.story.tsx', - dest: 'stories/' - }, - { - src: 'docs/blocks/**/*.story.tsx', - dest: 'blocks/' - } - ] }) ], test: { @@ -81,4 +68,4 @@ export default defineConfig(({ mode }) => environment: 'jsdom' } } -); \ No newline at end of file +);