diff --git a/app/(interview)/interview/[interviewId]/page.tsx b/app/(interview)/interview/[interviewId]/page.tsx index 455a8df7b..e1e03afc6 100644 --- a/app/(interview)/interview/[interviewId]/page.tsx +++ b/app/(interview)/interview/[interviewId]/page.tsx @@ -36,8 +36,8 @@ export default async function Page({ redirect('/interview/finished'); } - // If the interview is finished, redirect to the finish page - if (!session && interview?.finishTime) { + // If the interview is finished and there is no session, redirect to the finish page + if (interview?.finishTime && !session) { redirect('/interview/finished'); } diff --git a/app/dashboard/_components/InterviewsTable/ActionsDropdown.tsx b/app/dashboard/_components/InterviewsTable/ActionsDropdown.tsx index 92330e487..a75d0ed27 100644 --- a/app/dashboard/_components/InterviewsTable/ActionsDropdown.tsx +++ b/app/dashboard/_components/InterviewsTable/ActionsDropdown.tsx @@ -4,7 +4,7 @@ import type { Interview } from '@prisma/client'; import type { Row } from '@tanstack/react-table'; import { MoreHorizontal } from 'lucide-react'; import Link from 'next/link'; -import { objectHash } from 'ohash'; +import { hash as objectHash } from 'ohash'; import { useState } from 'react'; import { DeleteInterviewsDialog } from '~/app/dashboard/interviews/_components/DeleteInterviewsDialog'; import { ExportInterviewsDialog } from '~/app/dashboard/interviews/_components/ExportInterviewsDialog'; diff --git a/app/dashboard/_components/InterviewsTable/Columns.tsx b/app/dashboard/_components/InterviewsTable/Columns.tsx index a15c0e2e9..84f71cbc6 100644 --- a/app/dashboard/_components/InterviewsTable/Columns.tsx +++ b/app/dashboard/_components/InterviewsTable/Columns.tsx @@ -1,14 +1,15 @@ 'use client'; +import type { Codebook, NcNetwork, Stage } from '@codaco/shared-consts'; import { type ColumnDef } from '@tanstack/react-table'; -import { Checkbox } from '~/components/ui/checkbox'; +import Image from 'next/image'; import { DataTableColumnHeader } from '~/components/DataTable/ColumnHeader'; -import { Progress } from '~/components/ui/progress'; -import type { Stage } from '@codaco/shared-consts'; import { Badge } from '~/components/ui/badge'; +import { Checkbox } from '~/components/ui/checkbox'; +import { Progress } from '~/components/ui/progress'; import TimeAgo from '~/components/ui/TimeAgo'; -import Image from 'next/image'; import type { GetInterviewsReturnType } from '~/queries/interviews'; +import NetworkSummary from './NetworkSummary'; export const InterviewColumns = (): ColumnDef< Awaited[0] @@ -32,30 +33,24 @@ export const InterviewColumns = (): ColumnDef< enableSorting: false, enableHiding: false, }, - // { - // accessorKey: 'id', - // header: ({ column }) => { - // return ; - // }, - // }, - // { - // accessorKey: 'finishTime', - // header: 'Finish Time', - // cell: ({ row }) => { - // // finishTime is optional - // if (!row.original.finishTime) { - // return 'Not completed'; - // } - // const date = new Date(row.original.finishTime); - // return date.toLocaleString(); - // }, - // }, { id: 'identifier', accessorKey: 'participant.identifier', header: ({ column }) => { return ( - +
+ Participant icon + +
); }, cell: ({ row }) => { @@ -64,13 +59,6 @@ export const InterviewColumns = (): ColumnDef< className="flex items-center gap-2" title={row.original.participant.identifier} > - Protocol icon {row.original.participant.identifier} @@ -81,47 +69,65 @@ export const InterviewColumns = (): ColumnDef< }, }, { + id: 'protocolName', accessorKey: 'protocol.name', header: ({ column }) => { - return ; + return ( +
+ Protocol icon + +
+ ); }, cell: ({ row }) => { + const protocolFileName = row.original.protocol.name; + const protocolName = protocolFileName.replace(/\.netcanvas$/, ''); return (
- Protocol icon - {row.original.protocol.name} + {protocolName}
); }, }, { + id: 'startTime', accessorKey: 'startTime', - header: 'Started', + header: ({ column }) => { + return ; + }, cell: ({ row }) => { const date = new Date(row.original.startTime); - return ; + return ; }, }, { + id: 'lastUpdated', accessorKey: 'lastUpdated', header: ({ column }) => { return ; }, cell: ({ row }) => { const date = new Date(row.original.lastUpdated); - return ; + return ; }, }, { id: 'progress', + accessorFn: (row) => { + const stages = row.protocol.stages; + return Array.isArray(stages) + ? (row.currentStep / stages.length) * 100 + : 0; + }, header: ({ column }) => { return ; }, @@ -136,6 +142,24 @@ export const InterviewColumns = (): ColumnDef< ); }, }, + { + id: 'network', + accessorFn: (row) => { + const network = row.network as NcNetwork; + const nodeCount = network?.nodes?.length ?? 0; + const edgeCount = network?.edges?.length ?? 0; + return nodeCount + edgeCount; + }, + header: ({ column }) => { + return ; + }, + cell: ({ row }) => { + const network = row.original.network as NcNetwork; + const codebook = row.original.protocol.codebook as Codebook; + + return ; + }, + }, { accessorKey: 'exportTime', header: ({ column }) => { @@ -146,7 +170,11 @@ export const InterviewColumns = (): ColumnDef< return Not exported; } - return ; + return ( +
+ +
+ ); }, }, ]; diff --git a/app/dashboard/_components/InterviewsTable/InterviewsTable.tsx b/app/dashboard/_components/InterviewsTable/InterviewsTable.tsx index a77701d9f..7e6ce7ad8 100644 --- a/app/dashboard/_components/InterviewsTable/InterviewsTable.tsx +++ b/app/dashboard/_components/InterviewsTable/InterviewsTable.tsx @@ -1,7 +1,7 @@ 'use client'; import { HardDriveUpload } from 'lucide-react'; -import { objectHash } from 'ohash'; +import { hash as objectHash } from 'ohash'; import { use, useMemo, useState } from 'react'; import { ActionsDropdown } from '~/app/dashboard/_components/InterviewsTable/ActionsDropdown'; import { InterviewColumns } from '~/app/dashboard/_components/InterviewsTable/Columns'; diff --git a/app/dashboard/_components/InterviewsTable/InterviewsTableServer.tsx b/app/dashboard/_components/InterviewsTable/InterviewsTableServer.tsx index 2817bbde1..da04088cb 100644 --- a/app/dashboard/_components/InterviewsTable/InterviewsTableServer.tsx +++ b/app/dashboard/_components/InterviewsTable/InterviewsTableServer.tsx @@ -10,7 +10,7 @@ export default function InterviewsTableServer() { return ( } + fallback={} > +
+ {count} +
+ {typeName} + + ); +} +function EdgeSummary({ color, count, typeName }: EdgeSummaryProps) { + const lightColorClass = cn( + 'fill-[var(--edge-color-seq-1)]', + color === 'edge-color-seq-1' && 'fill-[var(--edge-color-seq-1)]', + color === 'edge-color-seq-2' && 'fill-[var(--edge-color-seq-2)]', + color === 'edge-color-seq-3' && 'fill-[var(--edge-color-seq-3)]', + color === 'edge-color-seq-4' && 'fill-[var(--edge-color-seq-4)]', + color === 'edge-color-seq-5' && 'fill-[var(--edge-color-seq-5)]', + color === 'edge-color-seq-6' && 'fill-[var(--edge-color-seq-6)]', + color === 'edge-color-seq-7' && 'fill-[var(--edge-color-seq-7)]', + color === 'edge-color-seq-8' && 'fill-[var(--edge-color-seq-8)]', + color === 'edge-color-seq-9' && 'fill-[var(--edge-color-seq-9)]', + ); + + const darkColorClass = cn( + 'fill-[var(--edge-color-seq-1-dark)]', + color === 'edge-color-seq-1' && 'fill-[var(--edge-color-seq-1-dark)]', + color === 'edge-color-seq-2' && 'fill-[var(--edge-color-seq-2-dark)]', + color === 'edge-color-seq-3' && 'fill-[var(--edge-color-seq-3-dark)]', + color === 'edge-color-seq-4' && 'fill-[var(--edge-color-seq-4-dark)]', + color === 'edge-color-seq-5' && 'fill-[var(--edge-color-seq-5-dark)]', + color === 'edge-color-seq-6' && 'fill-[var(--edge-color-seq-6-dark)]', + color === 'edge-color-seq-7' && 'fill-[var(--edge-color-seq-7-dark)]', + color === 'edge-color-seq-8' && 'fill-[var(--edge-color-seq-8-dark)]', + color === 'edge-color-seq-9' && 'fill-[var(--edge-color-seq-9-dark)]', + ); + + return ( +
+
+ + + + + + + + + + + + +
+ + {typeName} ({count}) + +
+ ); +} + +const NetworkSummary = ({ + network, + codebook, +}: { + network: NcNetwork | null; + codebook: Codebook | null; +}) => { + if (!network || !codebook) { + return
No interview data
; + } + const nodeSummaries = Object.entries( + network.nodes?.reduce>((acc, node) => { + acc[node.type] = (acc[node.type] ?? 0) + 1; + return acc; + }, {}) ?? {}, + ).map(([nodeType, count]) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + const nodeInfo = codebook.node?.[nodeType]!; + return ( + + ); + }); + + const edgeSummaries = Object.entries( + network.edges?.reduce>((acc, edge) => { + acc[edge.type] = (acc[edge.type] ?? 0) + 1; + return acc; + }, {}) ?? {}, + ).map(([edgeType, count]) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain + const edgeInfo = codebook.edge?.[edgeType]!; + return ( + + ); + }); + + if (nodeSummaries.length === 0 && edgeSummaries.length === 0) { + return
No nodes or edges
; + } + + return ( +
+
{nodeSummaries}
+
{edgeSummaries}
+
+ ); +}; + +export default NetworkSummary; diff --git a/app/dashboard/interviews/page.tsx b/app/dashboard/interviews/page.tsx index 7f644a283..e5bac4325 100644 --- a/app/dashboard/interviews/page.tsx +++ b/app/dashboard/interviews/page.tsx @@ -17,7 +17,7 @@ export default async function InterviewPage() { subHeaderText="View and manage your interview data." /> - +
diff --git a/components/ResponsiveContainer.tsx b/components/ResponsiveContainer.tsx index 0a25cc19b..0eb87c2b2 100644 --- a/components/ResponsiveContainer.tsx +++ b/components/ResponsiveContainer.tsx @@ -10,6 +10,7 @@ const containerVariants = cva('mx-auto flex flex-col my-6 md:my-10 ', { '5xl': 'max-w-5xl', '6xl': 'max-w-6xl', '7xl': 'max-w-7xl', + '8xl': 'max-w-8xl', }, baseSize: { '60%': 'w-[60%]', diff --git a/components/ui/TimeAgo.tsx b/components/ui/TimeAgo.tsx index 84674cd97..d9855c2a9 100644 --- a/components/ui/TimeAgo.tsx +++ b/components/ui/TimeAgo.tsx @@ -1,12 +1,12 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { dateOptions } from '~/fresco.config'; import { withNoSSRWrapper } from '~/utils/NoSSRWrapper'; -type TimeAgoProps = { +type TimeAgoProps = React.TimeHTMLAttributes & { date: Date | string | number; }; -const TimeAgo: React.FC = ({ date: dateProp }) => { +const TimeAgo: React.FC = ({ date: dateProp, ...props }) => { const date = useMemo(() => new Date(dateProp), [dateProp]); const localisedDate = new Intl.DateTimeFormat( navigator.language, @@ -49,7 +49,7 @@ const TimeAgo: React.FC = ({ date: dateProp }) => { }, [date, localisedDate]); return ( -